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:
Attacker has access to the cipherTextU8Ary.
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.
Attacker does not know neither the plainPass nor its length.
Attacker does not know any other part of the plainText.
Questions:
Is the derivedOTP a One Time Password / One Time Pad at all in any aspect?
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?
Does the fact that a part of the plainText (e.g. "Hello World") is known changes anything?
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?
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?
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?
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?
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.
getDerivedOTPfunction? $\endgroup$