0
$\begingroup$

Currently, I am using AES-GCM to encode and decode a plain text file. The key is derived from a plain text password using PBKDF2 and a random salt with SHA-256. However, I have been thinking and came to the conclusion that the resulting ciphertext is only as secure as the plain password and its entropy, plus the number of iterations of PBKDF2 to slow things down a little bit. Even if the AES-GCM key itself cannot be brute-forced, the security of the ciphertext is still dependent on the strength of the password.

I think I fail to understand of what additional protection offers AES-GCM in oppose to e.g. simple XORing using the same plain password as an input. I have made a short javascript mock up below that uses One Time Password? (One Time Pad? - although not truly random) derived from the plain password to XOR the plain text. I understand that inventing custom cryptographic systems is dangerous and the hashing functions have not been designed to be a cipher, but where exactly is the danger? I included assumptions and some detailed questions, and I would truly appreciate if someone explains what I am missing, why the below setup could be unsafe and what are the advantages of using the AES-GCM? ...Or am I getting it all wrong?

const u8AryFrom = data => new Uint8Array(data);
const getKeyMaterial = passU8Ary => window.crypto.subtle.importKey
(
    "raw",
    passU8Ary,
    "PBKDF2",
    false,
    ["deriveBits", "deriveKey"]
);
const deriveBits = async (passU8Ary, saltU8Ary, pbkfd2Loops, length) => window.crypto.subtle.deriveBits
(
    {
      "name": "PBKDF2",
      salt: saltU8Ary,
      "iterations": pbkfd2Loops,
      "hash": "SHA-256"
    },
    await getKeyMaterial(passU8Ary),
    length
);
const getDigest = dataU8Ary => window.crypto.subtle.digest('SHA-256', dataU8Ary);

const getDerivedOTP = async (plainPassU8Ary, passSaltU8Ary, plainTextLength) => {
    const concatCredentialsU8Ary = u8AryFrom([...plainPassU8Ary, ...passSaltU8Ary]); // || (Q.8) u8AryFrom(await deriveBits(plainPassU8Ary, passSaltU8Ary, 600000, 256));
    let otpAry = [...u8AryFrom(await getDigest(concatCredentialsU8Ary))];
    otpAry.reverse().splice(plainPassU8Ary.length); // (Q6.)
    while (otpAry.length < plainTextLength){
        const derivativeConcatU8Ary = u8AryFrom([...otpAry, ...concatCredentialsU8Ary]);
        const derivativeOtpAry = [...u8AryFrom(await getDigest(derivativeConcatU8Ary))];
        derivativeOtpAry.reverse().splice(plainPassU8Ary.length); // (Q6.)
        otpAry = [...otpAry, ...derivativeOtpAry];
    }
    otpAry.splice(plainTextLength);
    return otpAry
}

async function encodePlainTextU8Ary(plainTextU8Ary, plainPass){
    const plainPassU8Ary = new TextEncoder().encode(plainPass);
    const passSaltU8Ary = window.crypto.getRandomValues(u8AryFrom(32)); // 256 bits
    const derivedOTP = await getDerivedOTP(plainPassU8Ary, passSaltU8Ary, plainTextU8Ary.length);
    const encodedTextU8Ary = plainTextU8Ary.map((plainTextByte, idx) =>  plainTextByte ^ derivedOTP[idx]);
    const cipherTextU8Ary = u8AryFrom([...passSaltU8Ary, ...encodedTextU8Ary]);
    return cipherTextU8Ary; 
}

async function decodeCipherTextU8Ary(cipherTextU8Ary, plainPass){
    const plainPassU8Ary = new TextEncoder().encode(plainPass);
    const passSaltU8Ary = cipherTextU8Ary.slice(0, 32); // 256 bits of the passSalt 
    const encodedTextU8Ary = cipherTextU8Ary.slice(32); // and the rest is the encodedText
    const derivedOTP = await getDerivedOTP(plainPassU8Ary, passSaltU8Ary, encodedTextU8Ary.length);
    const decodedTextU8Ary = encodedTextU8Ary.map((encodedTextByte, idx) => encodedTextByte ^ derivedOTP[idx]);
    return decodedTextU8Ary;
}

