// Dependency-free primitives for Smol Mail (SPEC.md §1): SHA-256, SHA-512, // HMAC/HKDF-SHA256, ChaCha20-Poly1305, X25519, Ed25519, and the §2 key // conversions between the two curves. Pure JS so the same code runs in the // browser and in Node for testing against the reference implementation. const utf8 = new TextEncoder(); export function concat(...parts) { const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); let off = 0; for (const p of parts) { out.set(p, off); off += p.length; } return out; } export function utf8Bytes(text) { return utf8.encode(text); } export function hex(bytes) { return [...bytes].map(b => b.toString(16).padStart(2, "0")).join(""); } export function unhex(text) { if (text.length % 2) throw new Error(`odd-length hex string: ${text}`); const out = new Uint8Array(text.length / 2); for (let i = 0; i < out.length; i++) out[i] = parseInt(text.slice(i * 2, i * 2 + 2), 16); return out; } export function leBytesToBigInt(bytes) { let n = 0n; for (let i = bytes.length - 1; i >= 0; i--) n = (n << 8n) | BigInt(bytes[i]); return n; } export function bigIntToLeBytes(value, length) { const out = new Uint8Array(length); for (let i = 0; i < length; i++) { out[i] = Number(value & 0xffn); value >>= 8n; } return out; } export function randomBytes(n) { const out = new Uint8Array(n); crypto.getRandomValues(out); return out; } export function timingSafeEqual(a, b) { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; return diff === 0; } // --- SHA-256 ---------------------------------------------------------------- const K256 = new Uint32Array([ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, ]); const rotl32 = (x, n) => ((x << n) | (x >>> (32 - n))) >>> 0; const rotr32 = (x, n) => ((x >>> n) | (x << (32 - n))) >>> 0; export function sha256(message) { const padded = new Uint8Array((((message.length + 9) + 63) >> 6 << 6)); padded.set(message); padded[message.length] = 0x80; const bits = BigInt(message.length) * 8n; new DataView(padded.buffer).setBigUint64(padded.length - 8, bits, false); const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]); const w = new Uint32Array(64); const view = new DataView(padded.buffer); for (let off = 0; off < padded.length; off += 64) { for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + i * 4); for (let i = 16; i < 64; i++) { const s0 = rotr32(w[i - 15], 7) ^ rotr32(w[i - 15], 18) ^ (w[i - 15] >>> 3); const s1 = rotr32(w[i - 2], 17) ^ rotr32(w[i - 2], 19) ^ (w[i - 2] >>> 10); w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0; } let [a, b, c, d, e, f, g, hh] = h; for (let i = 0; i < 64; i++) { const S1 = rotr32(e, 6) ^ rotr32(e, 11) ^ rotr32(e, 25); const ch = (e & f) ^ (~e & g); const t1 = (hh + S1 + ch + K256[i] + w[i]) >>> 0; const S0 = rotr32(a, 2) ^ rotr32(a, 13) ^ rotr32(a, 22); const maj = (a & b) ^ (a & c) ^ (b & c); const t2 = (S0 + maj) >>> 0; hh = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0; } const add = [a, b, c, d, e, f, g, hh]; for (let i = 0; i < 8; i++) h[i] = (h[i] + add[i]) >>> 0; } const out = new Uint8Array(32); for (let i = 0; i < 8; i++) new DataView(out.buffer).setUint32(i * 4, h[i]); return out; } // --- HMAC-SHA256 and HKDF (RFC 2104, RFC 5869) ------------------------------- export function hmacSha256(key, message) { let block = new Uint8Array(64).fill(0x36); for (let i = 0; i < Math.min(64, key.length); i++) block[i] ^= key[i]; const inner = sha256(concat(block, message)); block = new Uint8Array(64).fill(0x5c); for (let i = 0; i < Math.min(64, key.length); i++) block[i] ^= key[i]; return sha256(concat(block, inner)); } export function hkdfSha256(ikm, salt, info, length = 32) { const prk = hmacSha256(salt, ikm); let out = new Uint8Array(0), block = new Uint8Array(0), counter = 1; while (out.length < length) { block = hmacSha256(prk, concat(block, info, Uint8Array.of(counter))); out = concat(out, block); counter++; } return out.slice(0, length); } // --- SHA-512 (Ed25519 only) -------------------------------------------------- const M64 = (1n << 64n) - 1n; const K512 = [ 0x428a2f98d728ae22n, 0x7137449123ef65cdn, 0xb5c0fbcfec4d3b2fn, 0xe9b5dba58189dbbcn, 0x3956c25bf348b538n, 0x59f111f1b605d019n, 0x923f82a4af194f9bn, 0xab1c5ed5da6d8118n, 0xd807aa98a3030242n, 0x12835b0145706fben, 0x243185be4ee4b28cn, 0x550c7dc3d5ffb4e2n, 0x72be5d74f27b896fn, 0x80deb1fe3b1696b1n, 0x9bdc06a725c71235n, 0xc19bf174cf692694n, 0xe49b69c19ef14ad2n, 0xefbe4786384f25e3n, 0x0fc19dc68b8cd5b5n, 0x240ca1cc77ac9c65n, 0x2de92c6f592b0275n, 0x4a7484aa6ea6e483n, 0x5cb0a9dcbd41fbd4n, 0x76f988da831153b5n, 0x983e5152ee66dfabn, 0xa831c66d2db43210n, 0xb00327c898fb213fn, 0xbf597fc7beef0ee4n, 0xc6e00bf33da88fc2n, 0xd5a79147930aa725n, 0x06ca6351e003826fn, 0x142929670a0e6e70n, 0x27b70a8546d22ffcn, 0x2e1b21385c26c926n, 0x4d2c6dfc5ac42aedn, 0x53380d139d95b3dfn, 0x650a73548baf63den, 0x766a0abb3c77b2a8n, 0x81c2c92e47edaee6n, 0x92722c851482353bn, 0xa2bfe8a14cf10364n, 0xa81a664bbc423001n, 0xc24b8b70d0f89791n, 0xc76c51a30654be30n, 0xd192e819d6ef5218n, 0xd69906245565a910n, 0xf40e35855771202an, 0x106aa07032bbd1b8n, 0x19a4c116b8d2d0c8n, 0x1e376c085141ab53n, 0x2748774cdf8eeb99n, 0x34b0bcb5e19b48a8n, 0x391c0cb3c5c95a63n, 0x4ed8aa4ae3418acbn, 0x5b9cca4f7763e373n, 0x682e6ff3d6b2b8a3n, 0x748f82ee5defb2fcn, 0x78a5636f43172f60n, 0x84c87814a1f0ab72n, 0x8cc702081a6439ecn, 0x90befffa23631e28n, 0xa4506cebde82bde9n, 0xbef9a3f7b2c67915n, 0xc67178f2e372532bn, 0xca273eceea26619cn, 0xd186b8c721c0c207n, 0xeada7dd6cde0eb1en, 0xf57d4f7fee6ed178n, 0x06f067aa72176fban, 0x0a637dc5a2c898a6n, 0x113f9804bef90daen, 0x1b710b35131c471bn, 0x28db77f523047d84n, 0x32caab7b40c72493n, 0x3c9ebe0a15c9bebcn, 0x431d67c49c100d4cn, 0x4cc5d4becb3e42b6n, 0x597f299cfc657e2an, 0x5fcb6fab3ad6faecn, 0x6c44198c4a475817n, ]; export function sha512(message) { const padded = new Uint8Array((((message.length + 17) + 127) >> 7 << 7)); padded.set(message); padded[message.length] = 0x80; const bits = BigInt(message.length) * 8n; new DataView(padded.buffer).setBigUint64(padded.length - 8, bits, false); let h = [0x6a09e667f3bcc908n, 0xbb67ae8584caa73bn, 0x3c6ef372fe94f82bn, 0xa54ff53a5f1d36f1n, 0x510e527fade682d1n, 0x9b05688c2b3e6c1fn, 0x1f83d9abfb41bd6bn, 0x5be0cd19137e2179n]; const rotr = (x, n) => ((x >> BigInt(n)) | (x << (64n - BigInt(n)))) & M64; const view = new DataView(padded.buffer); const w = new Array(80); for (let off = 0; off < padded.length; off += 128) { for (let i = 0; i < 16; i++) w[i] = view.getBigUint64(off + i * 8); for (let i = 16; i < 80; i++) { const s0 = rotr(w[i - 15], 1) ^ rotr(w[i - 15], 8) ^ (w[i - 15] >> 7n); const s1 = rotr(w[i - 2], 19) ^ rotr(w[i - 2], 61) ^ (w[i - 2] >> 6n); w[i] = (w[i - 16] + s0 + w[i - 7] + s1) & M64; } let [a, b, c, d, e, f, g, hh] = h; for (let i = 0; i < 80; i++) { const S1 = rotr(e, 14) ^ rotr(e, 18) ^ rotr(e, 41); const ch = (e & f) ^ (~e & g); const t1 = (hh + S1 + ch + K512[i] + w[i]) & M64; const S0 = rotr(a, 28) ^ rotr(a, 34) ^ rotr(a, 39); const maj = (a & b) ^ (a & c) ^ (b & c); const t2 = (S0 + maj) & M64; hh = g; g = f; f = e; e = (d + t1) & M64; d = c; c = b; b = a; a = (t1 + t2) & M64; } const sum = [a, b, c, d, e, f, g, hh]; h = h.map((v, i) => (v + sum[i]) & M64); } const out = new Uint8Array(64); for (let i = 0; i < 8; i++) new DataView(out.buffer).setBigUint64(i * 8, h[i], false); return out; } // --- ChaCha20-Poly1305 AEAD (RFC 8439) -------------------------------------- function chachaBlock(key, counter, nonce) { const state = new Uint32Array(16); state.set([0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]); const kview = new DataView(key.buffer); for (let i = 0; i < 8; i++) state[4 + i] = kview.getUint32(i * 4, true); state[12] = counter >>> 0; const nview = new DataView(nonce.buffer); for (let i = 0; i < 3; i++) state[13 + i] = nview.getUint32(i * 4, true); const x = Uint32Array.from(state); const qr = (a, b, c, d) => { x[a] = (x[a] + x[b]) >>> 0; x[d] = rotl32(x[d] ^ x[a], 16); x[c] = (x[c] + x[d]) >>> 0; x[b] = rotl32(x[b] ^ x[c], 12); x[a] = (x[a] + x[b]) >>> 0; x[d] = rotl32(x[d] ^ x[a], 8); x[c] = (x[c] + x[d]) >>> 0; x[b] = rotl32(x[b] ^ x[c], 7); }; for (let i = 0; i < 10; i++) { qr(0, 4, 8, 12); qr(1, 5, 9, 13); qr(2, 6, 10, 14); qr(3, 7, 11, 15); qr(0, 5, 10, 15); qr(1, 6, 11, 12); qr(2, 7, 8, 13); qr(3, 4, 9, 14); } const out = new Uint8Array(64); const view = new DataView(out.buffer); for (let i = 0; i < 16; i++) view.setUint32(i * 4, (x[i] + state[i]) >>> 0, true); return out; } function chacha20Xor(key, counter, nonce, data) { const out = new Uint8Array(data.length); for (let off = 0; off < data.length; off += 64) { const stream = chachaBlock(key, counter + (off / 64), nonce); const n = Math.min(64, data.length - off); for (let i = 0; i < n; i++) out[off + i] = data[off + i] ^ stream[i]; } return out; } // Poly1305 over BigInt; correctness over speed, messages here stay small. function poly1305(key, message) { const P1305 = (1n << 130n) - 5n; const r = leBytesToBigInt(key.slice(0, 16)) & 0x0ffffffc0ffffffc0ffffffc0fffffffn; const s = leBytesToBigInt(key.slice(16, 32)); let acc = 0n; for (let off = 0; off < message.length; off += 16) { const block = message.slice(off, Math.min(off + 16, message.length)); acc = ((acc + leBytesToBigInt(block) + (1n << BigInt(block.length * 8))) * r) % P1305; } return bigIntToLeBytes((acc + s) & ((1n << 128n) - 1n), 16); } export function aeadEncrypt(key, nonce, plaintext, aad) { const polyKey = chachaBlock(key, 0, nonce).slice(0, 32); const ciphertext = chacha20Xor(key, 1, nonce, plaintext); const le64 = (n) => bigIntToLeBytes(BigInt(n), 8); const pad = (n) => new Uint8Array((16 - (n % 16)) % 16); const mac = poly1305(polyKey, concat( aad, pad(aad.length), ciphertext, pad(ciphertext.length), le64(aad.length), le64(ciphertext.length))); return concat(ciphertext, mac); } export function aeadDecrypt(key, nonce, sealed, aad) { if (sealed.length < 16) throw new Error("ciphertext shorter than the Poly1305 tag"); const ciphertext = sealed.slice(0, sealed.length - 16); const polyKey = chachaBlock(key, 0, nonce).slice(0, 32); const le64 = (n) => bigIntToLeBytes(BigInt(n), 8); const pad = (n) => new Uint8Array((16 - (n % 16)) % 16); const expect = poly1305(polyKey, concat( aad, pad(aad.length), ciphertext, pad(ciphertext.length), le64(aad.length), le64(ciphertext.length))); if (!timingSafeEqual(expect, sealed.slice(sealed.length - 16))) throw new Error("decryption failed: bad Poly1305 tag"); return chacha20Xor(key, 1, nonce, ciphertext); } // --- X25519 (RFC 7748) ------------------------------------------------------- const P_ED = (1n << 255n) - 19n; function mod(value, p = P_ED) { return ((value % p) + p) % p; } function powMod(base, exponent, p = P_ED) { let out = 1n; base = mod(base, p); while (exponent > 0n) { if (exponent & 1n) out = out * base % p; base = base * base % p; exponent >>= 1n; } return out; } function clampScalar(scalar) { const k = Uint8Array.from(scalar); k[0] &= 248; k[31] &= 127; k[31] |= 64; return k; } function x25519Raw(scalar, u) { const k = leBytesToBigInt(clampScalar(scalar)); const x1 = leBytesToBigInt(u) & ((1n << 255n) - 1n); const a24 = 121665n; let x2 = 1n, z2 = 0n, x3 = x1, z3 = 1n, swap = 0n; for (let t = 254n; t >= 0n; t--) { const kt = (k >> t) & 1n; swap ^= kt; if (swap) { [x2, x3] = [x3, x2]; [z2, z3] = [z3, z2]; } swap = kt; const a = mod(x2 + z2), aa = a * a % P_ED; const b = mod(x2 - z2), bb = b * b % P_ED; const e = mod(aa - bb); const c = mod(x3 + z3), d = mod(x3 - z3); const da = d * a % P_ED, cb = c * b % P_ED; x3 = mod(da + cb) ** 2n % P_ED; z3 = x1 * mod(da - cb) ** 2n % P_ED; x2 = aa * bb % P_ED; z2 = e * mod(aa + a24 * e) % P_ED; } if (swap) { [x2, x3] = [x3, x2]; [z2, z3] = [z3, z2]; } return x2 * powMod(z2, P_ED - 2n) % P_ED; } // §2's low-order rejection: a clamped scalar is a multiple of 8, so any // low-order peer point yields an all-zero shared secret — rejecting the zero // output rejects all of them. export function x25519(scalar, peerPublic) { const shared = bigIntToLeBytes(x25519Raw(scalar, peerPublic), 32); if (shared.every(b => b === 0)) throw new Error("rejected low-order key agreement point"); return shared; } export function x25519Base(scalar) { return bigIntToLeBytes(x25519Raw(scalar, unhex("0900000000000000000000000000000000000000000000000000000000000000")), 32); } // --- Ed25519 (RFC 8032) ------------------------------------------------------ const L_ED = (1n << 252n) + 27742317777372353535851937790883648493n; const D_ED = mod(-121665n * powMod(121666n, P_ED - 2n)); const B_ED = { x: 15112221349535400772501151409588531511454012693041857206046113283949847762202n, y: mod(4n * powMod(5n, P_ED - 2n)) }; const IDENTITY = { x: 0n, y: 1n, z: 1n, t: 0n }; const toProjective = ({ x, y }) => ({ x, y, z: 1n, t: mod(x * y) }); function pointAdd(p, q) { const a = mod(p.y - p.x) * mod(q.y - q.x) % P_ED; const b = mod(p.y + p.x) * mod(q.y + q.x) % P_ED; const c = 2n * p.t * q.t % P_ED * D_ED % P_ED; const d = 2n * p.z * q.z % P_ED; const e = mod(b - a), f = mod(d - c), g = mod(d + c), h = b + a; return { x: e * f % P_ED, y: g * h % P_ED, z: f * g % P_ED, t: e * h % P_ED }; } function pointDouble(p) { const a = p.x * p.x % P_ED; const b = p.y * p.y % P_ED; const c = 2n * p.z * p.z % P_ED; const d = P_ED - a; // a = -1 on this curve, so d = -A const e = mod(mod(p.x + p.y) ** 2n - a - b); const g = mod(d + b); const f = mod(g - c); const h = mod(d - b); return { x: e * f % P_ED, y: g * h % P_ED, z: f * g % P_ED, t: e * h % P_ED }; } function scalarMult(scalar, point) { let result = IDENTITY; for (let t = 254n; t >= 0n; t--) { result = pointDouble(result); if ((scalar >> t) & 1n) result = pointAdd(result, point); } return result; } function encodePoint(p) { const zInv = powMod(p.z, P_ED - 2n); const x = p.x * zInv % P_ED, y = p.y * zInv % P_ED; const out = bigIntToLeBytes(y, 32); out[31] |= Number(x & 1n) << 7; return out; } function decodePoint(bytes) { if (bytes.length !== 32) throw new Error("Ed25519 public key must be 32 bytes"); const sign = bytes[31] >> 7; const y = leBytesToBigInt(bytes) & ((1n << 255n) - 1n); if (y >= P_ED) throw new Error("non-canonical Ed25519 public key"); const u = mod(y * y - 1n), v = mod(D_ED * y * y + 1n); const v2 = v * v % P_ED, v3 = v2 * v % P_ED, v4 = v2 * v2 % P_ED; let x = u * v3 % P_ED * powMod(u * v4 % P_ED * v3 % P_ED, (P_ED - 5n) / 8n) % P_ED; if (mod(v * x % P_ED * x) !== u) { if (mod(v * x % P_ED * x) === mod(-u)) x = x * powMod(2n, (P_ED - 1n) / 4n) % P_ED; else throw new Error("not a point on the Ed25519 curve"); } if (x === 0n && sign) throw new Error("invalid sign bit on x = 0"); if (Number(x & 1n) !== sign) x = P_ED - x; return { x, y }; } function seedToScalar(seed) { const h = sha512(seed); return leBytesToBigInt(clampScalar(h.slice(0, 32))); } export function ed25519PublicKey(seed) { if (seed.length !== 32) throw new Error("identity seed must be 32 bytes"); return encodePoint(scalarMult(seedToScalar(seed), toProjective(B_ED))); } export function ed25519Sign(seed, message) { const h = sha512(seed); const a = leBytesToBigInt(clampScalar(h.slice(0, 32))); const publicKey = encodePoint(scalarMult(a, toProjective(B_ED))); const r = leBytesToBigInt(sha512(concat(h.slice(32), message))) % L_ED; const rEnc = encodePoint(scalarMult(r, toProjective(B_ED))); const k = leBytesToBigInt(sha512(concat(rEnc, publicKey, message))) % L_ED; return concat(rEnc, bigIntToLeBytes((r + k * a) % L_ED, 32)); } export function ed25519Verify(publicKey, message, signature) { try { const a = toProjective(decodePoint(publicKey)); const r = toProjective(decodePoint(signature.slice(0, 32))); const s = leBytesToBigInt(signature.slice(32, 64)); if (signature.length !== 64 || s >= L_ED) return false; const k = leBytesToBigInt(sha512(concat(signature.slice(0, 32), publicKey, message))) % L_ED; const lhs = scalarMult(s, toProjective(B_ED)); const rhs = pointAdd(scalarMult(k, a), r); return lhs.x * rhs.z % P_ED === rhs.x * lhs.z % P_ED && lhs.y * rhs.z % P_ED === rhs.y * lhs.z % P_ED; } catch { return false; } } // --- §2 conversions between the identity key and X25519 ---------------------- export function ed25519ToX25519(publicKey) { const y = leBytesToBigInt(publicKey) & ((1n << 255n) - 1n); if (y >= P_ED) throw new Error("non-canonical Ed25519 public key"); if (mod(1n - y) === 0n) throw new Error("identity element has no X25519 image"); return bigIntToLeBytes(mod(1n + y) * powMod(1n - y, P_ED - 2n) % P_ED, 32); } export function ed25519SeedToX25519(seed) { return clampScalar(sha512(seed).slice(0, 32)); }