feat: adopt smolmail protocol 1.1 with accept tokens and leave-mail-on-server
This commit is contained in:
parent
eef7bf9b1d
commit
d6fa097626
11 changed files with 911 additions and 232 deletions
123
web/js/proto.js
123
web/js/proto.js
|
|
@ -1,18 +1,20 @@
|
|||
// Smol Mail protocol, version 1 (../smolmail SPEC.md): addresses, sealed and
|
||||
// signed envelopes, body frontmatter, key rotation, and the framed request
|
||||
// and response bodies of the five operations.
|
||||
// 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, randomBytes, sha256, timingSafeEqual,
|
||||
utf8Bytes, x25519, x25519Base,
|
||||
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 = 136, ID_LEN = 16;
|
||||
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 };
|
||||
|
|
@ -25,7 +27,9 @@ 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"),
|
||||
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 -------------------------------------------------------
|
||||
|
|
@ -89,16 +93,37 @@ export class Reader {
|
|||
get done() { return this.pos === this.buf.length; }
|
||||
}
|
||||
|
||||
// --- identity ---------------------------------------------------------------
|
||||
// --- identity (§2) ------------------------------------------------------------
|
||||
|
||||
// §2: an Ed25519 keypair with the X25519 agreement keys derived from it.
|
||||
// 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 newIdentity() {
|
||||
return identityFromSeed(randomBytes(KEY_LEN));
|
||||
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) --------------------------------------------------------
|
||||
|
|
@ -132,8 +157,10 @@ export function parseAddress(text) {
|
|||
|
||||
// --- 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)).slice(0, ID_LEN);
|
||||
return sha256(concat(LABEL.id, envelope));
|
||||
}
|
||||
|
||||
// §5.2 and §5.3. The ephemeral key is thrown away after sealing, so the sender
|
||||
|
|
@ -174,6 +201,7 @@ export function unseal(identities, envelope) {
|
|||
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) };
|
||||
}
|
||||
|
||||
|
|
@ -183,7 +211,8 @@ 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.
|
||||
// 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");
|
||||
|
|
@ -197,7 +226,7 @@ export function parseFrontmatter(text) {
|
|||
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);
|
||||
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 };
|
||||
|
|
@ -212,28 +241,35 @@ export function buildFrontmatter(fields, body) {
|
|||
|
||||
// --- key rotation (§7) -------------------------------------------------------
|
||||
|
||||
export function makeCert(oldIdentity, newSeed, when = nowSeconds()) {
|
||||
// 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, concat(LABEL.rotate, 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 (§7).
|
||||
export function walkChain(pinned, current, chain) {
|
||||
// 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), sig = cert.slice(72);
|
||||
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
|
||||
}
|
||||
if (!ed25519Verify(old, concat(LABEL.rotate, old, next, when), sig)) return false;
|
||||
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);
|
||||
|
|
@ -356,13 +392,20 @@ export function expectOk(status, what) {
|
|||
}
|
||||
|
||||
// §4 session authentication: sign the handshake hash, which binds the
|
||||
// signature to this session's server ephemeral and cannot be replayed.
|
||||
export async function authenticate(session, handshakeHash, username, identity) {
|
||||
// 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)));
|
||||
expectOk((await session.call(OP.AUTH, body)).status, "authentication");
|
||||
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).
|
||||
|
|
@ -375,19 +418,26 @@ export async function resolveOp(session, user) {
|
|||
return { identity: r.take(KEY_LEN), chain: Array.from({ length: r.u8() }, () => r.take(CERT_LEN)) };
|
||||
}
|
||||
|
||||
export async function sendOp(session, envelope) {
|
||||
const { status, body } = await session.call(OP.SEND, envelope);
|
||||
// §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 body.length === ID_LEN ? body : messageId(envelope);
|
||||
return reply.length === ID_LEN ? reply : messageId(envelope);
|
||||
}
|
||||
|
||||
export async function fetchOp(session) {
|
||||
const { status, body } = await session.call(OP.FETCH);
|
||||
// §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(body);
|
||||
const r = new Reader(reply);
|
||||
return Array.from({ length: r.u16() }, () => {
|
||||
const id = r.take(ID_LEN), receivedAt = Number(r.i64());
|
||||
return { id, receivedAt, envelope: r.take(r.u32()) };
|
||||
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) };
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -399,12 +449,19 @@ export async function deleteOp(session, ids) {
|
|||
return new Reader(reply).u16();
|
||||
}
|
||||
|
||||
export async function registerOp(session, { username, identity, token = "", cert = null }) {
|
||||
// §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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue