chore: flatten repository history

This commit is contained in:
randogoth 2026-10-09 13:47:42 +03:00
commit 3a03fab876
30 changed files with 7223 additions and 0 deletions

1660
web/js/app.js Normal file

File diff suppressed because it is too large Load diff

7
web/js/config.js Normal file
View file

@ -0,0 +1,7 @@
// Deploy-time configuration, empty by default. A plain checkout (`devbox run
// serve`) ships this file untouched, so PRESET_SERVER is null and nothing
// below changes behavior. The nix package can overwrite this file at build
// time with a real value — see nix/bridge.nix and the NixOS module's
// `services.gsmol-bridge.presetServer` option.
export const PRESET_SERVER = null;
// Shape when set: { host: "example.org", publicKey: "base32-encoded-key" }

437
web/js/crypto.js Normal file
View file

@ -0,0 +1,437 @@
// 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));
}

31
web/js/i18n.js Normal file
View file

@ -0,0 +1,31 @@
// Strings, loaded once from the flat dictionary scripts/build-locale.mjs
// generates from locales/en-US/main.ftl. No Fluent parser ships here — the
// build step already resolved the source file's syntax down to plain
// `{ $var }` placeholders, which is all this needs to substitute.
let messages = null;
export async function loadLocale() {
const response = await fetch("locales/en-US.json");
messages = await response.json();
}
// Falls back to the id itself on a miss, so a typo'd or forgotten call
// shows up as visibly wrong text in the UI rather than disappearing.
export function t(id, args = {}) {
const value = messages?.[id];
if (value === undefined) return id;
return value.replace(/\{\s*\$(\w+)\s*\}/g, (_, name) => args[name] ?? "");
}
// index.html's static markup keeps its literal English text and gains
// data-l10n-* attributes alongside it, so the page still reads correctly
// even if this runs late — this pass just overwrites it from the same
// English source, proving the wiring for whenever a second locale exists.
export function applyStaticLocale(root = document) {
for (const el of root.querySelectorAll("[data-l10n-id]")) el.textContent = t(el.dataset.l10nId);
for (const el of root.querySelectorAll("[data-l10n-placeholder]")) el.placeholder = t(el.dataset.l10nPlaceholder);
for (const el of root.querySelectorAll("[data-l10n-title]")) el.title = t(el.dataset.l10nTitle);
for (const el of root.querySelectorAll("[data-l10n-aria-label]"))
el.setAttribute("aria-label", t(el.dataset.l10nAriaLabel));
}

92
web/js/noise.js Normal file
View file

@ -0,0 +1,92 @@
// Noise_NX_25519_ChaChaPoly_SHA256 initiator (SPEC.md §4), rev-34 semantics.
// The initiator is anonymous; the responder's static key arrives encrypted in
// message two, which is what server pinning checks.
import { aeadDecrypt, aeadEncrypt, concat, hkdfSha256, randomBytes, sha256, utf8Bytes, x25519, x25519Base } from "./crypto.js";
const PROTOCOL = "Noise_NX_25519_ChaChaPoly_SHA256"; // exactly 32 bytes, so h = name
const PROLOGUE = utf8Bytes("smolmail/1");
// Noise's ChaChaPoly nonce: 4 zero bytes then the counter as u64 LE.
function nonce(n) {
const out = new Uint8Array(12);
new DataView(out.buffer).setBigUint64(4, BigInt(n), true);
return out;
}
// One direction of the post-handshake transport. The key is unique per
// session, so the counter starting at zero is safe.
class CipherState {
constructor(key) {
this.key = key;
this.counter = 0;
}
encrypt(plaintext) {
const sealed = aeadEncrypt(this.key, nonce(this.counter), plaintext, new Uint8Array(0));
this.counter++;
return sealed;
}
decrypt(sealed) {
const plaintext = aeadDecrypt(this.key, nonce(this.counter), sealed, new Uint8Array(0));
this.counter++;
return plaintext;
}
}
export class NxInitiator {
constructor() {
this.h = utf8Bytes(PROTOCOL);
this.ck = this.h.slice();
this.mixHash(PROLOGUE);
this.key = null;
}
mixHash(data) {
this.h = sha256(concat(this.h, data));
}
mixKey(ikm) {
const okm = hkdfSha256(ikm, this.ck, new Uint8Array(0), 64);
this.ck = okm.slice(0, 32);
this.key = okm.slice(32);
}
// Message one is just our ephemeral public key. No key is set yet, so the
// empty payload travels in the clear — and is still mixed into h.
// `esk` is pinned only by the test vectors, like the spec's fixed-ephemeral
// envelope.
writeMessage1(esk) {
this.esk = esk ?? randomBytes(32);
this.epk = x25519Base(this.esk);
this.mixHash(this.epk);
this.mixHash(new Uint8Array(0));
return this.epk;
}
// Message two: e (plaintext), ee, then the responder's static and the
// (empty) payload as AEAD ciphertexts chained through h. Each MixKey
// restarts the nonce at zero.
readMessage2(message) {
if (message.length !== 32 + 48 + 16)
throw new Error(`unexpected NX message length ${message.length}`);
const re = message.slice(0, 32);
this.mixHash(re);
this.mixKey(x25519(this.esk, re));
this.serverStatic = this.decryptAndHash(message.slice(32, 80));
this.mixKey(x25519(this.esk, this.serverStatic)); // es
const payload = this.decryptAndHash(message.slice(80));
if (payload.length !== 0) throw new Error("unexpected payload in handshake");
this.handshakeHash = this.h;
// Split(): two transport keys from the final chaining key, zero-length ikm
const okm = hkdfSha256(new Uint8Array(0), this.ck, new Uint8Array(0), 64);
return { send: new CipherState(okm.slice(0, 32)), recv: new CipherState(okm.slice(32)) };
}
decryptAndHash(sealed) {
const plaintext = aeadDecrypt(this.key, nonce(0), sealed, this.h);
this.mixHash(sealed);
return plaintext;
}
}

481
web/js/proto.js Normal file
View file

