Skip to content

Commit

Permalink
Add more overlapping tests. Harden AES overlaps. Ensure salsa works.
Browse files Browse the repository at this point in the history
  • Loading branch information
paulmillr committed Nov 30, 2024
1 parent b172df5 commit 4f97c0c
Show file tree
Hide file tree
Showing 7 changed files with 296 additions and 106 deletions.
11 changes: 6 additions & 5 deletions src/_micro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ unrolled loops (https://en.wikipedia.org/wiki/Loop_unrolling).
*/

function bytesToNumberLE(bytes: Uint8Array): bigint {
abytes(bytes);
return hexToNumber(bytesToHex(Uint8Array.from(bytes).reverse()));
}

Expand Down Expand Up @@ -254,7 +255,7 @@ function computeTag(
*/
export const xsalsa20poly1305 = /* @__PURE__ */ wrapCipher(
{ blockSize: 64, nonceLength: 24, tagLength: 16 },
function xsalsa20poly1305(key: Uint8Array, nonce: Uint8Array) {
function xsalsapoly(key: Uint8Array, nonce: Uint8Array) {
return {
encrypt(plaintext: Uint8Array) {
const m = concatBytes(new Uint8Array(32), plaintext);
Expand Down Expand Up @@ -290,16 +291,16 @@ export const _poly1305_aead =
const tagLength = 16;
return {
encrypt(plaintext: Uint8Array) {
const res = fn(key, nonce, plaintext, undefined, 1);
const tag = computeTag(fn, key, nonce, res, AAD);
return concatBytes(res, tag);
const data = fn(key, nonce, plaintext, undefined, 1); // stream from i=1
const tag = computeTag(fn, key, nonce, data, AAD);
return concatBytes(data, tag);
},
decrypt(ciphertext: Uint8Array) {
const passedTag = ciphertext.subarray(-tagLength);
const data = ciphertext.subarray(0, -tagLength);
const tag = computeTag(fn, key, nonce, data, AAD);
if (!equalBytes(tag, passedTag)) throw new Error('invalid poly1305 tag');
return fn(key, nonce, data, undefined, 1);
return fn(key, nonce, data, undefined, 1); // stream from i=1
},
};
};
Expand Down
7 changes: 7 additions & 0 deletions src/aes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
u32,
u8,
wrapCipher,
complexOverlapBytes,
overlapBytes,
} from './utils.js';

/*
Expand Down Expand Up @@ -232,6 +234,7 @@ function ctrCounter(xk: Uint32Array, nonce: Uint8Array, src: Uint8Array, dst?: U
abytes(src);
const srcLen = src.length;
dst = getOutput(srcLen, dst);
complexOverlapBytes(src, dst);
const ctr = nonce;
const c32 = u32(ctr);
// Fill block (empty, ctr=0)
Expand Down Expand Up @@ -360,6 +363,7 @@ function validateBlockEncrypt(plaintext: Uint8Array, pcks5: boolean, dst?: Uint8
outLen = outLen + left;
}
dst = getOutput(outLen, dst);
complexOverlapBytes(plaintext, dst);
const o = u32(dst);
return { b, o, out: dst };
}
Expand Down Expand Up @@ -418,6 +422,7 @@ export const ecb = /* @__PURE__ */ wrapCipher(
dst = getOutput(ciphertext.length, dst);
const toClean: (Uint8Array | Uint32Array)[] = [xk];
if (!isAligned32(ciphertext)) toClean.push((ciphertext = copyBytes(ciphertext)));
complexOverlapBytes(ciphertext, dst);
const b = u32(ciphertext);
const o = u32(dst);
for (let i = 0; i + 4 <= b.length; ) {
Expand Down Expand Up @@ -473,6 +478,7 @@ export const cbc = /* @__PURE__ */ wrapCipher(
const n32 = u32(_iv);
dst = getOutput(ciphertext.length, dst);
if (!isAligned32(ciphertext)) toClean.push((ciphertext = copyBytes(ciphertext)));
complexOverlapBytes(ciphertext, dst);
const b = u32(ciphertext);
const o = u32(dst);
// prettier-ignore
Expand Down Expand Up @@ -502,6 +508,7 @@ export const cfb = /* @__PURE__ */ wrapCipher(
abytes(src);
const srcLen = src.length;
dst = getOutput(srcLen, dst);
if (overlapBytes(src, dst)) throw new Error('overlapping src and dst not supported.');
const xk = expandKeyLE(key);
let _iv = iv;
const toClean: (Uint8Array | Uint32Array)[] = [xk];
Expand Down
9 changes: 6 additions & 3 deletions src/chacha.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,10 @@ export const _poly1305_aead =
encrypt(plaintext: Uint8Array, output?: Uint8Array) {
const plength = plaintext.length;
output = getOutput(plength + tagLength, output, false);
xorStream(key, nonce, plaintext, output, 1);
const tag = computeTag(xorStream, key, nonce, output.subarray(0, -tagLength), AAD);
output.set(plaintext);
const oPlain = output.subarray(0, -tagLength);
xorStream(key, nonce, oPlain, oPlain, 1);
const tag = computeTag(xorStream, key, nonce, oPlain, AAD);
output.set(tag, plength); // append tag
clean(tag);
return output;
Expand All @@ -252,7 +254,8 @@ export const _poly1305_aead =
const passedTag = ciphertext.subarray(-tagLength);
const tag = computeTag(xorStream, key, nonce, data, AAD);
if (!equalBytes(passedTag, tag)) throw new Error('invalid tag');
xorStream(key, nonce, data, output, 1);
output.set(ciphertext.subarray(0, -tagLength));
xorStream(key, nonce, output, output, 1); // start stream with i=1
clean(tag);
return output;
},
Expand Down
54 changes: 25 additions & 29 deletions src/salsa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,42 +121,38 @@ export const xsalsa20 = /* @__PURE__ */ createCipher(salsaCore, {
export const xsalsa20poly1305 = /* @__PURE__ */ wrapCipher(
{ blockSize: 64, nonceLength: 24, tagLength: 16 },
(key: Uint8Array, nonce: Uint8Array): Cipher => {
const tagLength = 16;
return {
encrypt(plaintext: Uint8Array, output?: Uint8Array) {
// This is small optimization (calculate auth key with same call as encryption itself) makes it hard
// to separate tag calculation and encryption itself, since 32 byte is half-block of salsa (64 byte)
output = getOutput(plaintext.length + 32, output, false);
// xsalsa20poly1305 optimizes by calculating auth key during the same call as encryption.
// Unfortunately, makes it hard to separate tag calculation & encryption itself,
// because 32 bytes is half-block of 64-byte salsa.
output = getOutput(plaintext.length + 32, output, false); // need 32 additional bytes, see above
const authKey = output.subarray(0, 32); // output[0..32] = poly1305 auth key
const ciphPlaintext = output.subarray(32); // output[32..] = plaintext, then ciphertext
output.set(plaintext, 32);
xsalsa20(key, nonce, output, output);
const authKey = output.subarray(0, 32);
const tag = poly1305(output.subarray(32), authKey);
// Clean auth key, even though JS provides no guarantees about memory cleaning
output.set(tag, tagLength);
clean(output.subarray(0, tagLength), tag);
return output.subarray(tagLength);
clean(authKey); // authKey is produced by xoring with zeros
xsalsa20(key, nonce, output, output); // output = stream ^ output; authKey = stream ^ zeros(32)
const tag = poly1305(ciphPlaintext, authKey); // calculate tag over ciphertext
output.set(tag, 16); // output[16..32] = tag
clean(output.subarray(0, 16), tag); // clean-up authKey remnants & copy of tag
return output.subarray(16); // return output[16..]
},
decrypt(ciphertext: Uint8Array, output?: Uint8Array) {
// tmp part passed tag ciphertext
// [0..32] [32..48] [48..]
abytes(ciphertext);
output = getOutput(ciphertext.length + 32, output, false);
// Create new ciphertext array:
// tmp part auth tag ciphertext
// [bytes 0..32] [bytes 32..48] [bytes 48..]
// 16 instead of 32, because we already have 16 byte tag
output.set(ciphertext, 32);
// Each xsalsa20 calls to hsalsa to calculate key, but seems not much perf difference
// Separate call to calculate authkey, since first bytes contains tag
// Here we use first 32 bytes for authKey
const authKeyBuf = output.subarray(0, 32);
clean(authKeyBuf);
const authKey = xsalsa20(key, nonce, authKeyBuf, authKeyBuf);
const tag = poly1305(output.subarray(48), authKey); // alloc
if (!equalBytes(output.subarray(32, 48), tag)) throw new Error('invalid tag');
// NOTE: first 32 bytes skipped (used for authKey)
xsalsa20(key, nonce, output.subarray(16), output.subarray(16));
// Cleanup
clean(output.subarray(0, 32 + 16), tag);
return output.subarray(32 + 16);
const tmp = output.subarray(0, 32); // output[0..32] is used to calc authKey
const passedTag = output.subarray(32, 48); // output[32..48] = passed tag
const ciphPlaintext = output.subarray(48); // output[48..] = ciphertext, then plaintext
output.set(ciphertext, 32); // copy ciphertext into output
clean(tmp); // authKey is produced by xoring with zeros
const authKey = xsalsa20(key, nonce, tmp, tmp); // authKey = stream ^ zeros(32)
const tag = poly1305(ciphPlaintext, authKey); // calculate tag over ciphertext
if (!equalBytes(passedTag, tag)) throw new Error('invalid tag');
xsalsa20(key, nonce, output.subarray(16), output.subarray(16)); // output = stream ^ output[16..]
clean(tmp, passedTag, tag);
return ciphPlaintext; // return output[48..], skipping zeroized output[0..48]
},
};
}
Expand Down
22 changes: 15 additions & 7 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,17 @@ export function overlapBytes(a: Uint8Array, b: Uint8Array): boolean {
);
}

/**
* If input and output overlap and input starts before output, we will overwrite end of input before
* we start processing it, so this is not supported for most ciphers (except chacha/salse, which designed with this)
*/
export function complexOverlapBytes(input: Uint8Array, output: Uint8Array) {
// This is very cursed. It works somehow, but I'm completely unsure,
// reasoning about overlapping aligned windows is very hard.
if (overlapBytes(input, output) && input.byteOffset < output.byteOffset)
throw new Error('complex overlap of input and output is not supported');
}

/**
* Copies several Uint8Arrays into one.
*/
Expand Down Expand Up @@ -248,30 +259,27 @@ export const wrapCipher = <C extends CipherCons<any>, P extends CipherParams>(
}

const cipher = constructor(key, ...args);
const checkOutput = (fnLength: number, data: Uint8Array, output?: Uint8Array) => {
const checkOutput = (fnLength: number, output?: Uint8Array) => {
if (output !== undefined) {
if (fnLength !== 2) throw new Error('cipher output not supported');
abytes(output);
if (overlapBytes(data, output))
throw new Error('input and output use same buffer and overlap');
}
};

// Create wrapped cipher with validation and single-use encryption
let called = false;
const wrCipher = {
encrypt(data: Uint8Array, output?: Uint8Array) {
if (called) throw new Error('cannot encrypt() twice with same key + nonce');
called = true;
abytes(data);
checkOutput(cipher.encrypt.length, data, output);
checkOutput(cipher.encrypt.length, output);
return (cipher as CipherWithOutput).encrypt(data, output);
},
decrypt(data: Uint8Array, output?: Uint8Array) {
abytes(data);
checkOutput(cipher.decrypt.length, data, output);
if (tagl && data.length < tagl)
throw new Error('invalid ciphertext length: smaller than tagLength=' + tagl);
checkOutput(cipher.decrypt.length, output);
return (cipher as CipherWithOutput).decrypt(data, output);
},
};
Expand All @@ -296,7 +304,7 @@ export function getOutput(expectedLength: number, out?: Uint8Array, onlyAligned
if (out.length !== expectedLength)
throw new Error('invalid output length, expected ' + expectedLength + ', got: ' + out.length);
if (onlyAligned && !isAligned32(out)) throw new Error('invalid output, must be aligned');
return out.fill(0);
return out;
}

// Polyfill for Safari 14
Expand Down
Loading

0 comments on commit 4f97c0c

Please sign in to comment.