diff --git a/src/_arx.ts b/src/_arx.ts new file mode 100644 index 0000000..2c7a101 --- /dev/null +++ b/src/_arx.ts @@ -0,0 +1,214 @@ +// Basic utils for ARX (add-rotate-xor) salsa and chacha ciphers. +import { number as anumber, bytes as abytes, bool as abool } from './_assert.js'; +import { XorStream, checkOpts, u32, utf8ToBytes } from './utils.js'; + +/* +RFC8439 requires multi-step cipher stream, where +authKey starts with counter: 0, actual msg with counter: 1. + +For this, we need a way to re-use nonce / counter: + + const counter = new Uint8Array(4); + chacha(..., counter, ...); // counter is now 1 + chacha(..., counter, ...); // counter is now 2 + +This is complicated: + +- 32-bit counters are enough, no need for 64-bit: max ArrayBuffer size in JS is 4GB +- Original papers don't allow mutating counters +- Counter overflow is undefined [^1] +- Idea A: allow providing (nonce | counter) instead of just nonce, re-use it +- Caveat: Cannot be re-used through all cases: +- * chacha has (counter | nonce) +- * xchacha has (nonce16 | counter | nonce16) +- Idea B: separate nonce / counter and provide separate API for counter re-use +- Caveat: there are different counter sizes depending on an algorithm. +- salsa & chacha also differ in structures of key & sigma: + salsa20: s[0] | k(4) | s[1] | nonce(2) | ctr(2) | s[2] | k(4) | s[3] + chacha: s(4) | k(8) | ctr(1) | nonce(3) + chacha20orig: s(4) | k(8) | ctr(2) | nonce(2) +- Idea C: helper method such as `setSalsaState(key, nonce, sigma, data)` +- Caveat: we can't re-use counter array + +xchacha [^2] uses the subkey and remaining 8 byte nonce with ChaCha20 as normal +(prefixed by 4 NUL bytes, since [RFC8439] specifies a 12-byte nonce). + +[^1]: https://mailarchive.ietf.org/arch/msg/cfrg/gsOnTJzcbgG6OqD8Sc0GO5aR_tU/ +[^2]: https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-xchacha#appendix-A.2 +*/ + +const sigma16 = utf8ToBytes('expand 16-byte k'); +const sigma32 = utf8ToBytes('expand 32-byte k'); +const sigma16_32 = u32(sigma16); +const sigma32_32 = u32(sigma32); + +export type CipherCoreFn = ( + sigma: Uint32Array, + key: Uint32Array, + nonce: Uint32Array, + output: Uint32Array, + counter: number, + rounds?: number +) => void; + +export type ExtendNonceFn = ( + sigma: Uint32Array, + key: Uint8Array, + input: Uint8Array, + out: Uint8Array +) => Uint8Array; + +export type CipherOpts = { + allowShortKeys?: boolean; // Original salsa / chacha allow 16-byte keys + extendNonceFn?: ExtendNonceFn; + counterLength?: number; + counterRight?: boolean; // right: nonce|counter; left: counter|nonce + rounds?: number; +}; + +// Is byte array aligned to 4 byte offset (u32)? +function isAligned32(b: Uint8Array) { + return b.byteOffset % 4 === 0; +} + +// Salsa and Chacha block length is always 512-bit +const BLOCK_LEN = 64; +const BLOCK_LEN32 = 16; + +// new Uint32Array([2**32]) // => Uint32Array(1) [ 0 ] +// new Uint32Array([2**32-1]) // => Uint32Array(1) [ 4294967295 ] +const MAX_COUNTER = 2 ** 32 - 1; + +const U32_EMPTY = new Uint32Array(); +function runCipher( + core: CipherCoreFn, + sigma: Uint32Array, + key: Uint8Array, + nonce: Uint8Array, + data: Uint8Array, + output: Uint8Array, + counter: number, + rounds: number +): void { + const key32 = u32(key); + const nonce32 = u32(nonce); + const len = data.length; + const block = new Uint8Array(BLOCK_LEN); + const b32 = u32(block); + // Make sure that buffers aligned to 4 bytes + const isAligned = isAligned32(data) && isAligned32(output); + const d32 = isAligned ? u32(data) : U32_EMPTY; + const o32 = isAligned ? u32(output) : U32_EMPTY; + for (let pos = 0; pos < len; counter++) { + core(sigma, key32, nonce32, b32, counter, rounds); + if (counter >= MAX_COUNTER) throw new Error('arx: counter overflow'); + const take = Math.min(BLOCK_LEN, len - pos); + // aligned to 4 bytes + if (isAligned && take === BLOCK_LEN) { + const pos32 = pos / 4; + if (pos % 4 !== 0) throw new Error('arx: invalid block position'); + for (let j = 0, posj: number; j < BLOCK_LEN32; j++) { + posj = pos32 + j; + o32[posj] = d32[posj] ^ b32[j]; + } + pos += BLOCK_LEN; + continue; + } + for (let j = 0, posj; j < take; j++) { + posj = pos + j; + output[posj] = data[posj] ^ block[j]; + } + pos += take; + } +} + +export function createCipher(core: CipherCoreFn, opts: CipherOpts): XorStream { + const { allowShortKeys, extendNonceFn, counterLength, counterRight, rounds } = checkOpts( + { allowShortKeys: false, counterLength: 8, counterRight: false, rounds: 20 }, + opts + ); + if (typeof core !== 'function') throw new Error('core must be a function'); + anumber(counterLength); + anumber(rounds); + abool(counterRight); + abool(allowShortKeys); + return ( + key: Uint8Array, + nonce: Uint8Array, + data: Uint8Array, + output?: Uint8Array, + counter = 0 + ): Uint8Array => { + abytes(key); + abytes(nonce); + abytes(data); + const len = data.length; + if (!output) output = new Uint8Array(len); + abytes(output); + anumber(counter); + if (counter < 0 || counter >= MAX_COUNTER) throw new Error('arx: counter overflow'); + if (output.length < len) + throw new Error(`arx: output (${output.length}) is shorter than data (${len})`); + const toClean = []; + + // Key & sigma + // + // key=16 -> sigma16, k=key|key + // key=32 -> sigma32, k=key + + let k: Uint8Array, sigma: Uint32Array; + if (key.length === 32) { + if (isAligned32(key)) k = key; + else { + // Align key to 4 bytes + k = key.slice(); + toClean.push(k); + } + sigma = sigma32_32; + } else if (key.length === 16 && allowShortKeys) { + k = new Uint8Array(32); + k.set(key); + k.set(key, 16); + sigma = sigma16_32; + toClean.push(k); + } else throw new Error(`arx: invalid 32-byte key, got length=${key.length}`); + + // Nonce + // + // salsa20: 8 (8-byte counter) + // chacha20orig: 8 (8-byte counter) + // chacha20: 12 (4-byte counter) + // xsalsa20: 24 (16 -> hsalsa, 8 -> old nonce) + // xchacha20: 24 (16 -> hchacha, 8 -> old nonce) + + // Align nonce to 4 bytes + if (!isAligned32(nonce)) { + nonce = nonce.slice(); + toClean.push(nonce); + } + + // hsalsa & hchacha: handle extended nonce + if (extendNonceFn) { + if (nonce.length !== 24) throw new Error(`arx: extended nonce must be 24 bytes`); + k = extendNonceFn(sigma, k, nonce.subarray(0, 16), new Uint8Array(32)); + toClean.push(k); + nonce = nonce.subarray(16); + } + + // Handle nonce counter + const nonceNcLen = 16 - counterLength; + if (nonceNcLen !== nonce.length) + throw new Error(`arx: nonce must be ${nonceNcLen} or 16 bytes`); + + // Pad counter when nonce is 64 bit + if (nonceNcLen !== 12) { + const nc = new Uint8Array(12); + nc.set(nonce, counterRight ? 0 : 12 - nonce.length); + nonce = nc; + toClean.push(nonce); + } + runCipher(core, sigma, k, nonce, data, output, counter, rounds); + while (toClean.length > 0) toClean.pop()!.fill(0); + return output; + }; +} diff --git a/src/_micro.ts b/src/_micro.ts index 4e22c1e..627fbb4 100644 --- a/src/_micro.ts +++ b/src/_micro.ts @@ -4,16 +4,20 @@ // Implements the same algorithms that are present in other files, // but without unrolled loops (https://en.wikipedia.org/wiki/Loop_unrolling). -import * as u from './utils.js'; -import { salsaBasic } from './_salsa.js'; +// prettier-ignore +import { + Cipher, XorStream, createView, setBigUint64, + bytesToNumberLE, concatBytes, ensureBytes, equalBytes, numberToBytesBE, u32, u8, +} from './utils.js'; +import { createCipher } from './_arx.js'; // Utils -function bytesToNumberLE(bytes: Uint8Array): bigint { - return u.hexToNumber(u.bytesToHex(Uint8Array.from(bytes).reverse())); -} +// function bytesToNumberLE(bytes: Uint8Array): bigint { +// return hexToNumber(bytesToHex(Uint8Array.from(bytes).reverse())); +// } function numberToBytesLE(n: number | bigint, len: number): Uint8Array { - return u.numberToBytesBE(n, len).reverse(); + return numberToBytesBE(n, len).reverse(); } const rotl = (a: number, b: number) => (a << b) | (a >>> (32 - b)); @@ -80,8 +84,8 @@ function salsaCore( } export function hsalsa(c: Uint32Array, key: Uint8Array, nonce: Uint8Array): Uint8Array { - const k = u.u32(key); - const i = u.u32(nonce); + const k = u32(key); + const i = u32(nonce); // prettier-ignore const x = new Uint32Array([ c[0], k[0], k[1], k[2], @@ -90,7 +94,7 @@ export function hsalsa(c: Uint32Array, key: Uint8Array, nonce: Uint8Array): Uint k[5], k[6], k[7], c[3] ]); salsaRound(x); - return u.u8(new Uint32Array([x[0], x[5], x[10], x[15], x[6], x[7], x[8], x[9]])); + return u8(new Uint32Array([x[0], x[5], x[10], x[15], x[6], x[7], x[8], x[9]])); } function chachaCore( @@ -114,8 +118,8 @@ function chachaCore( } export function hchacha(c: Uint32Array, key: Uint8Array, nonce: Uint8Array): Uint8Array { - const k = u.u32(key); - const i = u.u32(nonce); + const k = u32(key); + const i = u32(nonce); // prettier-ignore const x = new Uint32Array([ c[0], c[1], c[2], c[3], @@ -124,66 +128,62 @@ export function hchacha(c: Uint32Array, key: Uint8Array, nonce: Uint8Array): Uin i[0], i[1], i[2], i[3], ]); chachaRound(x); - return u.u8(new Uint32Array([x[0], x[1], x[2], x[3], x[12], x[13], x[14], x[15]])); + return u8(new Uint32Array([x[0], x[1], x[2], x[3], x[12], x[13], x[14], x[15]])); } /** * salsa20, 12-byte nonce. */ -export const salsa20 = salsaBasic({ core: salsaCore, counterRight: true }); +export const salsa20 = createCipher(salsaCore, { allowShortKeys: true, counterRight: true }); /** * xsalsa20, 24-byte nonce. */ -export const xsalsa20 = salsaBasic({ - core: salsaCore, +export const xsalsa20 = createCipher(salsaCore, { counterRight: true, extendNonceFn: hsalsa, - allow128bitKeys: false, }); /** * chacha20 non-RFC, original version by djb. 8-byte nonce, 8-byte counter. */ -export const chacha20orig = salsaBasic({ core: chachaCore, counterRight: false, counterLen: 8 }); +export const chacha20orig = createCipher(chachaCore, { + allowShortKeys: true, + counterRight: false, + counterLength: 8, +}); /** * chacha20 RFC 8439 (IETF / TLS). 12-byte nonce, 4-byte counter. */ -export const chacha20 = salsaBasic({ - core: chachaCore, +export const chacha20 = createCipher(chachaCore, { counterRight: false, - counterLen: 4, - allow128bitKeys: false, + counterLength: 4, }); /** * xchacha20 eXtended-nonce. https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-xchacha */ -export const xchacha20 = salsaBasic({ - core: chachaCore, +export const xchacha20 = createCipher(chachaCore, { counterRight: false, - counterLen: 8, + counterLength: 8, extendNonceFn: hchacha, - allow128bitKeys: false, }); /** * 8-round chacha from the original paper. */ -export const chacha8 = salsaBasic({ - core: chachaCore, +export const chacha8 = createCipher(chachaCore, { counterRight: false, - counterLen: 4, + counterLength: 4, rounds: 8, }); /** * 12-round chacha from the original paper. */ -export const chacha12 = salsaBasic({ - core: chachaCore, +export const chacha12 = createCipher(chachaCore, { counterRight: false, - counterLen: 4, + counterLength: 4, rounds: 12, }); @@ -191,8 +191,8 @@ const POW_2_130_5 = 2n ** 130n - 5n; const POW_2_128_1 = 2n ** (16n * 8n) - 1n; // Can be speed-up using BigUint64Array, but would be more complicated export function poly1305(msg: Uint8Array, key: Uint8Array): Uint8Array { - u.ensureBytes(msg); - u.ensureBytes(key); + ensureBytes(msg); + ensureBytes(key); let acc = 0n; const r = bytesToNumberLE(key.subarray(0, 16)) & 0x0ffffffc0ffffffc0ffffffc0fffffffn; const s = bytesToNumberLE(key.subarray(16)); @@ -207,7 +207,7 @@ export function poly1305(msg: Uint8Array, key: Uint8Array): Uint8Array { } function computeTag( - fn: typeof chacha20, + fn: XorStream, key: Uint8Array, nonce: Uint8Array, ciphertext: Uint8Array, @@ -224,37 +224,37 @@ function computeTag( if (leftover > 0) res.push(new Uint8Array(16 - leftover)); // Lengths const num = new Uint8Array(16); - const view = u.createView(num); - u.setBigUint64(view, 0, BigInt(AAD ? AAD.length : 0), true); - u.setBigUint64(view, 8, BigInt(ciphertext.length), true); + const view = createView(num); + setBigUint64(view, 0, BigInt(AAD ? AAD.length : 0), true); + setBigUint64(view, 8, BigInt(ciphertext.length), true); res.push(num); const authKey = fn(key, nonce, new Uint8Array(32)); - return poly1305(u.concatBytes(...res), authKey); + return poly1305(concatBytes(...res), authKey); } /** * xsalsa20-poly1305 eXtended-nonce (24 bytes) salsa. */ export function xsalsa20poly1305(key: Uint8Array, nonce: Uint8Array) { - u.ensureBytes(key); - u.ensureBytes(nonce); + ensureBytes(key); + ensureBytes(nonce); return { encrypt: (plaintext: Uint8Array) => { - u.ensureBytes(plaintext); - const m = u.concatBytes(new Uint8Array(32), plaintext); + ensureBytes(plaintext); + const m = concatBytes(new Uint8Array(32), plaintext); const c = xsalsa20(key, nonce, m); const authKey = c.subarray(0, 32); const data = c.subarray(32); const tag = poly1305(data, authKey); - return u.concatBytes(tag, data); + return concatBytes(tag, data); }, decrypt: (ciphertext: Uint8Array) => { - u.ensureBytes(ciphertext); + ensureBytes(ciphertext); if (ciphertext.length < 16) throw new Error('encrypted data must be at least 16 bytes'); - const c = u.concatBytes(new Uint8Array(16), ciphertext); + const c = concatBytes(new Uint8Array(16), ciphertext); const authKey = xsalsa20(key, nonce, new Uint8Array(32)); const tag = poly1305(c.subarray(32), authKey); - if (!u.equalBytes(c.subarray(16, 32), tag)) throw new Error('invalid poly1305 tag'); + if (!equalBytes(c.subarray(16, 32), tag)) throw new Error('invalid poly1305 tag'); return xsalsa20(key, nonce, c).subarray(32); }, }; @@ -264,35 +264,33 @@ export function xsalsa20poly1305(key: Uint8Array, nonce: Uint8Array) { * Alias to xsalsa20-poly1305 */ export function secretbox(key: Uint8Array, nonce: Uint8Array) { - u.ensureBytes(key); - u.ensureBytes(nonce); const xs = xsalsa20poly1305(key, nonce); return { seal: xs.encrypt, open: xs.decrypt }; } export const _poly1305_aead = - (fn: typeof chacha20) => - (key: Uint8Array, nonce: Uint8Array, AAD?: Uint8Array): u.Cipher => { + (fn: XorStream) => + (key: Uint8Array, nonce: Uint8Array, AAD?: Uint8Array): Cipher => { const tagLength = 16; const keyLength = 32; - u.ensureBytes(key, keyLength); - u.ensureBytes(nonce); + ensureBytes(key, keyLength); + ensureBytes(nonce); return { tagLength, encrypt: (plaintext: Uint8Array) => { - u.ensureBytes(plaintext); + ensureBytes(plaintext); const res = fn(key, nonce, plaintext, undefined, 1); const tag = computeTag(fn, key, nonce, res, AAD); - return u.concatBytes(res, tag); + return concatBytes(res, tag); }, decrypt: (ciphertext: Uint8Array) => { - u.ensureBytes(ciphertext); + ensureBytes(ciphertext); if (ciphertext.length < tagLength) throw new Error(`encrypted data must be at least ${tagLength} bytes`); const passedTag = ciphertext.subarray(-tagLength); const data = ciphertext.subarray(0, -tagLength); const tag = computeTag(fn, key, nonce, data, AAD); - if (!u.equalBytes(passedTag, tag)) throw new Error('invalid poly1305 tag'); + if (!equalBytes(passedTag, tag)) throw new Error('invalid poly1305 tag'); return fn(key, nonce, data, undefined, 1); }, }; diff --git a/src/_poly1305.ts b/src/_poly1305.ts index b362ef5..008276e 100644 --- a/src/_poly1305.ts +++ b/src/_poly1305.ts @@ -1,5 +1,5 @@ import { exists as aexists, output as aoutput } from './_assert.js'; -import { toBytes, Input, ensureBytes, Hash } from './utils.js'; +import { Input, ensureBytes, toBytes, Hash } from './utils.js'; // Poly1305 is a fast and parallel secret-key message-authentication code. // https://cr.yp.to/mac.html, https://cr.yp.to/mac/poly1305-20050329.pdf diff --git a/src/_salsa.ts b/src/_salsa.ts deleted file mode 100644 index 85ae120..0000000 --- a/src/_salsa.ts +++ /dev/null @@ -1,187 +0,0 @@ -// Basic utils for salsa-like ciphers -// Check out _micro.ts for descriptive documentation. -import { number as anumber, bytes as abytes, bool as abool } from './_assert.js'; -import { u32, utf8ToBytes, checkOpts } from './utils.js'; - -/* -RFC8439 requires multi-step cipher stream, where -authKey starts with counter: 0, actual msg with counter: 1. - -For this, we need a way to re-use nonce / counter: - - const counter = new Uint8Array(4); - chacha(..., counter, ...); // counter is now 1 - chacha(..., counter, ...); // counter is now 2 - -This is complicated: - -- Original papers don't allow mutating counters -- Counter overflow is undefined: https://mailarchive.ietf.org/arch/msg/cfrg/gsOnTJzcbgG6OqD8Sc0GO5aR_tU/ -- 3rd-party library stablelib implementation uses an approach where you can provide - nonce and counter instead of just nonce - and it will re-use it -- We could have did something similar, but ChaCha has different counter position - (counter | nonce), which is not composable with XChaCha, because full counter - is (nonce16 | counter | nonce16). Stablelib doesn't support in-place counter for XChaCha. -- We could separate nonce & counter and provide separate API for counter re-use, but - there are different counter sizes depending on an algorithm. -- Salsa & ChaCha also differ in structures of key / sigma: - - salsa: c0 | k(4) | c1 | nonce(2) | ctr(2) | c2 | k(4) | c4 - chacha: c(4) | k(8) | ctr(1) | nonce(3) - chachaDJB: c(4) | k(8) | ctr(2) | nonce(2) -- Creating function such as `setSalsaState(key, nonce, sigma, data)` won't work, - because we can't re-use counter array -- 32-bit nonce is `2 ** 32 * 64` = 256GB with 32-bit counter -- JS does not allow UintArrays bigger than 4GB, so supporting 64-bit counters doesn't matter - -Structure is as following: - -key=16 -> sigma16, k=key|key -key=32 -> sigma32, k=key - -nonces: -salsa20: 8 (8-byte counter) -chacha20djb: 8 (8-byte counter) -chacha20tls: 12 (4-byte counter) -xsalsa: 24 (16 -> hsalsa, 8 -> old nonce) -xchacha: 24 (16 -> hchacha, 8 -> old nonce) - -https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-xchacha#appendix-A.2 -Use the subkey and remaining 8 byte nonce with ChaCha20 as normal -(prefixed by 4 NUL bytes, since [RFC8439] specifies a 12-byte nonce). -*/ - -const sigma16 = utf8ToBytes('expand 16-byte k'); -const sigma32 = utf8ToBytes('expand 32-byte k'); -const sigma16_32 = u32(sigma16); -const sigma32_32 = u32(sigma32); - -export type SalsaOpts = { - core: ( - c: Uint32Array, - key: Uint32Array, - nonce: Uint32Array, - out: Uint32Array, - counter: number, - rounds?: number - ) => void; - rounds?: number; - counterRight?: boolean; // counterRight ? nonce | counter : counter | nonce; - counterLen?: number; - blockLen?: number; // NOTE: not tested with different blockLens! - allow128bitKeys?: boolean; // Original salsa/chacha allows these, but not tested! - extendNonceFn?: (c: Uint32Array, key: Uint8Array, src: Uint8Array, dst: Uint8Array) => Uint8Array; -}; - -// Is byte array aligned to 4 byte offset (u32)? -const isAligned32 = (b: Uint8Array) => !(b.byteOffset % 4); - -const MAX_COUNTER = 2 ** 32 - 1; - -export const salsaBasic = (opts: SalsaOpts) => { - const { core, rounds, counterRight, counterLen, allow128bitKeys, extendNonceFn, blockLen } = - checkOpts( - { rounds: 20, counterRight: false, counterLen: 8, allow128bitKeys: true, blockLen: 64 }, - opts - ); - anumber(counterLen); - anumber(rounds); - anumber(blockLen); - abool(counterRight); - abool(allow128bitKeys); - const blockLen32 = blockLen / 4; - if (blockLen % 4 !== 0) throw new Error('Salsa/ChaCha: blockLen must be aligned to 4 bytes'); - return ( - key: Uint8Array, - nonce: Uint8Array, - data: Uint8Array, - output?: Uint8Array, - counter = 0 - ): Uint8Array => { - abytes(key); - abytes(nonce); - abytes(data); - if (!output) output = new Uint8Array(data.length); - abytes(output); - anumber(counter); - // > new Uint32Array([2**32]) - // Uint32Array(1) [ 0 ] - // > new Uint32Array([2**32-1]) - // Uint32Array(1) [ 4294967295 ] - if (counter < 0 || counter >= MAX_COUNTER) throw new Error('Salsa/ChaCha: counter overflow'); - if (output.length < data.length) { - throw new Error( - `Salsa/ChaCha: output (${output.length}) is shorter than data (${data.length})` - ); - } - const toClean = []; - let k, sigma; - // Handle 128 byte keys - if (key.length === 32) { - if (isAligned32(key)) k = key; - else { - // Align key to 4 bytes - k = key.slice(); - toClean.push(k); - } - sigma = sigma32_32; - } else if (key.length === 16 && allow128bitKeys) { - k = new Uint8Array(32); - k.set(key); - k.set(key, 16); - sigma = sigma16_32; - toClean.push(k); - } else throw new Error(`Salsa/ChaCha: invalid 32-byte key, got length=${key.length}`); - // Align nonce to 4 bytes - if (!isAligned32(nonce)) { - nonce = nonce.slice(); - toClean.push(nonce); - } - // Handle extended nonce (HChaCha/HSalsa) - if (extendNonceFn) { - if (nonce.length <= 16) - throw new Error(`Salsa/ChaCha: extended nonce must be bigger than 16 bytes`); - k = extendNonceFn(sigma, k, nonce.subarray(0, 16), new Uint8Array(32)); - toClean.push(k); - nonce = nonce.subarray(16); - } - // Handle nonce counter - const nonceLen = 16 - counterLen; - if (nonce.length !== nonceLen) - throw new Error(`Salsa/ChaCha: nonce must be ${nonceLen} or 16 bytes`); - // Pad counter when nonce is 64 bit - if (nonceLen !== 12) { - const nc = new Uint8Array(12); - nc.set(nonce, counterRight ? 0 : 12 - nonce.length); - toClean.push((nonce = nc)); - } - // Counter positions - const block = new Uint8Array(blockLen); - // Cast to Uint32Array for speed - const b32 = u32(block); - const k32 = u32(k); - const n32 = u32(nonce); - // Make sure that buffers aligned to 4 bytes - const d32 = isAligned32(data) && u32(data); - const o32 = isAligned32(output) && u32(output); - toClean.push(b32); - const len = data.length; - for (let pos = 0, ctr = counter; pos < len; ctr++) { - core(sigma, k32, n32, b32, ctr, rounds); - if (ctr >= MAX_COUNTER) throw new Error('Salsa/ChaCha: counter overflow'); - const take = Math.min(blockLen, len - pos); - // full block && aligned to 4 bytes - if (take === blockLen && o32 && d32) { - const pos32 = pos / 4; - if (pos % 4 !== 0) throw new Error('Salsa/ChaCha: invalid block position'); - for (let j = 0; j < blockLen32; j++) o32[pos32 + j] = d32[pos32 + j] ^ b32[j]; - pos += blockLen; - continue; - } - for (let j = 0; j < take; j++) output[pos + j] = data[pos + j] ^ block[j]; - pos += take; - } - for (let i = 0; i < toClean.length; i++) toClean[i].fill(0); - return output; - }; -}; diff --git a/src/chacha.ts b/src/chacha.ts index ad8a0d0..8ba6ae2 100644 --- a/src/chacha.ts +++ b/src/chacha.ts @@ -1,5 +1,6 @@ import { CipherWithOutput, + XorStream, createView, ensureBytes, equalBytes, @@ -7,7 +8,7 @@ import { u32, } from './utils.js'; import { poly1305 } from './_poly1305.js'; -import { salsaBasic } from './_salsa.js'; +import { createCipher } from './_arx.js'; // ChaCha20 stream cipher was released in 2008. ChaCha aims to increase // the diffusion per round, but had slightly less cryptanalysis. @@ -21,9 +22,9 @@ const rotl = (a: number, b: number) => (a << b) | (a >>> (32 - b)); */ // prettier-ignore function chachaCore( - c: Uint32Array, k: Uint32Array, n: Uint32Array, out: Uint32Array, cnt: number, rounds = 20 + s: Uint32Array, k: Uint32Array, n: Uint32Array, out: Uint32Array, cnt: number, rounds = 20 ): void { - let y00 = c[0], y01 = c[1], y02 = c[2], y03 = c[3]; // "expa" "nd 3" "2-by" "te k" + let y00 = s[0], y01 = s[1], y02 = s[2], y03 = s[3]; // "expa" "nd 3" "2-by" "te k" let y04 = k[0], y05 = k[1], y06 = k[2], y07 = k[3]; // Key Key Key Key let y08 = k[4], y09 = k[5], y10 = k[6], y11 = k[7]; // Key Key Key Key let y12 = cnt, y13 = n[0], y14 = n[1], y15 = n[2]; // Counter Counter Nonce Nonce @@ -93,12 +94,12 @@ function chachaCore( */ // prettier-ignore export function hchacha( - c: Uint32Array, key: Uint8Array, src: Uint8Array, out: Uint8Array + s: Uint32Array, key: Uint8Array, src: Uint8Array, out: Uint8Array ): Uint8Array { const k32 = u32(key); const i32 = u32(src); const o32 = u32(out); - let x00 = c[0], x01 = c[1], x02 = c[2], x03 = c[3]; + let x00 = s[0], x01 = s[1], x02 = s[2], x03 = s[3]; let x04 = k32[0], x05 = k32[1], x06 = k32[2], x07 = k32[3]; let x08 = k32[4], x09 = k32[5], x10 = k32[6], x11 = k32[7] let x12 = i32[0], x13 = i32[1], x14 = i32[2], x15 = i32[3]; @@ -156,20 +157,19 @@ export function hchacha( /** * Original, non-RFC chacha20 from DJB. 8-byte nonce, 8-byte counter. */ -export const chacha20orig = /* @__PURE__ */ salsaBasic({ - core: chachaCore, +export const chacha20orig = /* @__PURE__ */ createCipher(chachaCore, { counterRight: false, - counterLen: 8, + counterLength: 8, + allowShortKeys: true, }); /** * ChaCha stream cipher. Conforms to RFC 8439 (IETF, TLS). 12-byte nonce, 4-byte counter. * With 12-byte nonce, it's not safe to use fill it with random (CSPRNG), due to collision chance. */ -export const chacha20 = /* @__PURE__ */ salsaBasic({ - core: chachaCore, +export const chacha20 = /* @__PURE__ */ createCipher(chachaCore, { counterRight: false, - counterLen: 4, - allow128bitKeys: false, + counterLength: 4, + allowShortKeys: false, }); /** @@ -177,50 +177,48 @@ export const chacha20 = /* @__PURE__ */ salsaBasic({ * With 24-byte nonce, it's safe to use fill it with random (CSPRNG). * https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-xchacha */ -export const xchacha20 = /* @__PURE__ */ salsaBasic({ - core: chachaCore, +export const xchacha20 = /* @__PURE__ */ createCipher(chachaCore, { counterRight: false, - counterLen: 8, + counterLength: 8, extendNonceFn: hchacha, - allow128bitKeys: false, + allowShortKeys: false, }); /** * Reduced 8-round chacha, described in original paper. */ -export const chacha8 = /* @__PURE__ */ salsaBasic({ - core: chachaCore, +export const chacha8 = /* @__PURE__ */ createCipher(chachaCore, { counterRight: false, - counterLen: 4, + counterLength: 4, rounds: 8, }); /** * Reduced 12-round chacha, described in original paper. */ -export const chacha12 = /* @__PURE__ */ salsaBasic({ - core: chachaCore, +export const chacha12 = /* @__PURE__ */ createCipher(chachaCore, { counterRight: false, - counterLen: 4, + counterLength: 4, rounds: 12, }); -const ZERO = /* @__PURE__ */ new Uint8Array(16); +const ZEROS16 = /* @__PURE__ */ new Uint8Array(16); // Pad to digest size with zeros const updatePadded = (h: ReturnType, msg: Uint8Array) => { h.update(msg); const left = msg.length % 16; - if (left) h.update(ZERO.subarray(left)); + if (left) h.update(ZEROS16.subarray(left)); }; -const computeTag = ( - fn: typeof chacha20, +const ZEROS32 = /* @__PURE__ */ new Uint8Array(32); +function computeTag( + fn: XorStream, key: Uint8Array, nonce: Uint8Array, data: Uint8Array, AAD?: Uint8Array -) => { - const authKey = fn(key, nonce, new Uint8Array(32)); +): Uint8Array { + const authKey = fn(key, nonce, ZEROS32); const h = poly1305.create(authKey); if (AAD) updatePadded(h, AAD); updatePadded(h, data); @@ -232,7 +230,7 @@ const computeTag = ( const res = h.digest(); authKey.fill(0); return res; -}; +} /** * AEAD algorithm from RFC 8439. @@ -244,7 +242,7 @@ const computeTag = ( * In chacha, authKey can't be computed inside computeTag, it modifies the counter. */ export const _poly1305_aead = - (xorStream: typeof chacha20) => + (xorStream: XorStream) => (key: Uint8Array, nonce: Uint8Array, AAD?: Uint8Array): CipherWithOutput => { const tagLength = 16; ensureBytes(key, 32); diff --git a/src/salsa.ts b/src/salsa.ts index 3b390b5..2d3790c 100644 --- a/src/salsa.ts +++ b/src/salsa.ts @@ -1,6 +1,6 @@ import { Cipher, ensureBytes, equalBytes, u32 } from './utils.js'; import { poly1305 } from './_poly1305.js'; -import { salsaBasic } from './_salsa.js'; +import { createCipher } from './_arx.js'; // Salsa20 stream cipher was released in 2005. // Salsa's goal was to implement AES replacement that does not rely on S-Boxes, @@ -15,13 +15,13 @@ const rotl = (a: number, b: number) => (a << b) | (a >>> (32 - b)); */ // prettier-ignore function salsaCore( - c: Uint32Array, k: Uint32Array, i: Uint32Array, out: Uint32Array, cnt: number, rounds = 20 + s: Uint32Array, k: Uint32Array, i: Uint32Array, out: Uint32Array, cnt: number, rounds = 20 ): void { // Based on https://cr.yp.to/salsa20.html - let y00 = c[0], y01 = k[0], y02 = k[1], y03 = k[2]; // "expa" Key Key Key - let y04 = k[3], y05 = c[1], y06 = i[0], y07 = i[1]; // Key "nd 3" Nonce Nonce - let y08 = cnt, y09 = 0 , y10 = c[2], y11 = k[4]; // Pos. Pos. "2-by" Key - let y12 = k[5], y13 = k[6], y14 = k[7], y15 = c[3]; // Key Key Key "te k" + let y00 = s[0], y01 = k[0], y02 = k[1], y03 = k[2]; // "expa" Key Key Key + let y04 = k[3], y05 = s[1], y06 = i[0], y07 = i[1]; // Key "nd 3" Nonce Nonce + let y08 = cnt, y09 = 0 , y10 = s[2], y11 = k[4]; // Pos. Pos. "2-by" Key + let y12 = k[5], y13 = k[6], y14 = k[7], y15 = s[3]; // Key Key Key "te k" // Save state to temporary variables let x00 = y00, x01 = y01, x02 = y02, x03 = y03, x04 = y04, x05 = y05, x06 = y06, x07 = y07, @@ -66,15 +66,15 @@ function salsaCore( */ // prettier-ignore export function hsalsa( - c: Uint32Array, key: Uint8Array, nonce: Uint8Array, out: Uint8Array + s: Uint32Array, key: Uint8Array, nonce: Uint8Array, out: Uint8Array ): Uint8Array { const k32 = u32(key); const i32 = u32(nonce); const o32 = u32(out); - let x00 = c[0], x01 = k32[0], x02 = k32[1], x03 = k32[2], x04 = k32[3]; - let x05 = c[1], x06 = i32[0], x07 = i32[1], x08 = i32[2], x09 = i32[3]; - let x10 = c[2], x11 = k32[4], x12 = k32[5], x13 = k32[6], x14 = k32[7]; - let x15 = c[3]; + let x00 = s[0], x01 = k32[0], x02 = k32[1], x03 = k32[2], x04 = k32[3]; + let x05 = s[1], x06 = i32[0], x07 = i32[1], x08 = i32[2], x09 = i32[3]; + let x10 = s[2], x11 = k32[4], x12 = k32[5], x13 = k32[6], x14 = k32[7]; + let x15 = s[3]; // Main loop for (let i = 0; i < 20; i += 2) { x04 ^= rotl(x00 + x12 | 0, 7); x08 ^= rotl(x04 + x00 | 0, 9); @@ -109,17 +109,18 @@ export function hsalsa( * Salsa20 from original paper. * With 12-byte nonce, it's not safe to use fill it with random (CSPRNG), due to collision chance. */ -export const salsa20 = /* @__PURE__ */ salsaBasic({ core: salsaCore, counterRight: true }); +export const salsa20 = /* @__PURE__ */ createCipher(salsaCore, { + allowShortKeys: true, + counterRight: true, +}); /** * xsalsa20 eXtended-nonce salsa. * With 24-byte nonce, it's safe to use fill it with random (CSPRNG). */ -export const xsalsa20 = /* @__PURE__ */ salsaBasic({ - core: salsaCore, +export const xsalsa20 = /* @__PURE__ */ createCipher(salsaCore, { counterRight: true, extendNonceFn: hsalsa, - allow128bitKeys: false, }); /** diff --git a/src/utils.ts b/src/utils.ts index 87609f6..9ba1b9c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -78,6 +78,10 @@ export function bytesToNumberBE(bytes: Uint8Array): bigint { return hexToNumber(bytesToHex(bytes)); } +export function bytesToNumberLE(bytes: Uint8Array): bigint { + return hexToNumber(bytesToHex(Uint8Array.from(bytes).reverse())); +} + export function numberToBytesBE(n: number | bigint, len: number): Uint8Array { return hexToBytes(n.toString(16).padStart(len * 2, '0')); } @@ -193,6 +197,7 @@ export abstract class Hash> { // Also, we probably can make tags composable export type Cipher = { tagLength?: number; + nonceLength?: number; encrypt(plaintext: Uint8Array): Uint8Array; decrypt(ciphertext: Uint8Array): Uint8Array; }; @@ -208,6 +213,14 @@ export type CipherWithOutput = Cipher & { decrypt(ciphertext: Uint8Array, output?: Uint8Array): Uint8Array; }; +export type XorStream = ( + key: Uint8Array, + nonce: Uint8Array, + data: Uint8Array, + output?: Uint8Array, + counter?: number +) => Uint8Array; + // Polyfill for Safari 14 export function setBigUint64( view: DataView, @@ -225,3 +238,11 @@ export function setBigUint64( view.setUint32(byteOffset + h, wh, isLE); view.setUint32(byteOffset + l, wl, isLE); } + +export function u64Lengths(ciphertext: Uint8Array, AAD?: Uint8Array) { + const num = new Uint8Array(16); + const view = createView(num); + setBigUint64(view, 0, BigInt(AAD ? AAD.length : 0), true); + setBigUint64(view, 8, BigInt(ciphertext.length), true); + return num; +}