gsmol/web/js/crypto.js

438 lines
18 KiB
JavaScript
Raw Permalink Normal View History

2026-10-09 13:47:42 +03:00
// 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));
}