@ -0,0 +1,481 @@
// Smol Mail protocol, version 1.1 (../smolmail SPEC.md): addresses, sealed and
// signed envelopes, body frontmatter, key rotation, accept tokens, and the
// framed request and response bodies of the five operations.
import {
aeadDecrypt, aeadEncrypt, concat, ed25519PublicKey, ed25519SeedToX25519,
ed25519Sign, ed25519ToX25519, ed25519Verify, hkdfSha256, hmacSha256, randomBytes, sha256,
timingSafeEqual, utf8Bytes, x25519, x25519Base,
} from "./crypto.js";
import { NxInitiator } from "./noise.js";
export const DEFAULT_PORT = 1961;
export const KEY_LEN = 32, SIG_LEN = 64, CERT_LEN = 200, ID_LEN = 32, TOKEN_LEN = 32;
export const MAX_FRAME = 1 << 20, NOISE_PAYLOAD = 65535 - 16, PAD_TO = 1024;
export const ENVELOPE_HEADER = 69, PAYLOAD_HEADER = 45, MAX_CHAIN = 16;
export const MAX_SKEW = 86400; // §5.3: how far ahead of our clock a payload may be dated
export const FLAG_REQUESTS = 0x01; // §6.1: set when a FETCH record missed an accept token
const FRONTMATTER_MAX = 4096, FRONTMATTER_KEYS = 64;
export const OP = { AUTH: 0x00, RESOLVE: 0x01, SEND: 0x02, FETCH: 0x03, DELETE: 0x04, REGISTER: 0x05 };
export const STATUS = { 0: "ok", 1: "malformed", 2: "bad version", 3: "unknown user",
4: "auth required", 5: "auth failed", 6: "quota exceeded", 7: "too large",
8: "rate limited", 9: "not permitted", 10: "internal error" };
export class SmolError extends Error {}
const LABEL = Object.freeze({
auth: utf8Bytes("smolmail/1 auth"), seal: utf8Bytes("smolmail/1 seal"),
msg: utf8Bytes("smolmail/1 msg"), id: utf8Bytes("smolmail/1 id"),
rotate: utf8Bytes("smolmail/1 rotate"), identity: utf8Bytes("smolmail/1 identity"),
accept: utf8Bytes("smolmail/1 accept"), mac: utf8Bytes("smolmail/1 mac"),
register: utf8Bytes("smolmail/1 register"),
});
// --- encoding helpers -------------------------------------------------------
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
export function b32encode(bytes) {
let out = "", value = 0, bits = 0;
for (const b of bytes) {
value = (value << 8) | b;
bits += 8;
while (bits >= 5) { bits -= 5; out += B32[(value >>> bits) & 31]; }
}
if (bits) out += B32[(value << (5 - bits)) & 31];
return out.toLowerCase();
}
export function b32decode(text) {
let out = [], value = 0, bits = 0;
for (const ch of text.trim().toUpperCase().replace(/=+$/, "")) {
const idx = B32.indexOf(ch);
if (idx < 0) throw new SmolError(`invalid base32 character '${ch}'`);
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) { bits -= 8; out.push((value >>> bits) & 0xff); }
}
return Uint8Array.from(out);
}
// §3: the first 20 base32 characters of the identity, in groups of four.
export function fingerprint(identity) {
const s = b32encode(identity).slice(0, 20);
return s.match(/.{4}/g).join(" ");
}
export const u16BE = (n) => Uint8Array.of((n >> 8) & 0xff, n & 0xff);
export const u32BE = (n) => Uint8Array.of((n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff);
export const i64BE = (n) => {
const out = new Uint8Array(8);
new DataView(out.buffer).setBigInt64(0, BigInt(n), false);
return out;
};
export const nowSeconds = () => Math.floor(Date.now() / 1000);
// Fail-closed reader; every parse raises rather than reading past the end.
export class Reader {
constructor(buf) { this.buf = buf; this.pos = 0; }
take(n) {
if (n < 0 || this.pos + n > this.buf.length) throw new SmolError("truncated message");
this.pos += n;
return this.buf.slice(this.pos - n, this.pos);
}
u8() { return this.take(1)[0]; }
u16() { const b = this.take(2); return (b[0] << 8) | b[1]; }
u32() { return new DataView(this.take(4).buffer).getUint32(0); }
i64() { return new DataView(this.take(8).buffer).getBigInt64(0); }
get left() { return this.buf.length - this.pos; }
get done() { return this.pos === this.buf.length; }
}
// --- identity (§2) ------------------------------------------------------------
// An Ed25519 keypair with the X25519 agreement keys derived from it.
export function identityFromSeed(seed) {
if (seed.length !== KEY_LEN) throw new SmolError(`identity seed must be ${KEY_LEN} bytes`);
return { seed, publicKey: ed25519PublicKey(seed) };
}
export function newMaster() {
return randomBytes(KEY_LEN);
}
// §2: the only secret a user holds. Every rotation index's signing seed, and
// the accept key, are derived from it with HKDF.
export function identitySeed(master, index) {
return hkdfSha256(master, new Uint8Array(0), concat(LABEL.identity, u32BE(index)));
}
export function acceptKeyFor(master) {
return hkdfSha256(master, new Uint8Array(0), LABEL.accept);
}
// §5.8: the token this account issues to one correspondent, independent of
// the rotation index so it survives the owner's key rotation.
export function tokenFor(master, correspondentIdentity) {
return hmacSha256(acceptKeyFor(master), correspondentIdentity);
}
// §5.8: what a sender attaches to SEND to reach the recipient's main tier.
export function acceptMac(token, id) {
return hmacSha256(token, concat(LABEL.mac, id));
}
// --- addressing (§3) --------------------------------------------------------
const ADDRESS = /^(?<user>[a-z0-9._-]{1,63})@(?<host>[^/:]+)(?::(?<port>\d+))?$/;
export function parseAddress(text) {
text = text.trim();
let identity = null;
if (text.startsWith("smol://")) {
const rest = text.slice("smol://".length);
const slash = rest.lastIndexOf("/");
if (slash < 0) throw new SmolError(`${text}: smol:// address carries no key`);
identity = b32decode(rest.slice(slash + 1));
if (identity.length !== KEY_LEN)
throw new SmolError(`${text}: key is ${identity.length} bytes, expected ${KEY_LEN}`);
text = rest.slice(0, slash);
}
const m = ADDRESS.exec(text.toLowerCase());
if (!m) throw new SmolError(`'${text}' is not a valid address`);
const { user, host } = m.groups;
if ("._-".includes(user[0]) || "._-".includes(user.at(-1)))
throw new SmolError(`${user} may not begin or end with a separator`);
const port = m.groups.port ? Number(m.groups.port) : DEFAULT_PORT;
return {
user, host, port, identity,
get short() { return `${user}@${host}${port === DEFAULT_PORT ? "" : ":" + port}`; },
uri: (key) => `smol://${user}@${host}${port === DEFAULT_PORT ? "" : ":" + port}/${b32encode(key)}`,
};
}
// --- message format (§5) ----------------------------------------------------
// §5.4: derived from the envelope so no sender can choose it; used whole,
// nothing truncates it.
export function messageId(envelope) {
return sha256(concat(LABEL.id, envelope));
}
// §5.2 and §5.3. The ephemeral key is thrown away after sealing, so the sender
// cannot decrypt what they sent; opts.esk exists only so tests can pin it.
export function seal(identity, recipient, body, when = nowSeconds(), opts = {}) {
const esk = opts.esk ?? randomBytes(KEY_LEN);
const epk = x25519Base(esk);
const key = hkdfSha256(x25519(esk, ed25519ToX25519(recipient)), concat(epk, recipient), LABEL.seal);
const header = concat(Uint8Array.of(1), identity.publicKey, i64BE(when), u32BE(body.length));
let plaintext = concat(header, body,
ed25519Sign(identity.seed, concat(LABEL.msg, recipient, epk, header, body)));
if (opts.pad !== false)
plaintext = concat(plaintext, new Uint8Array((PAD_TO - plaintext.length % PAD_TO) % PAD_TO));
const aad = concat(utf8Bytes("SMOL"), Uint8Array.of(1), recipient, epk);
return concat(aad, aeadEncrypt(key, new Uint8Array(12), plaintext, aad));
}
// Inverse of seal(); throws unless the signature and the recipient both check
// out. `identities` may include retired keys, per §7.
export function unseal(identities, envelope) {
if (envelope.length < ENVELOPE_HEADER + 16) throw new SmolError("envelope too short");
if (utf8Bytes("SMOL").some((b, i) => b !== envelope[i])) throw new SmolError("not a Smol Mail envelope");
if (envelope[4] !== 1) throw new SmolError(`unsupported envelope version ${envelope[4]}`);
const to = envelope.slice(5, 37), epk = envelope.slice(37, 69), sealed = envelope.slice(69);
const me = identities.find(i => timingSafeEqual(i.publicKey, to));
if (!me) throw new SmolError(`addressed to ${b32encode(to).slice(0, 16)}…, not one of our keys`);
const key = hkdfSha256(x25519(ed25519SeedToX25519(me.seed), epk), concat(epk, to), LABEL.seal);
let plaintext;
try {
plaintext = aeadDecrypt(key, new Uint8Array(12), sealed, envelope.slice(0, ENVELOPE_HEADER));
} catch {
throw new SmolError("decryption failed: wrong key or corrupt envelope");
}
const r = new Reader(plaintext);
if (r.u8() !== 1) throw new SmolError("unsupported payload version");
const sender = r.take(KEY_LEN), when = r.i64(), bodyLen = r.u32();
if (bodyLen > r.left) throw new SmolError("payload body length exceeds the payload");
const body = r.take(bodyLen), signature = r.take(SIG_LEN); // trailing bytes are padding
if (!ed25519Verify(sender, concat(LABEL.msg, to, epk, plaintext.slice(0, PAYLOAD_HEADER), body), signature))
throw new SmolError("signature does not verify");
if (when > BigInt(nowSeconds() + MAX_SKEW)) throw new SmolError("payload is dated in the future");
return { sender, time: Number(when), body, id: messageId(envelope) };
}
// --- body frontmatter (§5.5) ------------------------------------------------
const FM_KEY = /^[A-Za-z0-9-]{1,64}$/;
// A flat `Key: value` block, deliberately not YAML. Any malformed line
// invalidates the whole block, which is then returned as ordinary body text:
// frontmatter fails closed toward display, never toward silent discard. Keys
// are compared case-insensitively (§5.5), so they are returned lowercased.
export function parseFrontmatter(text) {
if (!text.startsWith("---\n")) return { fields: {}, body: text };
const lines = text.split("\n");
const close = lines.indexOf("---", 1);
if (close < 0) return { fields: {}, body: text };
const block = lines.slice(1, close), rest = lines.slice(close + 1).join("\n");
const encoded = block.map(line => utf8Bytes(line).length + 1);
if (block.length > FRONTMATTER_KEYS || encoded.reduce((a, b) => a + b, 0) > FRONTMATTER_MAX)
return { fields: {}, body: text };
const fields = {};
for (let i = 0; i < block.length; i++) {
const line = block[i], colon = line.indexOf(":");
if (colon < 0 || !FM_KEY.test(line.slice(0, colon))) return { fields: {}, body: text };
const key = line.slice(0, colon).toLowerCase();
if (!(key in fields)) fields[key] = line.slice(colon + 1).trim(); // first occurrence wins
}
return { fields, body: rest };
}
// Emit a block only when needed, including to escape a body that genuinely
// begins with `---` (§5.5).
export function buildFrontmatter(fields, body) {
if (!fields.length && !body.startsWith("---\n")) return body;
return `---\n${fields.map(([k, v]) => `${k}: ${v}\n`).join("")}---\n${body}`;
}
// --- key rotation (§7) -------------------------------------------------------
// old_pub 32 || new_pub 32 || time 8 || sig_old 64 || sig_new 64. Both keys
// sign, so the old key alone cannot hand the username to a key nobody
// controls; the username is covered but not carried, so a verifier always
// supplies the one it is checking.
export function makeCert(username, oldIdentity, newSeed, when = nowSeconds()) {
const newPub = ed25519PublicKey(newSeed), time = i64BE(when);
const signed = concat(LABEL.rotate, utf8Bytes(username), oldIdentity.publicKey, newPub, time);
return concat(oldIdentity.publicKey, newPub, time,
ed25519Sign(oldIdentity.seed, signed), ed25519Sign(newSeed, signed));
}
// Accept a key change only when a signed chain leads from the key we hold to
// the one the server now returns, both keys signing each link (§7).
export function walkChain(username, pinned, current, chain) {
const same = (a, b) => timingSafeEqual(a, b);
if (same(pinned, current)) return true;
if (!chain.length || chain.length > MAX_CHAIN) return false;
let key = pinned, started = false;
for (const cert of chain) {
const old = cert.slice(0, 32), next = cert.slice(32, 64), when = cert.slice(64, 72);
const sigOld = cert.slice(72, 136), sigNew = cert.slice(136, 200);
if (!started) {
if (!same(old, key)) continue; // a link predating the key we hold
started = true;
} else if (!same(old, key)) {
return false; // the chain is not continuous
}
const signed = concat(LABEL.rotate, utf8Bytes(username), old, next, when);
if (!ed25519Verify(old, signed, sigOld) || !ed25519Verify(next, signed, sigNew)) return false;
key = next;
}
return started && same(key, current);
}
// --- framing and operations (§4, §6) -----------------------------------------
// Reassembles a byte stream (WebSocket or TCP) into exact-length reads.
class ByteStream {
constructor(stream, timeoutMs = 0) {
this.chunks = [];
this.length = 0;
this.closed = null;
this.waiters = [];
this.timeoutMs = timeoutMs;
stream.onData = (data) => { this.chunks.push(data); this.length += data.length; this.wake(); };
stream.onClose = () => this.fail(new SmolError("server closed the connection"));
}
wake() {
this.waiters = this.waiters.filter(w => {
if (this.closed) { w.reject(this.closed); return false; }
if (this.length >= w.need) { w.resolve(); return false; }
return true;
});
}
fail(error) {
this.closed = error;
this.waiters.forEach(w => w.reject(error));
this.waiters = [];
}
async readExact(n) {
if (this.closed) throw this.closed;
if (this.length < n) {
await new Promise((resolve, reject) => {
const waiter = { need: n, resolve, reject };
this.waiters.push(waiter);
if (!this.timeoutMs) return;
// Settings' timeout, applied per read: a stalled handshake or request
// otherwise waits here forever, since nothing else ever rejects it.
setTimeout(() => {
if (!this.waiters.includes(waiter)) return; // already settled elsewhere
this.waiters = this.waiters.filter(w => w !== waiter);
reject(new SmolError(`no response from the server within ${this.timeoutMs / 1000}s`));
}, this.timeoutMs);
});
if (this.closed) throw this.closed;
}
const out = new Uint8Array(n);
let off = 0;
while (off < n) {
const chunk = this.chunks[0];
const take = Math.min(chunk.length, n - off);
out.set(off ? chunk.slice(0, take) : chunk.subarray(0, take), off);
if (take === chunk.length) this.chunks.shift();
else this.chunks[0] = chunk.subarray(take);
off += take;
this.length -= take;
}
return out;
}
}
// One Noise session: application frames split across u16-prefixed Noise
// messages, requests and responses as in §6.1.
export class Session {
constructor(wire, stream, send, recv) {
this.wire = wire;
this.stream = stream;
this.send = send;
this.recv = recv;
}
async readNoise() {
const head = await this.wire.readExact(2);
const length = (head[0] << 8) | head[1];
if (length < 16) throw new SmolError(`server sent a ${length}-byte Noise message`);
return this.recv.decrypt(await this.wire.readExact(length));
}
async call(op, body = new Uint8Array(0)) {
const frame = concat(u32BE(1 + body.length), Uint8Array.of(op), body);
if (frame.length > MAX_FRAME + 4) throw new SmolError("request exceeds the maximum frame size");
for (let off = 0; off < frame.length; off += NOISE_PAYLOAD) {
const packet = this.send.encrypt(frame.slice(off, off + NOISE_PAYLOAD));
this.stream.send(concat(u16BE(packet.length), packet));
}
let length = -1;
let have = new Uint8Array(0);
while (length < 0 || have.length < 4 + length) {
have = concat(have, await this.readNoise());
if (length < 0 && have.length >= 4) {
length = new DataView(have.buffer).getUint32(0);
// §6.1: the shortest response is a type byte and a status byte.
if (length < 2 || length > MAX_FRAME)
throw new SmolError(`server sent a frame of length ${length}`);
}
}
const payload = have.slice(4, 4 + length);
// §6.1: a response reuses the request's type byte. A mismatch means the
// session desynchronised, which must not be mistaken for a status.
if (payload[0] !== op)
throw new SmolError(`server answered op 0x${payload[0].toString(16)}, expected 0x${op.toString(16)}`);
return { status: payload[1], body: payload.slice(2) }; // op echo, status, body (§6.1)
}
}
// Handshake plus §4 pinning. Returns the session, the server's static key as
// revealed by the handshake, and whether that key was already pinned.
export async function openSession(stream, host, pinned = null, timeoutMs = 0) {
const wire = new ByteStream(stream, timeoutMs);
const nx = new NxInitiator();
const m1 = nx.writeMessage1();
stream.send(concat(u16BE(m1.length), m1));
const head = await wire.readExact(2);
const ciphers = nx.readMessage2(await wire.readExact((head[0] << 8) | head[1]));
if (pinned && !timingSafeEqual(pinned, nx.serverStatic))
throw new SmolError(`${host} presented a different key than the one pinned\n` +
` pinned: ${b32encode(pinned)}\n presented: ${b32encode(nx.serverStatic)}`);
return {
session: new Session(wire, stream, ciphers.send, ciphers.recv),
serverStatic: nx.serverStatic,
pinned: pinned !== null,
handshakeHash: nx.handshakeHash,
};
}
export function expectOk(status, what) {
if (status !== 0) throw new SmolError(`${what} failed: ${STATUS[status] ?? status} (${status})`);
}
// §4 session authentication: sign the handshake hash, which binds the
// signature to this session's server ephemeral and cannot be replayed, and
// push the accept token set (§5.8). `sync = 0` leaves the server's stored set
// untouched and `tokens` MUST then be empty; `sync = 1` replaces it exactly.
// Returns the number of accept tokens the server now holds.
export async function authenticate(session, handshakeHash, username, identity, { sync = 0, tokens = [] } = {}) {
const name = utf8Bytes(username);
if (name.length > 255) throw new SmolError("username too long");
if (tokens.length > 0xffff) throw new SmolError("too many accept tokens for one AUTH");
const body = concat(Uint8Array.of(name.length), name, identity.publicKey,
ed25519Sign(identity.seed, concat(LABEL.auth, handshakeHash)),
Uint8Array.of(sync), u16BE(tokens.length), ...tokens);
const { status, body: reply } = await session.call(OP.AUTH, body);
expectOk(status, "authentication");
return reply.length >= 2 ? new Reader(reply).u16() : 0;
}
// RESOLVE, returning the current key and its rotation chain (§6.1).
export async function resolveOp(session, user) {
const name = utf8Bytes(user);
if (name.length > 255) throw new SmolError("username too long");
const { status, body } = await session.call(OP.RESOLVE, concat(Uint8Array.of(name.length), name));
expectOk(status, `resolving ${user}`);
const r = new Reader(body);
return { identity: r.take(KEY_LEN), chain: Array.from({ length: r.u8() }, () => r.take(CERT_LEN)) };
}
// §5.8: `mac` is the sender's proof of an accept token, absent or TOKEN_LEN bytes.
export async function sendOp(session, envelope, mac = null) {
const macBytes = mac ?? new Uint8Array(0);
if (macBytes.length && macBytes.length !== TOKEN_LEN)
throw new SmolError(`accept MAC must be ${TOKEN_LEN} bytes`);
const body = concat(Uint8Array.of(macBytes.length), macBytes, envelope);
const { status, body: reply } = await session.call(OP.SEND, body);
expectOk(status, "sending");
return reply.length === ID_LEN ? reply : messageId(envelope);
}
// §6.1: pages forward from a cursor; an all-zero id starts at the beginning.
export async function fetchOp(session, afterReceivedAt = 0, afterId = new Uint8Array(ID_LEN)) {
const body = concat(i64BE(afterReceivedAt), afterId);
const { status, body: reply } = await session.call(OP.FETCH, body);
expectOk(status, "fetching");
const r = new Reader(reply);
return Array.from({ length: r.u16() }, () => {
const id = r.take(ID_LEN), receivedAt = Number(r.i64()), flags = r.u8();
return { id, receivedAt, flags, envelope: r.take(r.u32()), isRequest: Boolean(flags & FLAG_REQUESTS) };
});
}
export async function deleteOp(session, ids) {
if (ids.length > 0xffff) throw new SmolError("too many ids for one DELETE");
const body = concat(u16BE(ids.length), ...ids);
const { status, body: reply } = await session.call(OP.DELETE, body);
expectOk(status, "acknowledging");
return new Reader(reply).u16();
}
// §6.1: the signature is proof of possession, bound to the server that will
// store the binding so it cannot be replayed to another server.
export function registerSigned(serverStatic, username, identity) {
return concat(LABEL.register, serverStatic, utf8Bytes(username), identity);
}
export async function registerOp(session, serverStatic, { username, identity, token = "", cert = null }) {
const name = utf8Bytes(username);
const tokenBytes = utf8Bytes(token);
if (name.length > 255 || tokenBytes.length > 255 || cert && cert.length > 255)
throw new SmolError("REGISTER field too long");
const body = concat(Uint8Array.of(name.length), name, identity.publicKey,
ed25519Sign(identity.seed, registerSigned(serverStatic, username, identity.publicKey)),
Uint8Array.of(tokenBytes.length), tokenBytes,
Uint8Array.of(cert ? cert.length : 0), cert ?? new Uint8Array(0));
const { status } = await session.call(OP.REGISTER, body);
expectOk(status, `registering ${username}`);
}

564
web/js/store.js Normal file
View file

@ -0,0 +1,564 @@
// Browser state: identity, pins, contacts and read markers in localStorage;
// sealed envelopes in IndexedDB, opened only on demand, so nothing at rest
// is plaintext (the master secret excepted — it is the user's browser profile).
import { aeadDecrypt, aeadEncrypt, hex, hkdfSha256, randomBytes, unhex, utf8Bytes } from "./crypto.js";
import { ID_LEN, MAX_CHAIN, b32decode, b32encode, identityFromSeed, identitySeed, tokenFor } from "./proto.js";
const KEY = "gsmol";
export const TIER_MAIN = 0, TIER_REQUESTS = 1;
// Every getter reads the whole blob and some are called per message row, so the
// parse is cached. `derived` holds the identities that follow from it, which
// cost an Ed25519 scalar multiplication each.
let cached = null, derived = null;
function load() {
if (cached) return cached;
try {
cached = JSON.parse(localStorage.getItem(KEY) || "{}");
} catch {
cached = {};
}
return cached;
}
function save(state) {
localStorage.setItem(KEY, JSON.stringify(state));
cached = state;
derived = null;
}
// Another tab writing this key leaves our copy stale.
window.addEventListener("storage", (event) => {
if (event.key === KEY || event.key === null) cached = derived = null;
});
function update(fn) {
const state = load();
const next = fn(state);
save(next ?? state);
}
// --- identity (§2) -------------------------------------------------------------
export function master() {
const raw = load().master;
return raw ? unhex(raw) : null;
}
// The rotation index (§7) of the identity currently in use.
export function rotations() {
return load().rotations ?? 0;
}
export function identity() {
return identities()[0] ?? null;
}
// §7: every key rotated away from is re-derivable from the master, since mail
// sealed to a superseded key is readable with nothing else.
export function identities() {
if (derived) return derived;
const m = master();
if (!m) return [];
derived = [];
for (let n = rotations(); n >= 0; n--) derived.push(identityFromSeed(identitySeed(m, n)));
return derived;
}
function bindMaster(newMaster, rotationIndex, syncOk) {
if (master()) throw new Error("an identity already exists; rotate it instead");
update(state => {
state.master = hex(newMaster);
state.rotations = rotationIndex;
state.syncOk = syncOk;
});
setCursor(0, new Uint8Array(ID_LEN));
}
// A fresh identity: rotation index 0, and an empty accepted set is already
// complete, so it may sync.
export function setIdentity(newMaster) {
bindMaster(newMaster, 0, true);
}
// §2: recovering a master alone does not recover which correspondents were
// accepted, so that set must not overwrite the server's until rebuilt.
export function restoreMaster(newMaster, rotationIndex = 0) {
bindMaster(newMaster, rotationIndex, false);
}
// Corrects the rotation index once a RESOLVE against the account's address
// reveals which one the server actually has bound (see restoreMaster()'s
// default of 0, the common case of a never-rotated identity).
export function setRotationIndex(n) {
update(state => { state.rotations = n; });
}
// Whether the accepted-correspondent set held here may replace the server's
// on the next AUTH — false right after a restore from the master alone, whose
// empty set must not erase the server's (§4).
export function syncOk() {
return load().syncOk ?? true;
}
export function setSyncOk(ok) {
update(state => { state.syncOk = ok; });
}
// Rotation (§7): only the index advances; the superseded key stays derivable
// from the master, so nothing has to be archived.
export function advanceRotation() {
const current = rotations();
if (!master()) throw new Error("no identity to rotate");
if (current >= MAX_CHAIN) throw new Error(`the rotation chain is full at ${MAX_CHAIN} links`);
update(state => { state.rotations = current + 1; });
}
// --- account and server pins ---------------------------------------------------
export function account() {
const a = load().account;
return a ? { ...a } : null;
}
export function setAccount(addr) {
update(state => {
state.account = { user: addr.user, host: addr.host, port: addr.port };
});
}
export function serverPin(host) {
const raw = (load().servers || {})[host];
return raw ? b32decode(raw) : null;
}
export function pinServer(host, keyBytes) {
update(state => {
state.servers = { ...(state.servers || {}), [host]: b32encode(keyBytes) };
});
}
export function allPins() {
return Object.entries(load().servers || {}).map(([host, key]) => ({ host, key }));
}
// §4/first contact: when true, resolving a never-before-seen recipient's key
// from a server that isn't pinned is refused instead of merely toasted after
// the envelope is already sealed. Persisted, not in-memory — an in-memory
// toggle fails open on every restart, worth little as a security setting.
export function requirePinnedServer() {
return load().requirePinnedServer ?? false;
}
export function setRequirePinnedServer(value) {
update(state => { state.requirePinnedServer = value; });
}
// --- FETCH behavior and cursor (§6.1) -------------------------------------------
// When true, fetch does not acknowledge (delete) what it retrieves — mail
// stays on the server until explicitly deleted. Defaults to the original
// behavior: fetched mail is acknowledged immediately.
export function leaveOnServer() {
return load().leaveOnServer ?? false;
}
export function setLeaveOnServer(value) {
update(state => { state.leaveOnServer = value; });
}
// Seconds a network call waits for a response before giving up. Applied per
// read, not per operation, so a slow-but-trickling exchange is not cut off
// just because it spans several reads.
export function timeoutSeconds() {
return load().timeoutSeconds ?? 30;
}
export function setTimeoutSeconds(value) {
const n = Math.trunc(Number(value));
if (!Number.isFinite(n)) return; // non-numeric input is ignored, not an error
update(state => { state.timeoutSeconds = Math.min(600, Math.max(1, n)); });
}
// Undoes setIdentity()/restoreMaster(): onboarding's "back"/"start over",
// before anything is bound to an account. Pins and contacts are left alone —
// server trust is not identity-scoped.
export function discardIdentity() {
if (account()) throw new Error("already bound to an account; log out instead");
update(state => {
delete state.master;
delete state.rotations;
delete state.syncOk;
delete state.afterTime;
delete state.afterId;
});
}
export function cursor() {
const state = load();
const afterId = state.afterId;
return [state.afterTime ?? 0, afterId ? unhex(afterId) : new Uint8Array(ID_LEN)];
}
export function setCursor(afterTime, afterId) {
update(state => {
state.afterTime = afterTime;
state.afterId = hex(afterId);
});
}
// --- contacts ------------------------------------------------------------------
export function contact(address) {
const c = (load().contacts || {})[address];
return c ? { key: b32decode(c.key), verified: c.verified, history: c.history ?? [] } : null;
}
// A key that displaces another is kept: it is the only local record that the
// contact rotated, and §8 turns on the user being able to notice such changes.
// Re-saving the same key is not a rotation and must not add an entry.
export function saveContact(address, keyBytes, verified) {
const key = b32encode(keyBytes);
update(state => {
const contacts = { ...(state.contacts || {}) };
const previous = contacts[address];
const history = previous && previous.key !== key
? [...(previous.history ?? []), { key: previous.key, until: Date.now() }]
: previous?.history ?? [];
contacts[address] = { key, verified, seenAt: Date.now(), ...(history.length && { history }) };
state.contacts = contacts;
});
}
export function addressForKey(keyBytes) {
return Object.entries(load().contacts || {})
.find(([, c]) => c.key === b32encode(keyBytes))?.[0] ?? null;
}
export function allContacts() {
return Object.entries(load().contacts || {})
.map(([address, c]) => ({ address, key: c.key, verified: c.verified, history: c.history ?? [] }));
}
// --- accept tokens (§5.8) --------------------------------------------------------
export function accepted(address) {
const a = (load().accepted || {})[address];
return a ? { identity: b32decode(a.identity), active: a.active } : null;
}
// Admit a contact to the main tier. The identity is frozen at acceptance — a
// re-accept after a block must not change which key the token is derived
// from (§5.8).
export function accept(address, identityBytes) {
update(state => {
const table = { ...(state.accepted || {}) };
const previous = table[address];
table[address] = {
identity: previous?.identity ?? b32encode(identityBytes),
active: true,
addedAt: previous?.addedAt ?? Date.now(),
};
state.accepted = table;
});
}
// Withdraw a contact's accept token; their mail lands in the requests tier
// from their next message on. Throws if the contact was never accepted.
export function block(address) {
if (!(load().accepted || {})[address]) throw new Error(`${address} was never accepted`);
update(state => {
const table = { ...(state.accepted || {}) };
table[address] = { ...table[address], active: false };
state.accepted = table;
});
}
export function allAccepted() {
return Object.entries(load().accepted || {})
.map(([address, a]) => ({ address, identity: b32decode(a.identity), active: a.active }));
}
// §4: the tokens to push with AUTH, and whether to push at all. A client that
// cannot vouch for its own set — one restored from the master alone — must
// not replace the server's with an incomplete one.
export function tokenSet(masterBytes) {
if (!syncOk()) return { sync: 0, tokens: [] };
const active = allAccepted().filter(a => a.active).sort((a, b) => a.address.localeCompare(b.address));
return { sync: 1, tokens: active.map(a => tokenFor(masterBytes, a.identity)) };
}
// A token received from a correspondent, filed under the address that issued
// it: an address outlives the keys behind it, so the token keeps working
// across the issuer's rotations (§5.8).
export function tokenFrom(address) {
const raw = (load().tokens || {})[address];
return raw ? b32decode(raw.token) : null;
}
export function learnToken(address, tokenBytes) {
update(state => {
const tokens = { ...(state.tokens || {}) };
tokens[address] = { token: b32encode(tokenBytes), seenAt: Date.now() };
state.tokens = tokens;
});
}
// --- read markers ---------------------------------------------------------------
export function markRead(idHex) {
update(state => {
state.read = { ...(state.read || {}), [idHex]: true };
});
}
export function isRead(idHex) {
return Boolean((load().read || {})[idHex]);
}
// --- appearance ------------------------------------------------------------
// "light" or "dark" to pin a side; null to follow the system.
export function theme() {
return load().theme ?? null;
}
export function setTheme(value) {
update(state => {
if (value) state.theme = value;
else delete state.theme;
});
}
// --- sealed mail (IndexedDB) ------------------------------------------------------
const DB_NAME = "gsmol", DB_VERSION = 1;
// Held open: reopening per read cost more than the reads themselves.
let dbPromise = null;
function openDb() {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
for (const box of ["inbox", "sent"]) {
if (!db.objectStoreNames.contains(box)) db.createObjectStore(box, { keyPath: "id" });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
}).catch(error => { dbPromise = null; throw error; });
return dbPromise;
}
function tx(db, box, mode, fn) {
return new Promise((resolve, reject) => {
const request = fn(db.transaction(box, mode).objectStore(box));
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// "requests" is a view over the same physical "inbox" records, filtered by
// tier (§5.8) — not a separate folder, so a message keeps one identity
// regardless of which tier it arrived in.
const physicalFolder = (folder) => folder === "requests" ? "inbox" : folder;
export async function storeMessage(box, record) {
const db = await openDb();
await tx(db, box, "readwrite", store => store.put(record));
}
// Returns null when the id already exists, so fetch can leave server state alone.
export async function storeIfNew(box, record) {
const db = await openDb();
const existing = await tx(db, box, "readonly", store => store.get(record.id));
if (existing) return null;
await tx(db, box, "readwrite", store => store.put(record));
return record;
}
export async function listMessages(folder) {
const physical = physicalFolder(folder);
const db = await openDb();
const rows = await tx(db, physical, "readonly", store => store.getAll());
const wantTier = folder === "requests" ? TIER_REQUESTS : TIER_MAIN;
const filtered = physical === "inbox" ? rows.filter(r => (r.tier ?? TIER_MAIN) === wantTier) : rows;
return filtered.sort((a, b) => (b.receivedAt ?? b.sentAt) - (a.receivedAt ?? a.sentAt)); // newest first
}
export async function getMessage(folder, id) {
const db = await openDb();
return await tx(db, physicalFolder(folder), "readonly", store => store.get(id)) ?? null;
}
export async function removeMessage(folder, id) {
const db = await openDb();
await tx(db, physicalFolder(folder), "readwrite", store => store.delete(id));
}
// The unread badge must survive leaving the inbox, so it is counted from ids
// and read markers alone — no envelope is opened.
async function unreadIn(folder) {
const rows = await listMessages(folder);
const read = load().read || {};
return rows.filter(row => !read[row.id]).length;
}
export const unreadCount = () => unreadIn("inbox");
export const requestsUnreadCount = () => unreadIn("requests");
// --- export / import: mail, contacts, pins — never the master --------------------
// btoa/atob work on JS's UTF-16 "binary string" convention (one code unit per
// byte); this just bridges that to Uint8Array. Browser-only, like the rest of
// this file — no Node test exercises store.js, unlike crypto.js/proto.js.
const toBase64 = (bytes) => btoa(String.fromCharCode(...bytes));
const fromBase64 = (text) => Uint8Array.from(atob(text), c => c.charCodeAt(0));
// A gsmol-only construction, not the wire protocol's own HKDF namespace
// ("smolmail/1 ..." in proto.js) — export/import has no counterpart in
// SPEC.md, so it gets its own label rather than borrowing the protocol's.
// Sealing to a key derived from the identity's own master means the file is
// opaque without it — no new passphrase to manage, and it can only ever be
// opened where that master is already restored, exactly the situation import
// already assumes.
const EXPORT_LABEL = utf8Bytes("gsmol/1 export");
const exportKey = (masterBytes) => hkdfSha256(masterBytes, new Uint8Array(0), EXPORT_LABEL, 32);
// Deliberately excludes the master: it already has its own reveal-and-copy
// flow in settings, meant for a password manager, not a downloadable file.
// Beyond that, everything else in here was worth hiding too — contacts and
// pins are a social graph and a list of which mail servers you use, not just
// the mail SPEC.md §5 makes irreplaceable once fetched — so the whole payload
// is sealed, not just the parts that were already ciphertext at rest.
export async function exportData() {
const m = master();
if (!m) throw new Error("no identity yet");
const [inbox, requests, sent] = await Promise.all(
[listMessages("inbox"), listMessages("requests"), listMessages("sent")]);
const payload = {
servers: Object.fromEntries(allPins().map(({ host, key }) => [host, key])),
contacts: Object.fromEntries(allContacts().map(({ address, key, verified, history }) =>
[address, { key, verified, history }])),
inbox: [...inbox, ...requests].map(row => ({
id: row.id, receivedAt: row.receivedAt, envelope: toBase64(row.envelope),
tier: row.tier ?? TIER_MAIN, keptOnServer: row.keptOnServer ?? false,
})),
sent: sent.map(row =>
({ id: row.id, recipient: row.recipient, sentAt: row.sentAt, envelope: toBase64(row.envelope) })),
};
// Random per export: the key is the same every time (deterministic from the
// master), so nonce reuse has to be ruled out the way it always is under a
// fixed key — a fresh 96-bit nonce per seal, not a fixed one like seal()'s
// in proto.js gets away with (there, a fresh ephemeral key each message
// makes the derived key itself unique, so a zero nonce is safe).
const nonce = randomBytes(12);
const ciphertext = aeadEncrypt(exportKey(m), nonce, utf8Bytes(JSON.stringify(payload)), new Uint8Array(0));
return {
gsmolExport: 2,
exportedAt: Date.now(),
nonce: toBase64(nonce),
ciphertext: toBase64(ciphertext),
};
}
// Never overwrites a trust binding that already differs locally — the same
// rule refreshContact()/saveReplyAddress() apply elsewhere: an existing pin
// or contact key changes only by explicit user action, never silently. A
// malformed entry (hand-edited file, corruption) is skipped, not fatal — one
// bad record cannot abort the rest of the import, matching describe()'s
// per-message fail-open elsewhere in the app.
// A plain object, as JSON.parse would produce for `{...}`; Object.entries()
// on a string iterates its characters rather than failing, which is exactly
// the kind of malformed input this rejects as one unit instead of one per char.
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
export async function importData(data) {
let payload;
if (data?.gsmolExport === 2) {
const m = master();
if (!m) throw new Error("no identity yet — restore it before importing");
try {
const plaintext = aeadDecrypt(
exportKey(m), fromBase64(data.nonce), fromBase64(data.ciphertext), new Uint8Array(0));
payload = JSON.parse(new TextDecoder().decode(plaintext));
} catch {
throw new Error("couldn't decrypt — exported by a different identity, or the file is corrupted");
}
} else if (data?.gsmolExport === 1) {
payload = data; // pre-encryption shape: the fields already sit at the top level
} else {
throw new Error("not a gsmol export file");
}
const summary = { pinsAdded: 0, pinsConflicted: 0, contactsAdded: 0, contactsConflicted: 0,
mailAdded: 0, malformed: 0 };
update(state => {
const servers = { ...(state.servers || {}) };
if (payload.servers !== undefined && !isRecord(payload.servers)) summary.malformed++;
for (const [host, key] of Object.entries(isRecord(payload.servers) ? payload.servers : {})) {
try {
if (b32decode(key).length !== 32) throw new Error("bad length");
} catch { summary.malformed++; continue; }
if (!(host in servers)) { servers[host] = key; summary.pinsAdded++; }
else if (servers[host] !== key) summary.pinsConflicted++;
}
state.servers = servers;
const contacts = { ...(state.contacts || {}) };
if (payload.contacts !== undefined && !isRecord(payload.contacts)) summary.malformed++;
for (const [address, c] of Object.entries(isRecord(payload.contacts) ? payload.contacts : {})) {
try {
if (typeof c.key !== "string" || b32decode(c.key).length !== 32) throw new Error("bad key");
} catch { summary.malformed++; continue; }
if (!(address in contacts)) {
contacts[address] = { key: c.key, verified: Boolean(c.verified), seenAt: Date.now(),
...(Array.isArray(c.history) && c.history.length && { history: c.history }) };
summary.contactsAdded++;
} else if (contacts[address].key !== c.key) {
summary.contactsConflicted++;
}
}
state.contacts = contacts;
});
for (const [box, rows] of [["inbox", payload.inbox], ["sent", payload.sent]]) {
if (rows !== undefined && !Array.isArray(rows)) summary.malformed++;
for (const row of Array.isArray(rows) ? rows : []) {
try {
const record = box === "inbox"
? { id: row.id, receivedAt: row.receivedAt, envelope: fromBase64(row.envelope),
tier: row.tier ?? TIER_MAIN, keptOnServer: Boolean(row.keptOnServer) }
: { id: row.id, recipient: row.recipient, sentAt: row.sentAt, envelope: fromBase64(row.envelope) };
if (await storeIfNew(box, record)) summary.mailAdded++;
} catch { summary.malformed++; }
}
}
return summary;
}
// Logout: erases the master, pins, contacts and every cached message from
// this browser. Closes the held connection first so the delete isn't left
// "blocked" waiting for a handle that never closes on its own.
export async function clearAll() {
localStorage.removeItem(KEY);
cached = derived = null;
if (dbPromise) {
(await dbPromise).close();
dbPromise = null;
}
await new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(DB_NAME);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
request.onblocked = () => resolve(); // still completes once the reload closes every handle
});
}

29
web/js/transport.js Normal file
View file

@ -0,0 +1,29 @@
// The byte pipe to a smolmaild server. Browsers cannot open TCP sockets, so
// bytes travel through the local bridge's WebSocket relay unchanged; the
// Noise session lives in this page either way.
const BRIDGE = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}`;
export function connectStream(host, port, timeoutMs = 0) {
return new Promise((resolve, reject) => {
const socket = new WebSocket(`${BRIDGE}/tcp/${host}/${port}`);
socket.binaryType = "arraybuffer";
const stream = {
send: bytes => socket.send(bytes),
close: () => socket.close(),
onData: null,
onClose: null,
};
// Settings' timeout only governs this; the WebSocket may otherwise never
// fire open, error, or close if something between here and the bridge
// just drops packets.
const timer = timeoutMs ? setTimeout(() => {
socket.close();
reject(new Error(`could not reach ${host}:${port} within ${timeoutMs / 1000}s`));
}, timeoutMs) : null;
socket.onopen = () => { clearTimeout(timer); resolve(stream); };
socket.onerror = () => { clearTimeout(timer); reject(new Error(`cannot reach the bridge at ${BRIDGE}`)); };
socket.onmessage = event => stream.onData?.(new Uint8Array(event.data));
socket.onclose = () => stream.onClose?.();
});
}