-
-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
7 changed files
with
334 additions
and
289 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
}; | ||
} |
Oops, something went wrong.