async function encodeAndDecode(){
    const randText = "This is a secret text. "; // 23 bytes
    const plainText = Array(10).fill(randText).join("") + "Hello World"; // (A2.c, Q3., Q4., Q5.) 230 bytes of secret + 88 bits of known plain text
    const plainTextU8Ary = new TextEncoder().encode(plainText);
    console.log(plainText);
    const plainPassEncode = "password";
    const cipherTextU8Ary = await encodePlainTextU8Ary(plainTextU8Ary, plainPassEncode);// || (Q5.) re-ecode (* 2 || * n), e.g.: await encodePlainTextU8Ary(await encodePlainTextU8Ary(plainTextU8Ary, plainPassEncode), plainPassEncode); 
    console.log("cipherTextU8Ary.length:", cipherTextU8Ary.length);
    
    const plainPassDecode = "unknown";
    const decodedTextU8Ary = await decodeCipherTextU8Ary(cipherTextU8Ary, plainPassDecode);
    const decodedPlainText = new TextDecoder().decode(decodedTextU8Ary);
    console.log(decodedPlainText);
}
encodeAndDecode();

Assumptions:

  1. Attacker has access to the cipherTextU8Ary.

  2. Attacker has access to the above code, therefore knows:

    a) that the first 256 bits of the cipherTextU8Ary is the passSaltAry.

    b) that the remaining part of the cipherTextU8Ary is the encodedTextAry and therefore also knows its length.

    c) that the last 11 bytes of the encodedTextAry will XOR to "Hello World", therefore knows the last 88 bits of the derivedOTP.

    d) the method of deriving the derivedOTP.

  3. Attacker does not know neither the plainPass nor its length.

  4. Attacker does not know any other part of the plainText.

Questions:

  1. Is the derivedOTP a One Time Password / One Time Pad at all in any aspect?

  2. Excluding guessing / brute forcing of the plainPass:

    a) How the Attacker can learn about the rest of the derivedOTP?

    b) How the Attacker can learn about the rest of the plainText?

    c) How the Attacker can learn about the plainPass?

  3. Does the fact that a part of the plainText (e.g. "Hello World") is known changes anything?

  4. How probable is that XORing the encodedText using a different plainPass will yield the "Hello World" part but the rest of the plainText would remain secret?

  5. Will brute forcing of the plainPass be only feasible if the part of the plainText (e.g. "Hello World") is known? Will anything change if the cipherTextU8Ary is encoded again (twice or n-times) using another random(ish) passSaltU8Ary?

  6. Does the fact that the derivedOTP hash is reversed and truncated at each iteration to the arbitrary (but unknown to the Attacker) length (plainPass length - between let say 8 to 32 bytes) has any positive or negative effect on the security of the above setup?

  7. Does increasing the plainPass entropy would significantly increase the security of the encodedText (beyond the increased difficulty of guessing / brute forcing the plainPass)? e.g., would a plainPass with 256 bits of entropy (Math.log2(256**32)) made the cipherTextU8Ary computationally over expensive for the Attacker?

  8. Does increasing the brute forcing cost of the concatCredentialsU8Ary would significantly increase the security? i.e., using the e.g. derive crypto.subtle.deriveBits with e.g. 600K of PBKDF2 on the concatCredentialsU8Ary instead of just concatenating it?

As asked by @MaartenBodewes, below is a short explanation of the getDerivedOTP function:

The function takes a plain password and salt as input. The function concatenates the plain password and salt arrays into a new array called concatCredentialsU8Ary. It then generates a digest of the concatenated array using the SHA-256 hash function and stores it in an array called otpAry. The function enters a loop that continues until the length of otpAry is equal to plainTextLength. In each iteration of the loop, the function concatenates the otpAry and concatCredentialsU8Ary arrays into a new array called derivativeConcatU8Ary. It generates a digest of the derivativeConcatU8Ary array using the SHA-256 hash function and stores it in an array called derivativeOtpAry. Then it concatenates the otpAry and derivativeOtpAry arrays and stores the result in otpAry. Once the length of otpAry is greater than the plainTextLength, the function removes the elements after the plainTextLength index from the otpAry array. Finally, the function returns the otpAry array.

