import { blake2b } from '@noble/hashes/blake2.js'; // AEZ v5 — the wide-block cipher LND's aezeed uses to encrypt a cipher seed. // // SCOPE, deliberately narrow. This is a DECRYPT-ONLY implementation of the SHORT-INPUT path: // // - No encrypt. Officer never mints aezeed seeds — it only imports ones LND/Zeus already created. // A new Officer wallet gets a BIP39 mnemonic, which is the portable format. // - No aezCore. An aezeed ciphertext is a FIXED 23 bytes (33 total, minus the 1-byte version, the // 5-byte salt and the 4-byte CRC), and AEZ dispatches on length: anything under 32 bytes takes the // `aezTiny` Feistel path. `aezCore` is unreachable here, so shipping it would mean carrying ~120 // lines of untested cipher in a seed-recovery path. `decrypt` throws rather than guess. // - No aezPRF. Only reached when the ciphertext is exactly `tau` bytes (empty plaintext). // // Every line below is exercised by the tests. That is the point of the narrowness — a subtle bug in // unreachable crypto is a bug nobody finds until it eats someone's wallet. // // Ported from the reference implementation (Yawning/aez, as used by lnd/aezeed). Faithful to the point // of preserving its integer-truncation quirks; where the original relied on a JavaScript accident, the // comment says so. const BLOCK_SIZE = 16; const EXTRACTED_KEY_SIZE = 48; const ZERO = new Uint8Array(BLOCK_SIZE); // ── AES round tables ───────────────────────────────────────────────────────────────────────────── // // AEZ is built on reduced-round AES (4 and 10 rounds, always with MixColumns and no key schedule — // the "keys" are just the extracted key material). The four 256-entry T-tables are pure functions of // the AES S-box: TE0[i] = [2·S[i], S[i], S[i], 3·S[i]] big-endian, and TE1..TE3 are TE0 rotated right // by 8, 16 and 24 bits. Deriving them costs microseconds once and removes 1024 hand-copied constants // from a file where a single wrong digit silently produces the wrong wallet. // prettier-ignore const SBOX_HEX = '637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0' + 'b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b275' + '09832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cf' + 'd0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2' + 'cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb' + 'e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08' + 'ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9e' + 'e1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16'; const SBOX = Uint8Array.from(SBOX_HEX.match(/../g)!, (h) => parseInt(h, 16)); /** GF(2^8) doubling — the `xtime` of the AES spec. */ const xtime = (b: number): number => ((b << 1) ^ (b & 0x80 ? 0x1b : 0)) & 0xff; function buildTables(): [Uint32Array, Uint32Array, Uint32Array, Uint32Array] { const te0 = new Uint32Array(256); for (let i = 0; i < 256; i++) { const s = SBOX[i]!; te0[i] = ((xtime(s) << 24) | (s << 16) | (s << 8) | (xtime(s) ^ s)) >>> 0; } const ror8 = (x: number): number => ((x >>> 8) | (x << 24)) >>> 0; const te1 = te0.map(ror8); const te2 = te1.map(ror8); const te3 = te2.map(ror8); return [te0, te1, te2, te3]; } const [TE0, TE1, TE2, TE3] = buildTables(); // ── block helpers ──────────────────────────────────────────────────────────────────────────────── const block = (size = BLOCK_SIZE): Uint8Array => new Uint8Array(size); function xor16(a: Uint8Array, b: Uint8Array, dst: Uint8Array): void { for (let i = 0; i < BLOCK_SIZE; i++) dst[i] = a[i]! ^ b[i]!; } function xor4x16(a: Uint8Array, b: Uint8Array, c: Uint8Array, d: Uint8Array, dst: Uint8Array): void { for (let i = 0; i < BLOCK_SIZE; i++) dst[i] = a[i]! ^ b[i]! ^ c[i]! ^ d[i]!; } /** Multiply a block by x in GF(2^128), in place. */ function doubleBlock(p: Uint8Array): void { const carry = p[0]!; for (let i = 0; i < 15; i++) p[i] = ((p[i]! << 1) | (p[i + 1]! >> 7)) & 0xff; p[15] = ((p[15]! << 1) ^ (carry >> 7 ? 135 : 0)) & 0xff; } /** Multiply a block by the integer `x` in GF(2^128) — repeated doubling, accumulating odd bits. */ function multBlock(x: number, src: Uint8Array, dst: Uint8Array): void { const t = block(); const r = block(); t.set(src); let n = x; while (n !== 0) { if (n & 1) xor16(r, t, r); doubleBlock(t); n >>= 1; } dst.set(r); } const readU32BE = (b: Uint8Array, off: number): number => ((b[off]! << 24) | (b[off + 1]! << 16) | (b[off + 2]! << 8) | b[off + 3]!) >>> 0; function writeU32BE(b: Uint8Array, off: number, v: number): void { b[off] = (v >>> 24) & 0xff; b[off + 1] = (v >>> 16) & 0xff; b[off + 2] = (v >>> 8) & 0xff; b[off + 3] = v & 0xff; } // ── reduced-round AES ──────────────────────────────────────────────────────────────────────────── type AesKeys = { aes4: Uint32Array; aes10: Uint32Array }; /** Lay the 48-byte extracted key out into the fixed round-key schedules AEZ prescribes. */ function aesKeys(extracted: Uint8Array): AesKeys { const k = new Uint32Array(12); for (let i = 0; i < 12; i++) k[i] = readU32BE(extracted, 4 * i); const aes10 = new Uint32Array(40); aes10.set(k, 0); aes10.set(k, 12); aes10.set(k, 24); aes10.set(k.subarray(0, 4), 36); const aes4 = new Uint32Array(16); aes4.set(k.subarray(4, 8), 0); aes4.set(k.subarray(0, 4), 4); aes4.set(k.subarray(8, 12), 8); return { aes4, aes10 }; } /** AES rounds with MixColumns applied on every round, including the last (as AEZ specifies). */ function rounds(keys: Uint32Array, count: number, b: Uint8Array): void { let s0 = readU32BE(b, 0); let s1 = readU32BE(b, 4); let s2 = readU32BE(b, 8); let s3 = readU32BE(b, 12); for (let r = 0; r < count; r++) { const o = r * 4; const t0 = TE0[(s0 >>> 24) & 0xff]! ^ TE1[(s1 >>> 16) & 0xff]! ^ TE2[(s2 >>> 8) & 0xff]! ^ TE3[s3 & 0xff]! ^ keys[o]!; const t1 = TE0[(s1 >>> 24) & 0xff]! ^ TE1[(s2 >>> 16) & 0xff]! ^ TE2[(s3 >>> 8) & 0xff]! ^ TE3[s0 & 0xff]! ^ keys[o + 1]!; const t2 = TE0[(s2 >>> 24) & 0xff]! ^ TE1[(s3 >>> 16) & 0xff]! ^ TE2[(s0 >>> 8) & 0xff]! ^ TE3[s1 & 0xff]! ^ keys[o + 2]!; const t3 = TE0[(s3 >>> 24) & 0xff]! ^ TE1[(s0 >>> 16) & 0xff]! ^ TE2[(s1 >>> 8) & 0xff]! ^ TE3[s2 & 0xff]! ^ keys[o + 3]!; s0 = t0 >>> 0; s1 = t1 >>> 0; s2 = t2 >>> 0; s3 = t3 >>> 0; } writeU32BE(b, 0, s0); writeU32BE(b, 4, s1); writeU32BE(b, 8, s2); writeU32BE(b, 12, s3); } function aes4(k: AesKeys, j: Uint8Array, i: Uint8Array, l: Uint8Array, src: Uint8Array, dst: Uint8Array): void { xor4x16(j, i, l, src, dst); rounds(k.aes4, 4, dst); } function aes10(k: AesKeys, l: Uint8Array, src: Uint8Array, dst: Uint8Array): void { xor16(src, l, dst); rounds(k.aes10, 10, dst); } // ── AEZ state ──────────────────────────────────────────────────────────────────────────────────── type AezState = { I: Uint8Array[]; J: Uint8Array[]; L: Uint8Array[]; aes: AesKeys }; /** AEZ tolerates any key length by hashing it to 48 bytes; a 48-byte key is used verbatim. */ function extractKey(key: Uint8Array): Uint8Array { return key.length === EXTRACTED_KEY_SIZE ? key : blake2b(key, { dkLen: EXTRACTED_KEY_SIZE }); } function init(key: Uint8Array): AezState { const ext = extractKey(key); const I = [block(), block()]; const J = [block(), block(), block()]; const L = Array.from({ length: 8 }, () => block()); I[0]!.set(ext.subarray(0, 16)); multBlock(2, I[0]!, I[1]!); J[0]!.set(ext.subarray(16, 32)); multBlock(2, J[0]!, J[1]!); multBlock(2, J[1]!, J[2]!); // L[0] is deliberately left as the zero block — the reference never assigns it. L[1]!.set(ext.subarray(32, 48)); multBlock(2, L[1]!, L[2]!); xor16(L[2]!, L[1]!, L[3]!); multBlock(2, L[2]!, L[4]!); xor16(L[4]!, L[1]!, L[5]!); multBlock(2, L[3]!, L[6]!); xor16(L[6]!, L[1]!, L[7]!); return { I, J, L, aes: aesKeys(ext) }; } /** The AXU hash binding the tag length, the nonce and each associated-data string into `delta`. */ function aezHash(st: AezState, nonce: Uint8Array | null, ad: Uint8Array[], tauBits: number): Uint8Array { const buf = block(); const sum = block(); const I = block(); const J = block(); writeU32BE(buf, 12, tauBits >>> 0); xor16(st.J[0]!, st.J[1]!, J); aes4(st.aes, J, st.I[1]!, st.L[1]!, buf, sum); const nonceEmpty = !nonce || nonce.length === 0; let n = nonce ?? new Uint8Array(0); let nBytes = n.length; I.set(st.I[1]!); for (let i = 1; nBytes >= BLOCK_SIZE; i++, nBytes -= BLOCK_SIZE) { aes4(st.aes, st.J[2]!, I, st.L[i % 8]!, n.subarray(0, BLOCK_SIZE), buf); xor16(sum, buf, sum); n = n.subarray(BLOCK_SIZE); if (i % 8 === 0) doubleBlock(I); } if (nBytes > 0 || nonceEmpty) { buf.fill(0); if (!nonceEmpty) buf.set(n.subarray(0, nBytes)); buf[nBytes] = 0x80; aes4(st.aes, st.J[2]!, st.I[0]!, st.L[0]!, buf, buf); xor16(sum, buf, sum); } ad.forEach((entry, k) => { let p = entry ?? new Uint8Array(0); const adEmpty = p.length === 0; let bytes = p.length; I.set(st.I[1]!); multBlock(5 + k, st.J[0]!, J); for (let i = 1; bytes >= BLOCK_SIZE; i++, bytes -= BLOCK_SIZE) { aes4(st.aes, J, I, st.L[i % 8]!, p.subarray(0, BLOCK_SIZE), buf); xor16(sum, buf, sum); p = p.subarray(BLOCK_SIZE); if (i % 8 === 0) doubleBlock(I); } if (bytes > 0 || adEmpty) { buf.fill(0); if (!adEmpty) buf.set(p.subarray(0, bytes)); buf[bytes] = 0x80; aes4(st.aes, J, st.I[0]!, st.L[0]!, buf, buf); xor16(sum, buf, sum); } }); return sum; } /** * The short-input (< 32 byte) balanced Feistel network. `d` is 0 to encipher, 1 to decipher — the only * difference is which end of the round counter it starts from. */ function aezTiny(st: AezState, delta: Uint8Array, input: Uint8Array, d: 0 | 1, dst: Uint8Array): void { const inBytes = input.length; const buf = block(2 * BLOCK_SIZE); const L = block(); const R = block(); const tmp = block(); let mask = 0x00; let pad = 0x80; // Integer halves. The reference indexes with `inBytes/2` and leans on truncation; spelling the two // halves out makes the odd-length case readable instead of accidental. const half = inBytes >> 1; const halfUp = (inBytes + 1) >> 1; let i = 7; let roundCount: number; if (inBytes === 1) roundCount = 24; else if (inBytes === 2) roundCount = 16; else if (inBytes < 16) roundCount = 10; else { i = 6; roundCount = 8; } L.set(input.subarray(0, halfUp)); R.set(input.subarray(half, half + halfUp)); if (inBytes & 1) { // Odd length: the two halves overlap by a nibble, so shift R left by 4 bits and switch to // nibble-granular padding. for (let k = 0; k < half; k++) R[k] = ((R[k]! << 4) | (R[k + 1]! >> 4)) & 0xff; R[half] = (R[half]! << 4) & 0xff; pad = 0x08; mask = 0xf0; } let j: number; let step: number; if (d !== 0) { if (inBytes < 16) { buf.set(input.subarray(0, BLOCK_SIZE)); buf[0] = buf[0]! | 0x80; xor16(delta, buf, buf); aes4(st.aes, ZERO, st.I[1]!, st.L[3]!, buf, tmp); L[0] = L[0]! ^ (tmp[0]! & 0x80); } j = roundCount - 1; step = -1; } else { j = 0; step = 1; } for (let k = 0; k < roundCount / 2; k++, j += step * 2) { buf.fill(0, 0, BLOCK_SIZE); buf.set(R.subarray(0, halfUp)); buf[half] = (buf[half]! & mask) | pad; xor16(buf, delta, buf); buf[15] = buf[15]! ^ (j & 0xff); aes4(st.aes, ZERO, st.I[1]!, st.L[i]!, buf, tmp); xor16(L, tmp, L); buf.fill(0, 0, BLOCK_SIZE); buf.set(L.subarray(0, halfUp)); buf[half] = (buf[half]! & mask) | pad; xor16(buf, delta, buf); buf[15] = buf[15]! ^ ((j + step) & 0xff); aes4(st.aes, ZERO, st.I[1]!, st.L[i]!, buf, tmp); xor16(R, tmp, R); } buf.set(R.subarray(0, half), 0); buf.set(L.subarray(0, halfUp), half); if (inBytes & 1) { for (let k = inBytes - 1; k > half; k--) buf[k] = ((buf[k]! >> 4) | (buf[k - 1]! << 4)) & 0xff; buf[half] = (L[0]! >> 4) | (R[half]! & 0xf0); } dst.set(buf.subarray(0, inBytes)); if (inBytes < 16 && d === 0) { buf.fill(0, inBytes, BLOCK_SIZE); buf[0] = buf[0]! | 0x80; xor16(delta, buf, buf); aes4(st.aes, ZERO, st.I[1]!, st.L[3]!, buf, tmp); dst[0] = dst[0]! ^ (tmp[0]! & 0x80); } } /** * AEZ decrypt-and-verify. Returns the plaintext, or `null` when the `tau`-byte authenticator does not * check out — which is the signal that the key (and therefore the seed passphrase) is wrong. * * Throws for inputs outside the aezeed envelope rather than silently taking an untested path; see the * scope note at the top of this file. */ export function aezDecrypt( key: Uint8Array, nonce: Uint8Array | null, ad: Uint8Array[], tau: number, ciphertext: Uint8Array, ): Uint8Array | null { if (ciphertext.length >= 32 || ciphertext.length <= tau) { throw new Error(`aez: unsupported ciphertext length ${ciphertext.length} (decrypt is short-input only)`); } const st = init(key); const delta = aezHash(st, nonce, ad, tau * 8); const x = new Uint8Array(ciphertext.length); aezTiny(st, delta, ciphertext, 1, x); // Constant-time-ish: fold every authenticator byte before deciding, so the comparison does not // short-circuit on the first mismatch. let sum = 0; for (let i = 0; i < tau; i++) sum |= x[ciphertext.length - tau + i]!; if (sum !== 0) return null; return x.slice(0, ciphertext.length - tau); }