$\endgroup$
1
  • $\begingroup$ Can you please add a description of the getDerivedOTP function? $\endgroup$
    – Maarten Bodewes
    Nov 5, 2023 at 20:55

1 Answer 1

2
$\begingroup$
  1. Is the derivedOTP a One Time Password / One Time Pad at all in any aspect?

No. It's a stream cipher if it produces a key stream that is XOR'ed with the plaintext. That the code buffers all of it is inefficient but any stream cipher data can be buffered.

  1. Excluding guessing / brute forcing of the plainPass (ABC):

Very simply said, this seems like a digest based stream cipher. Although this construction seems very labor intensive, it might well be secure. As long as the state is kept safe from leaking it probably is.

  1. Does the fact that a part of the plainText (e.g. "Hello World") is known changes anything?

Not if the stream cipher is well designed.

  1. How probable is that XORing the encodedText using a different plainPass will yield the "Hello World" part but the rest of the plainText would remain secret?

100% if the stream cipher is well designed.

Will brute forcing of the plainPass be only feasible if the part of the plainText (e.g. "Hello World") is known? Will anything change if the cipherTextU8Ary is encoded again (twice or n-times) using another random(ish) passSaltU8Ary?

The more is known about the plaintext the better. But for e.g. ASCII we already know that the first bit of every byte should be zero. If there are 128 characters then that fact alone already allows an adversary will know with a 1 in $2^{128}$ certainty that the derived key is correct.

  1. Does the fact that the derivedOTP hash is reversed and truncated at each iteration to the arbitrary (but unknown to the Attacker) length (plainPass length - between let say 8 to 32 bytes) has any positive or negative effect on the security of the above setup?

Generally the attacker does at least know that this is happening. Maybe they don't know the length, but the fact that they have to guess that anyway to get to the key makes this point moot. So there are no advantages to it. Worse, it makes the stream cipher plaintext aware. That may introduce side channel attacks as well as making pre-computation impossible.

  1. Does increasing the plainPass entropy would significantly increase the security of the encodedText (beyond the increased difficulty of guessing / brute forcing the plainPass)? e.g., would a plainPass with 256 bits of entropy (Math.log2(256**32)) made the cipherTextU8Ary computationally over expensive for the Attacker?

Yes, but the reason that we don't use keys as passwords is that they are impossible to remember. If you take the time to enter e.g. 64 hexadecimals you might as well use a key.

  1. Does increasing the brute forcing cost of the concatCredentialsU8Ary would significantly increase the security? i.e., using the e.g. derive crypto.subtle.deriveBits with e.g. 600K of PBKDF2 on the concatCredentialsU8Ary instead of just concatenating it?

Anything you do after generating the first block basically costs the user but not the adversary.


All in all you've badly designed a stream cipher. It is not very performant, the additional security you've added probably decreases security against side channels, it doesn't offer integrity or authenticity as described. I don't see an IV which means that you shouldn't reuse the calculated key.

"I understand that inventing custom cryptographic systems is dangerous and the hashing functions have not been designed to be a cipher"

Yes, it is dangerous and they almost never offer any advantages. If you want to create a secure cryptosystem there are many, many challenges. AES - in any mode other than ECB - is generally not the problem, and there is no advantage in trying to replace it.

$\endgroup$
2
  • $\begingroup$ Maarten, your answer has been incredibly informative. After a bit more research following your feedback, I realised how a lack of understanding of cryptography systems can lead to disastrous consequences. I must admit, I feel a bit ashamed for asking what might have seemed like childish questions at first. However, on the other hand, without asking, I might not have done any research or connected theoretical concepts to practical applications. So, in a way, my poor but unintentional attempt at “reinventing the wheel” helped me. Thank you again for your insights. $\endgroup$
    – Hubert
    Nov 8, 2023 at 11:32
  • $\begingroup$ You are very welcome. Better to first ask than to move and implement :) $\endgroup$
    – Maarten Bodewes
    Nov 8, 2023 at 16:44

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.