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
23
README.md
23
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# gsmol
|
# gsmol
|
||||||
|
|
||||||
An HTML5 browser client for [Smol Mail](../smolmail), the minimalist end-to-end encrypted mail protocol: one Ed25519 identity, five operations, Noise_NX transport, sealed and signed messages. Everything the reference CLI client does, in a browser tab.
|
An HTML5 browser client for [Smol Mail](../smolmail), the minimalist end-to-end encrypted mail protocol: one 32-byte master secret, five operations, Noise_NX transport, sealed and signed messages, and accept tokens that sort correspondence from cold mail. Everything the reference CLI client does, in a browser tab.
|
||||||
|
|
||||||
## Why there is a bridge
|
## Why there is a bridge
|
||||||
|
|
||||||
|
|
@ -18,7 +18,7 @@ browser (all crypto, all keys) ⇄ bridge.py (dumb byte pipe) ⇄ smolmaild
|
||||||
devbox run serve
|
devbox run serve
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open http://127.0.0.1:8096. First use: create an identity, back up the seed, pin your server's public key (from the operator, through a trusted channel), register an address, and fetch.
|
Then open http://127.0.0.1:8096. First use: create an identity, back up the master, pin your server's public key (from the operator, through a trusted channel), register an address, and fetch.
|
||||||
|
|
||||||
For a server on a non-default port, start the bridge with `uv run bridge.py --allow-port <port>`.
|
For a server on a non-default port, start the bridge with `uv run bridge.py --allow-port <port>`.
|
||||||
|
|
||||||
|
|
@ -64,11 +64,13 @@ The service runs under `DynamicUser`, restarts on failure, and — like the stan
|
||||||
|
|
||||||
## What it implements
|
## What it implements
|
||||||
|
|
||||||
- **Identity** (§2): one 32-byte seed; create or restore; the X25519 agreement keys are derived from the Ed25519 keypair. The seed lives in localStorage, mail stays sealed in IndexedDB — the browser profile is the trust boundary.
|
- **Identity** (§2): one 32-byte master secret; create or restore. Every rotation index's signing seed and the accept key are derived from it with HKDF, so the X25519 agreement keys and every superseded identity are re-derivable rather than archived. The master lives in localStorage, mail stays sealed in IndexedDB — the browser profile is the trust boundary.
|
||||||
- **Addressing** (§3): short `user@host` and self-certifying `smol://` addresses, base32 fingerprints.
|
- **Addressing** (§3): short `user@host` and self-certifying `smol://` addresses, base32 fingerprints.
|
||||||
- **Trust** (§4, §8): server keys pinned on first trust, mismatch aborts the handshake; `RESOLVE` over an unpinned session is marked unverified; registration and fetching require a pinned server.
|
- **Trust** (§4, §8): server keys pinned on first trust, mismatch aborts the handshake; `RESOLVE` over an unpinned session is marked unverified; registration and fetching require a pinned server; `REGISTER` carries a proof-of-possession signature bound to the server's own key, so a binding cannot be replayed elsewhere.
|
||||||
- **Mail** (§5): sealed and signed envelopes, 1 KiB padding, frontmatter bodies, sent copies sealed to self, fetch verifies id and signature before acknowledging — anything unreadable stays on the server. Outgoing mail carries a signed `Reply-To` frontmatter field with the sender's full `smol://` address (an "anonymous" checkbox omits it), so a first-time recipient can name the sender and answer; on receipt such a claim binds only when its key matches the message's signer, and never over an address already pinned to a different key.
|
- **Mail** (§5): sealed and signed envelopes, 1 KiB padding, frontmatter bodies, sent copies sealed to self, fetch pages forward by cursor and verifies id and signature before acknowledging — anything unreadable stays on the server. Outgoing mail carries a signed `Reply-To` frontmatter field with the sender's full `smol://` address (an "anonymous" checkbox omits it), so a first-time recipient can name the sender and answer; on receipt such a claim binds only when its key matches the message's signer, and never over an address already pinned to a different key.
|
||||||
- **Rotation** (§7): rotate with a signed certificate; contacts accept the change from the chain; superseded seeds are kept, since mail sealed to them is readable with nothing else; key changes without a chain are surfaced, never applied silently.
|
- **Accept tokens** (§5.8): mail from an accepted correspondent lands in the inbox's main tier; everything else lands in a separate "requests" tier. Accepting or blocking a contact pushes the changed token set to the server immediately; a learned token is attached automatically to outgoing mail once a correspondent's own token has been received.
|
||||||
|
- **Leave mail on server**: a settings toggle that has fetch page forward by cursor without acknowledging, so mail stays server-side until deleted from the reader — off by default, which acknowledges (and so deletes) fetched mail immediately, as before.
|
||||||
|
- **Rotation** (§7): rotate to the next index's derived key with a certificate both the old and new keys sign, binding the change to this username; contacts accept it from the chain; every superseded key stays derivable from the master, since mail sealed to it is readable with nothing else; key changes without a chain are surfaced, never applied silently.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
|
|
@ -91,14 +93,15 @@ The crypto is pure JavaScript rather than WebCrypto so the exact same modules ru
|
||||||
devbox run test
|
devbox run test
|
||||||
```
|
```
|
||||||
|
|
||||||
1. `test/gen_vectors.py` regenerates `test/vectors.json` from the reference stack (hashlib/hmac, PyNaCl, noiseprotocol): hashes, AEAD, key agreement, signatures, the §2 conversions, a fixed-ephemeral envelope, and a deterministic Noise_NX transcript with internal states.
|
1. `test/gen_vectors.py` regenerates `test/vectors.json` from the reference stack (hashlib/hmac, PyNaCl, noiseprotocol): hashes, AEAD, key agreement, signatures, the §2 conversions, master-derived rotation seeds and accept tokens, a fixed-ephemeral envelope, and a deterministic Noise_NX transcript with internal states.
|
||||||
2. `test/vectors.test.mjs` checks the JS implementation byte for byte against those vectors, plus protocol cases: frontmatter, addresses, rotation chains (accepted, broken, missing link, oversize).
|
2. `test/vectors.test.mjs` checks the JS implementation byte for byte against those vectors, plus protocol cases: frontmatter (case-insensitive keys), addresses, and rotation chains bound to a username (accepted, broken, missing link, oversize, wrong username).
|
||||||
3. `test/e2e.py` runs a live `smolmaild.py` and drives the reference client (Alice) against this client's JS stack (Bob, headless via `test/client.mjs`): registration, both send directions, fetch with acknowledgment, and a rotation whose chain the reference client walks. Bob's reply travels through the bridge's WebSocket relay — the exact byte path the browser takes.
|
3. `test/e2e.py` runs a live `smolmaild.py` and drives the reference client (Alice) against this client's JS stack (Bob, headless via `test/client.mjs`): registration, unsolicited mail landing in the requests tier, accepting a correspondent and the accept-token round trip that follows, fetch with acknowledgment, and a rotation whose chain the reference client walks. Bob's reply travels through the bridge's WebSocket relay — the exact byte path the browser takes.
|
||||||
|
|
||||||
What is not verified here: clicking through the actual browser UI. `devbox run serve` and a manual pass remain the last step; the logic under every button is what the tests exercise.
|
What is not verified here: clicking through the actual browser UI. `devbox run serve` and a manual pass remain the last step; the logic under every button is what the tests exercise.
|
||||||
|
|
||||||
## Notes and limits
|
## Notes and limits
|
||||||
|
|
||||||
- The seed sits in localStorage: an XSS in the page or a compromised browser profile is game over, same as a stolen `identity.key` for the CLI client. The bridge serves a strict Content-Security-Policy (`default-src 'self'`, no inline script or style, no foreign origin) to raise that bar, but it is a mitigation and not a fix. No server push or notifications in v1 (SPEC.md §13); click fetch.
|
- The master sits in localStorage: an XSS in the page or a compromised browser profile is game over, same as a stolen `identity.key` for the CLI client. The bridge serves a strict Content-Security-Policy (`default-src 'self'`, no inline script or style, no foreign origin) to raise that bar, but it is a mitigation and not a fix. No server push or notifications; click fetch.
|
||||||
- Rotation is not revocation (§7): a stolen key can rotate onward and the chain validates. The UI surfaces rotations instead of applying them invisibly; out-of-band re-verification is the only defence.
|
- Rotation is not revocation (§7): a stolen key can rotate onward and the chain validates. The UI surfaces rotations instead of applying them invisibly; out-of-band re-verification is the only defence.
|
||||||
|
- The requests tier is a quota boundary the server enforces, not a filter: it does not refuse anyone's mail outright, which stays the reader's decision after decryption (§5.8).
|
||||||
- The bridge binds to 127.0.0.1 by default and has no TLS of its own. Serving it beyond localhost directly is possible but unadvised: the relay is plaintext-agnostic (the traffic is already end-to-end encrypted, but the bridge would relay anyone's bytes to allowlisted ports). The NixOS module's `nginx.enable` puts a TLS-terminating reverse proxy in front instead, which is the deployment path "Deploy" above documents.
|
- The bridge binds to 127.0.0.1 by default and has no TLS of its own. Serving it beyond localhost directly is possible but unadvised: the relay is plaintext-agnostic (the traffic is already end-to-end encrypted, but the bridge would relay anyone's bytes to allowlisted ports). The NixOS module's `nginx.enable` puts a TLS-terminating reverse proxy in front instead, which is the deployment path "Deploy" above documents.
|
||||||
|
|
|
||||||
183
test/client.mjs
183
test/client.mjs
|
|
@ -5,8 +5,8 @@
|
||||||
// node test/client.mjs --state bob.json <command> [args]
|
// node test/client.mjs --state bob.json <command> [args]
|
||||||
//
|
//
|
||||||
// Commands mirror the reference smolmail.py: keygen, trust, register, resolve,
|
// Commands mirror the reference smolmail.py: keygen, trust, register, resolve,
|
||||||
// import, send, fetch, list, read, rotate. State lives in a JSON file; mail is
|
// import, accept, block, send, fetch, list, read, rotate. State lives in a
|
||||||
// stored sealed, like the browser store does.
|
// JSON file; mail is stored sealed, like the browser store does.
|
||||||
|
|
||||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
|
|
@ -83,10 +83,43 @@ async function open(host, port, { requirePin }) {
|
||||||
return opened;
|
return opened;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// §2: the identity at the state's current rotation index, plus every earlier
|
||||||
|
// one — mail sealed to a superseded key is readable with nothing else.
|
||||||
|
function currentIdentity(state) {
|
||||||
|
return proto.identityFromSeed(proto.identitySeed(unhex(state.master), state.rotations ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
function identities(state) {
|
function identities(state) {
|
||||||
const current = proto.identityFromSeed(unhex(state.seed));
|
const master = unhex(state.master);
|
||||||
const retired = (state.retired || []).map(s => proto.identityFromSeed(unhex(s)));
|
const out = [];
|
||||||
return [current, ...retired];
|
for (let n = state.rotations ?? 0; n >= 0; n--) out.push(proto.identityFromSeed(proto.identitySeed(master, n)));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cursor(state) {
|
||||||
|
return [state.afterTime ?? 0, state.afterId ? unhex(state.afterId) : new Uint8Array(proto.ID_LEN)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// §4/§5.8: the accept tokens to push with AUTH, and whether to push at all —
|
||||||
|
// a client that cannot vouch for its own set (freshly restored) must not
|
||||||
|
// replace the server's with an incomplete one.
|
||||||
|
function tokenSet(state) {
|
||||||
|
if (state.syncOk === false) return { sync: 0, tokens: [] };
|
||||||
|
const accepted = Object.entries(state.accepted || {}).filter(([, a]) => a.active)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
const master = unhex(state.master);
|
||||||
|
return { sync: 1, tokens: accepted.map(([, a]) => proto.tokenFor(master, b32decode(a.identity))) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addressOfSigner(state, sender, replyToField) {
|
||||||
|
const known = Object.entries(state.contacts || {}).find(([, c]) => c.key === b32encode(sender))?.[0];
|
||||||
|
if (known) return known;
|
||||||
|
if (!replyToField) return null;
|
||||||
|
try {
|
||||||
|
const parsed = proto.parseAddress(replyToField);
|
||||||
|
if (parsed.identity && timingSafeEqual(parsed.identity, sender)) return parsed.short;
|
||||||
|
} catch { /* malformed claim */ }
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- commands -------------------------------------------------------------------
|
// --- commands -------------------------------------------------------------------
|
||||||
|
|
@ -94,9 +127,11 @@ function identities(state) {
|
||||||
const commands = {
|
const commands = {
|
||||||
keygen() {
|
keygen() {
|
||||||
const state = load();
|
const state = load();
|
||||||
state.seed = hex(cryptoRandom(32));
|
state.master = hex(cryptoRandom(32));
|
||||||
|
state.rotations = 0;
|
||||||
|
state.syncOk = true;
|
||||||
save(state);
|
save(state);
|
||||||
const me = proto.identityFromSeed(unhex(state.seed));
|
const me = currentIdentity(state);
|
||||||
console.log(`public key: ${b32encode(me.publicKey)}`);
|
console.log(`public key: ${b32encode(me.publicKey)}`);
|
||||||
console.log(`fingerprint: ${fingerprint(me.publicKey)}`);
|
console.log(`fingerprint: ${fingerprint(me.publicKey)}`);
|
||||||
},
|
},
|
||||||
|
|
@ -112,11 +147,11 @@ const commands = {
|
||||||
|
|
||||||
async register() {
|
async register() {
|
||||||
const state = load();
|
const state = load();
|
||||||
const me = proto.identityFromSeed(unhex(state.seed));
|
const me = currentIdentity(state);
|
||||||
const addr = proto.parseAddress(rest[0]);
|
const addr = proto.parseAddress(rest[0]);
|
||||||
const opened = await open(addr.host, addr.port, { requirePin: true });
|
const opened = await open(addr.host, addr.port, { requirePin: true });
|
||||||
try {
|
try {
|
||||||
await proto.registerOp(opened.session, {
|
await proto.registerOp(opened.session, opened.serverStatic, {
|
||||||
username: addr.user, identity: me, token: parseOption("--token") || "",
|
username: addr.user, identity: me, token: parseOption("--token") || "",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -147,7 +182,7 @@ const commands = {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (timingSafeEqual(b32decode(known.key), current)) return console.log(`${addr.short}: unchanged`);
|
if (timingSafeEqual(b32decode(known.key), current)) return console.log(`${addr.short}: unchanged`);
|
||||||
if (proto.walkChain(b32decode(known.key), current, chain)) {
|
if (proto.walkChain(addr.user, b32decode(known.key), current, chain)) {
|
||||||
state.contacts[addr.short] = { key: b32encode(current), verified: known.verified };
|
state.contacts[addr.short] = { key: b32encode(current), verified: known.verified };
|
||||||
save(state);
|
save(state);
|
||||||
console.log(`warning: ${addr.short} rotated its key; a signed chain confirms it`);
|
console.log(`warning: ${addr.short} rotated its key; a signed chain confirms it`);
|
||||||
|
|
@ -170,13 +205,44 @@ const commands = {
|
||||||
|
|
||||||
contacts() {
|
contacts() {
|
||||||
const state = load();
|
const state = load();
|
||||||
for (const [address, c] of Object.entries(state.contacts || {}))
|
for (const [address, c] of Object.entries(state.contacts || {})) {
|
||||||
console.log(`${address} ${c.key} ${c.verified ? "verified" : "tofu"}`);
|
const accepted = state.accepted?.[address];
|
||||||
|
const tag = accepted ? (accepted.active ? "accepted" : "blocked") : "";
|
||||||
|
console.log(`${address} ${c.key} ${c.verified ? "verified" : "tofu"} ${tag}`.trimEnd());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// §5.8: admit a contact to the main tier; their token travels in our next
|
||||||
|
// message to them. Pushed to the server right away.
|
||||||
|
async accept() {
|
||||||
|
const state = load();
|
||||||
|
const address = rest[0];
|
||||||
|
const contact = state.contacts?.[address];
|
||||||
|
if (!contact) throw new proto.SmolError(`no key for ${address} yet`);
|
||||||
|
state.accepted = { ...(state.accepted || {}),
|
||||||
|
[address]: { identity: state.accepted?.[address]?.identity ?? contact.key, active: true } };
|
||||||
|
state.syncOk = true;
|
||||||
|
save(state);
|
||||||
|
const held = await pushTokens(state);
|
||||||
|
console.log(`accepted ${address}; server now holds ${held} accept token(s)`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// §5.8: withdraw a contact's accept token; their mail lands in requests
|
||||||
|
// from their next message on.
|
||||||
|
async block() {
|
||||||
|
const state = load();
|
||||||
|
const address = rest[0];
|
||||||
|
if (!state.accepted?.[address]) throw new proto.SmolError(`${address} was never accepted`);
|
||||||
|
state.accepted[address] = { ...state.accepted[address], active: false };
|
||||||
|
save(state);
|
||||||
|
const held = await pushTokens(state);
|
||||||
|
console.log(`blocked ${address}; server now holds ${held} accept token(s)`);
|
||||||
},
|
},
|
||||||
|
|
||||||
async send() {
|
async send() {
|
||||||
const state = load();
|
const state = load();
|
||||||
const me = proto.identityFromSeed(unhex(state.seed));
|
const me = currentIdentity(state);
|
||||||
|
const master = unhex(state.master);
|
||||||
const addr = proto.parseAddress(rest[0]);
|
const addr = proto.parseAddress(rest[0]);
|
||||||
let recipient;
|
let recipient;
|
||||||
if (addr.identity) {
|
if (addr.identity) {
|
||||||
|
|
@ -204,11 +270,17 @@ const commands = {
|
||||||
if (state.account && !rest.includes("--anonymous"))
|
if (state.account && !rest.includes("--anonymous"))
|
||||||
fields.push(["Reply-To", proto.parseAddress(`${state.account.user}@${state.account.host}` +
|
fields.push(["Reply-To", proto.parseAddress(`${state.account.user}@${state.account.host}` +
|
||||||
(state.account.port === proto.DEFAULT_PORT ? "" : `:${state.account.port}`)).uri(me.publicKey)]);
|
(state.account.port === proto.DEFAULT_PORT ? "" : `:${state.account.port}`)).uri(me.publicKey)]);
|
||||||
|
// §5.8: hand an accepted correspondent the token for our own mailbox.
|
||||||
|
const accepted = state.accepted?.[addr.short];
|
||||||
|
if (accepted?.active) fields.push(["Accept", b32encode(proto.tokenFor(master, b32decode(accepted.identity)))]);
|
||||||
const body = utf8Bytes(proto.buildFrontmatter(fields, (parseOption("--body") || "") + "\n"));
|
const body = utf8Bytes(proto.buildFrontmatter(fields, (parseOption("--body") || "") + "\n"));
|
||||||
const envelope = proto.seal(me, recipient, body);
|
const envelope = proto.seal(me, recipient, body);
|
||||||
|
// §5.8: our token for their mailbox, if they have given us one.
|
||||||
|
const held = state.tokens?.[addr.short];
|
||||||
|
const mac = held ? proto.acceptMac(b32decode(held.token), proto.messageId(envelope)) : null;
|
||||||
const opened = await open(addr.host, addr.port, { requirePin: false });
|
const opened = await open(addr.host, addr.port, { requirePin: false });
|
||||||
try {
|
try {
|
||||||
await proto.sendOp(opened.session, envelope);
|
await proto.sendOp(opened.session, envelope, mac);
|
||||||
} finally {
|
} finally {
|
||||||
opened.session.stream.close();
|
opened.session.stream.close();
|
||||||
}
|
}
|
||||||
|
|
@ -217,29 +289,37 @@ const commands = {
|
||||||
envelope: hex(proto.seal(me, me.publicKey, body)), sentAt: proto.nowSeconds(),
|
envelope: hex(proto.seal(me, me.publicKey, body)), sentAt: proto.nowSeconds(),
|
||||||
}];
|
}];
|
||||||
save(state);
|
save(state);
|
||||||
console.log(`sent ${hex(proto.messageId(envelope)).slice(0, 8)} to ${addr.short}`);
|
console.log(`sent ${hex(proto.messageId(envelope)).slice(0, 16)} to ${addr.short}` +
|
||||||
|
(mac ? " (accepted)" : ""));
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetch() {
|
async fetch() {
|
||||||
const state = load();
|
const state = load();
|
||||||
const me = proto.identityFromSeed(unhex(state.seed));
|
const me = currentIdentity(state);
|
||||||
const account = proto.parseAddress(`${state.account.user}@${state.account.host}` +
|
const account = proto.parseAddress(`${state.account.user}@${state.account.host}` +
|
||||||
(state.account.port === proto.DEFAULT_PORT ? "" : `:${state.account.port}`));
|
(state.account.port === proto.DEFAULT_PORT ? "" : `:${state.account.port}`));
|
||||||
const ids = identities(state);
|
const ids = identities(state);
|
||||||
|
const keep = rest.includes("--keep");
|
||||||
|
if (rest.includes("--reset")) { state.afterTime = 0; state.afterId = hex(new Uint8Array(proto.ID_LEN)); }
|
||||||
|
let [afterTime, afterId] = keep ? cursor(state) : [0, new Uint8Array(proto.ID_LEN)];
|
||||||
const opened = await open(account.host, account.port, { requirePin: true });
|
const opened = await open(account.host, account.port, { requirePin: true });
|
||||||
let total = 0, stored = 0, rejected = 0;
|
let total = 0, stored = 0, rejected = 0;
|
||||||
try {
|
try {
|
||||||
await proto.authenticate(opened.session, opened.handshakeHash, account.user, me);
|
const { sync, tokens } = tokenSet(state);
|
||||||
|
await proto.authenticate(opened.session, opened.handshakeHash, account.user, me, { sync, tokens });
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const records = await proto.fetchOp(opened.session);
|
const records = await proto.fetchOp(opened.session, afterTime, afterId);
|
||||||
if (!records.length) break;
|
if (!records.length) break;
|
||||||
const acked = [];
|
const acked = [];
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
total++;
|
total++;
|
||||||
|
afterTime = record.receivedAt;
|
||||||
|
afterId = record.id;
|
||||||
|
let unsealed;
|
||||||
try {
|
try {
|
||||||
if (!timingSafeEqual(proto.messageId(record.envelope), record.id))
|
if (!timingSafeEqual(proto.messageId(record.envelope), record.id))
|
||||||
throw new proto.SmolError("id does not match the envelope");
|
throw new proto.SmolError("id does not match the envelope");
|
||||||
proto.unseal(ids, record.envelope);
|
unsealed = proto.unseal(ids, record.envelope);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
rejected++;
|
rejected++;
|
||||||
console.error(`warning: ${hex(record.id)}: ${err.message}; left on server`);
|
console.error(`warning: ${hex(record.id)}: ${err.message}; left on server`);
|
||||||
|
|
@ -249,25 +329,45 @@ const commands = {
|
||||||
if (!state.inbox.some(m => m.id === hex(record.id))) {
|
if (!state.inbox.some(m => m.id === hex(record.id))) {
|
||||||
state.inbox.push({
|
state.inbox.push({
|
||||||
id: hex(record.id), envelope: hex(record.envelope), receivedAt: record.receivedAt,
|
id: hex(record.id), envelope: hex(record.envelope), receivedAt: record.receivedAt,
|
||||||
|
isRequest: record.isRequest, keptOnServer: keep,
|
||||||
});
|
});
|
||||||
stored++;
|
stored++;
|
||||||
|
const { fields } = proto.parseFrontmatter(new TextDecoder().decode(unsealed.body));
|
||||||
|
const raw = fields.accept;
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
const token = b32decode(raw);
|
||||||
|
if (token.length === proto.TOKEN_LEN) {
|
||||||
|
const address = addressOfSigner(state, unsealed.sender, fields["reply-to"]);
|
||||||
|
if (address) state.tokens = { ...(state.tokens || {}), [address]: { token: b32encode(token) } };
|
||||||
|
}
|
||||||
|
} catch { /* malformed token: ignore */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
acked.push(record.id);
|
acked.push(record.id);
|
||||||
}
|
}
|
||||||
if (!acked.length) break;
|
if (keep) {
|
||||||
await proto.deleteOp(opened.session, acked);
|
state.afterTime = afterTime;
|
||||||
|
state.afterId = hex(afterId);
|
||||||
|
} else if (acked.length) {
|
||||||
|
await proto.deleteOp(opened.session, acked);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
opened.session.stream.close();
|
opened.session.stream.close();
|
||||||
}
|
}
|
||||||
|
if (!keep) { state.afterTime = 0; state.afterId = hex(new Uint8Array(proto.ID_LEN)); }
|
||||||
save(state);
|
save(state);
|
||||||
console.log(`${total} message(s): ${stored} new, ${rejected} rejected`);
|
console.log(`${total} message(s): ${stored} new, ${rejected} rejected`);
|
||||||
},
|
},
|
||||||
|
|
||||||
list() {
|
list() {
|
||||||
const state = load();
|
const state = load();
|
||||||
for (const m of state.inbox || []) describe(state, m, "inbox");
|
const wantRequests = rest.includes("--requests");
|
||||||
for (const m of state.sent || []) describe(state, m, "sent");
|
for (const m of state.inbox || []) {
|
||||||
|
if (Boolean(m.isRequest) === wantRequests) describe(state, m, "inbox");
|
||||||
|
}
|
||||||
|
if (rest.includes("--sent")) for (const m of state.sent || []) describe(state, m, "sent");
|
||||||
},
|
},
|
||||||
|
|
||||||
read() {
|
read() {
|
||||||
|
|
@ -281,19 +381,21 @@ const commands = {
|
||||||
|
|
||||||
async rotate() {
|
async rotate() {
|
||||||
const state = load();
|
const state = load();
|
||||||
const old = proto.identityFromSeed(unhex(state.seed));
|
const old = currentIdentity(state);
|
||||||
|
const master = unhex(state.master);
|
||||||
const account = proto.parseAddress(`${state.account.user}@${state.account.host}` +
|
const account = proto.parseAddress(`${state.account.user}@${state.account.host}` +
|
||||||
(state.account.port === proto.DEFAULT_PORT ? "" : `:${state.account.port}`));
|
(state.account.port === proto.DEFAULT_PORT ? "" : `:${state.account.port}`));
|
||||||
const fresh = proto.newIdentity();
|
const nextIndex = (state.rotations ?? 0) + 1;
|
||||||
const cert = proto.makeCert(old, fresh.seed);
|
const freshSeed = proto.identitySeed(master, nextIndex);
|
||||||
|
const fresh = proto.identityFromSeed(freshSeed);
|
||||||
|
const cert = proto.makeCert(account.user, old, freshSeed);
|
||||||
const opened = await open(account.host, account.port, { requirePin: true });
|
const opened = await open(account.host, account.port, { requirePin: true });
|
||||||
try {
|
try {
|
||||||
await proto.registerOp(opened.session, { username: account.user, identity: fresh, cert });
|
await proto.registerOp(opened.session, opened.serverStatic, { username: account.user, identity: fresh, cert });
|
||||||
} finally {
|
} finally {
|
||||||
opened.session.stream.close();
|
opened.session.stream.close();
|
||||||
}
|
}
|
||||||
state.retired = [...(state.retired || []), state.seed];
|
state.rotations = nextIndex;
|
||||||
state.seed = hex(fresh.seed);
|
|
||||||
save(state);
|
save(state);
|
||||||
console.log(`rotated ${account.short}`);
|
console.log(`rotated ${account.short}`);
|
||||||
console.log(`new key: ${b32encode(fresh.publicKey)}`);
|
console.log(`new key: ${b32encode(fresh.publicKey)}`);
|
||||||
|
|
@ -301,8 +403,25 @@ const commands = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function pushTokens(state) {
|
||||||
|
if (!state.account) {
|
||||||
|
console.error("warning: not registered; the set will be pushed with your first fetch");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const me = currentIdentity(state);
|
||||||
|
const account = proto.parseAddress(`${state.account.user}@${state.account.host}` +
|
||||||
|
(state.account.port === proto.DEFAULT_PORT ? "" : `:${state.account.port}`));
|
||||||
|
const opened = await open(account.host, account.port, { requirePin: true });
|
||||||
|
try {
|
||||||
|
const { sync, tokens } = tokenSet(state);
|
||||||
|
return await proto.authenticate(opened.session, opened.handshakeHash, account.user, me, { sync, tokens });
|
||||||
|
} finally {
|
||||||
|
opened.session.stream.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function describe(state, row, box, verbose = false) {
|
function describe(state, row, box, verbose = false) {
|
||||||
const id = box === "inbox" ? row.id : row.id;
|
const id = row.id;
|
||||||
try {
|
try {
|
||||||
const opened = proto.unseal(identities(state), unhex(row.envelope));
|
const opened = proto.unseal(identities(state), unhex(row.envelope));
|
||||||
const { fields, body } = proto.parseFrontmatter(new TextDecoder().decode(opened.body));
|
const { fields, body } = proto.parseFrontmatter(new TextDecoder().decode(opened.body));
|
||||||
|
|
@ -311,12 +430,12 @@ function describe(state, row, box, verbose = false) {
|
||||||
?? `<${b32encode(opened.sender).slice(0, 20)}…>`;
|
?? `<${b32encode(opened.sender).slice(0, 20)}…>`;
|
||||||
const stamp = new Date((opened.time) * 1000).toISOString().slice(0, 16).replace("T", " ");
|
const stamp = new Date((opened.time) * 1000).toISOString().slice(0, 16).replace("T", " ");
|
||||||
if (!verbose) return console.log(
|
if (!verbose) return console.log(
|
||||||
`${id.slice(0, 8)} ${stamp} ${who.padEnd(28).slice(0, 28)} ${fields.Subject || "(no subject)"}`);
|
`${id.slice(0, 8)} ${stamp} ${who.padEnd(28).slice(0, 28)} ${fields.subject || "(no subject)"}`);
|
||||||
console.log(`id: ${id}`);
|
console.log(`id: ${id}`);
|
||||||
console.log(`${box === "sent" ? "to" : "from"}: ${who}`);
|
console.log(`${box === "sent" ? "to" : "from"}: ${who}`);
|
||||||
console.log(`key: ${b32encode(opened.sender)}`);
|
console.log(`key: ${b32encode(opened.sender)}`);
|
||||||
console.log(`date: ${new Date(opened.time * 1000).toISOString()}`);
|
console.log(`date: ${new Date(opened.time * 1000).toISOString()}`);
|
||||||
for (const [k, v] of Object.entries(fields)) console.log(`${k.toLowerCase()}: ${v}`);
|
for (const [k, v] of Object.entries(fields)) if (k !== "accept") console.log(`${k}: ${v}`);
|
||||||
console.log("signature verified\n");
|
console.log("signature verified\n");
|
||||||
console.log(body);
|
console.log(body);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
38
test/e2e.py
38
test/e2e.py
|
|
@ -85,12 +85,18 @@ def main() -> int:
|
||||||
run(bob + ["register", f"bob@{host}"])
|
run(bob + ["register", f"bob@{host}"])
|
||||||
check("bob registers on the reference server", True)
|
check("bob registers on the reference server", True)
|
||||||
|
|
||||||
# --- reference client sends to the JS client
|
# bob needs alice as a known contact before he can accept her (§5.8).
|
||||||
|
run(bob + ["resolve", f"alice@{host}"])
|
||||||
|
|
||||||
|
# --- reference client sends to the JS client, unsolicited: bob has not
|
||||||
|
# accepted alice yet, so it lands in his requests tier, not the main one
|
||||||
run(alice + ["send", f"bob@{host}", "--subject", "greetings", "--body", "hello bob"])
|
run(alice + ["send", f"bob@{host}", "--subject", "greetings", "--body", "hello bob"])
|
||||||
print(run(bob + ["fetch"]).strip())
|
print(run(bob + ["fetch"]).strip())
|
||||||
bob_list = run(bob + ["list"])
|
bob_requests = run(bob + ["list", "--requests"])
|
||||||
check("bob lists alice's message", "greetings" in bob_list, bob_list)
|
check("bob's requests tier holds alice's unsolicited message", "greetings" in bob_requests,
|
||||||
bob_read = run(bob + ["read", re.match(r"([0-9a-f]{8})", bob_list).group(1)])
|
bob_requests)
|
||||||
|
check("bob's main tier is still empty", "greetings" not in run(bob + ["list"]))
|
||||||
|
bob_read = run(bob + ["read", re.match(r"([0-9a-f]{8})", bob_requests).group(1)])
|
||||||
alice_pub = re.search(r"public key: (\S+)", run(alice + ["whoami"])).group(1)
|
alice_pub = re.search(r"public key: (\S+)", run(alice + ["whoami"])).group(1)
|
||||||
check("bob reads the message", "hello bob" in bob_read, bob_read)
|
check("bob reads the message", "hello bob" in bob_read, bob_read)
|
||||||
check("bob sees alice's key", alice_pub in bob_read)
|
check("bob sees alice's key", alice_pub in bob_read)
|
||||||
|
|
@ -99,23 +105,34 @@ def main() -> int:
|
||||||
check("reference client emits the signed reply address",
|
check("reference client emits the signed reply address",
|
||||||
"reply-to: smol://alice@" in bob_read, bob_read)
|
"reply-to: smol://alice@" in bob_read, bob_read)
|
||||||
|
|
||||||
# --- JS client replies, through the bridge's WebSocket relay
|
# --- bob accepts alice (§5.8): pushed to the server right away, so her
|
||||||
|
# future mail reaches his main tier
|
||||||
|
accepted = run(bob + ["accept", f"alice@{host}"])
|
||||||
|
check("bob's server now holds an accept token", "server now holds 1 accept token" in accepted,
|
||||||
|
accepted)
|
||||||
|
|
||||||
|
# --- JS client replies, through the bridge's WebSocket relay. Bob has
|
||||||
|
# accepted alice, so this carries the token issued to her — but it is
|
||||||
|
# still unsolicited from alice's side, landing in her requests tier.
|
||||||
ws_env = dict(os.environ, GSMOL_TRANSPORT="ws", GSMOL_BRIDGE=f"ws://127.0.0.1:{bridge_port}")
|
ws_env = dict(os.environ, GSMOL_TRANSPORT="ws", GSMOL_BRIDGE=f"ws://127.0.0.1:{bridge_port}")
|
||||||
reply_id = re.search(r"sent ([0-9a-f]+)", run(bob + ["send", f"alice@{host}",
|
reply_id = re.search(r"sent ([0-9a-f]+)", run(bob + ["send", f"alice@{host}",
|
||||||
"--subject", "re: greetings", "--body", "hello alice"], env=ws_env)).group(1)
|
"--subject", "re: greetings", "--body", "hello alice"], env=ws_env)).group(1)
|
||||||
check("bob sends through the bridge", bool(reply_id))
|
check("bob sends through the bridge", bool(reply_id))
|
||||||
run(alice + ["fetch"])
|
run(alice + ["fetch"])
|
||||||
alice_list = run(alice + ["list"])
|
alice_requests = run(alice + ["list", "--requests"])
|
||||||
check("alice lists bob's reply", "re: greetings" in alice_list, alice_list)
|
check("alice's requests tier holds bob's reply", "re: greetings" in alice_requests,
|
||||||
|
alice_requests)
|
||||||
alice_read = run(alice + ["read", reply_id[:8]])
|
alice_read = run(alice + ["read", reply_id[:8]])
|
||||||
check("alice reads bob's reply", "hello alice" in alice_read, alice_read)
|
check("alice reads bob's reply", "hello alice" in alice_read, alice_read)
|
||||||
check("reference client verifies the JS signature", "signature verified" in alice_read)
|
check("reference client verifies the JS signature", "signature verified" in alice_read)
|
||||||
# bob's reply carries his signed smol:// address; a receiver that does
|
# bob's reply carries his signed smol:// address; a receiver that does
|
||||||
# not know the field must still preserve and display it (SPEC.md §5.5)
|
# not know the field must still preserve and display it (SPEC.md §5.5)
|
||||||
check("reference client preserves the signed reply address",
|
check("reference client preserves the signed reply address",
|
||||||
"reply-to:smol://bob@" in alice_read, alice_read)
|
bool(re.search(r"reply-to:\s+smol://bob@", alice_read)), alice_read)
|
||||||
|
|
||||||
# --- rotation: the JS client rotates, the reference client walks the chain
|
# --- having now learned bob's accept token from that reply's Accept
|
||||||
|
# field, alice's next message to him carries a matching MAC and
|
||||||
|
# reaches his main tier even after he rotates his key
|
||||||
bob_rotated = run(bob + ["rotate"])
|
bob_rotated = run(bob + ["rotate"])
|
||||||
new_key = re.search(r"new key: (\S+)", bob_rotated).group(1)
|
new_key = re.search(r"new key: (\S+)", bob_rotated).group(1)
|
||||||
resolved = run(alice + ["resolve", f"bob@{host}"])
|
resolved = run(alice + ["resolve", f"bob@{host}"])
|
||||||
|
|
@ -124,7 +141,8 @@ def main() -> int:
|
||||||
"--anonymous"])
|
"--anonymous"])
|
||||||
print(run(bob + ["fetch"]).strip())
|
print(run(bob + ["fetch"]).strip())
|
||||||
bob_list = run(bob + ["list"])
|
bob_list = run(bob + ["list"])
|
||||||
check("bob reads mail addressed to the new key", "post-rotation" in bob_list)
|
check("bob's main tier reaches mail addressed to the new key", "post-rotation" in bob_list,
|
||||||
|
bob_list)
|
||||||
# --anonymous omits the reply address; the field list must show none
|
# --anonymous omits the reply address; the field list must show none
|
||||||
post_read = run(bob + ["read", re.search(r"([0-9a-f]{8}) .*post-rotation",
|
post_read = run(bob + ["read", re.search(r"([0-9a-f]{8}) .*post-rotation",
|
||||||
bob_list).group(1)])
|
bob_list).group(1)])
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import nacl.bindings as sodium
|
||||||
from noise.connection import Keypair, NoiseConnection
|
from noise.connection import Keypair, NoiseConnection
|
||||||
|
|
||||||
LABEL_SEAL, LABEL_MSG, LABEL_ID = b"smolmail/1 seal", b"smolmail/1 msg", b"smolmail/1 id"
|
LABEL_SEAL, LABEL_MSG, LABEL_ID = b"smolmail/1 seal", b"smolmail/1 msg", b"smolmail/1 id"
|
||||||
|
LABEL_IDENTITY, LABEL_ACCEPT, LABEL_MAC = b"smolmail/1 identity", b"smolmail/1 accept", b"smolmail/1 mac"
|
||||||
TIME = 1730000000 # fixed so the envelope vector is reproducible
|
TIME = 1730000000 # fixed so the envelope vector is reproducible
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -139,9 +140,26 @@ def main() -> int:
|
||||||
"sender_seed": sender_seed.hex(), "recipient_seed": recipient_seed.hex(),
|
"sender_seed": sender_seed.hex(), "recipient_seed": recipient_seed.hex(),
|
||||||
"esk": esk.hex(), "body": body.hex(), "time": TIME,
|
"esk": esk.hex(), "body": body.hex(), "time": TIME,
|
||||||
"envelope": envelope.hex(),
|
"envelope": envelope.hex(),
|
||||||
"id": hashlib.sha256(LABEL_ID + envelope).digest()[:16].hex(),
|
"id": hashlib.sha256(LABEL_ID + envelope).digest().hex(),
|
||||||
"unpadded_plaintext_len": len(plaintext)}
|
"unpadded_plaintext_len": len(plaintext)}
|
||||||
|
|
||||||
|
# --- §2/§5.8: master-derived rotation seeds and accept tokens
|
||||||
|
master = seed("master")
|
||||||
|
correspondent = seed("correspondent identity")
|
||||||
|
accept_key = hkdf_sha256(master, b"", LABEL_ACCEPT, 32)
|
||||||
|
token = hmac.new(accept_key, correspondent, hashlib.sha256).digest()
|
||||||
|
mid = seed("token message id")
|
||||||
|
out["tokens"] = {
|
||||||
|
"master": master.hex(),
|
||||||
|
"identity_seed_0": hkdf_sha256(master, b"", LABEL_IDENTITY + struct.pack(">I", 0), 32).hex(),
|
||||||
|
"identity_seed_1": hkdf_sha256(master, b"", LABEL_IDENTITY + struct.pack(">I", 1), 32).hex(),
|
||||||
|
"accept_key": accept_key.hex(),
|
||||||
|
"correspondent": correspondent.hex(),
|
||||||
|
"token": token.hex(),
|
||||||
|
"message_id": mid.hex(),
|
||||||
|
"accept_mac": hmac.new(token, LABEL_MAC + mid, hashlib.sha256).digest().hex(),
|
||||||
|
}
|
||||||
|
|
||||||
# --- Noise NX transcript with every key fixed
|
# --- Noise NX transcript with every key fixed
|
||||||
i_e, r_e, s_priv = seed("nx initiator eph"), seed("nx responder eph"), seed("nx server static")
|
i_e, r_e, s_priv = seed("nx initiator eph"), seed("nx responder eph"), seed("nx server static")
|
||||||
s_pub = sodium.crypto_scalarmult_base(s_priv)
|
s_pub = sodium.crypto_scalarmult_base(s_priv)
|
||||||
|
|
|
||||||
|
|
@ -190,9 +190,19 @@
|
||||||
"body": "2d2d2d0a5375626a6563743a20766563746f720a2d2d2d0a68656c6c6f20626f620a",
|
"body": "2d2d2d0a5375626a6563743a20766563746f720a2d2d2d0a68656c6c6f20626f620a",
|
||||||
"time": 1730000000,
|
"time": 1730000000,
|
||||||
"envelope": "534d4f4c01e048814b56d9b82e54fd367d3c980661313cc6a3d81a80315561fc0ac87f81184e49921528d72321669ae1b275229800d8786c1ecdd7d9331bcb2cc64dc27c0399b106b5061e9105d8adf82f6b7464930fa31cb08ba85dba4764ce14cf3ddc32599f43fea73ced76ffa3a3b768e5a127034f5e82d667687369c19f060e8eafb09da0e212683290f4e1a73ce072b94f053c9088652109d3639f7b9aa0d48619626ecefe33855aade6ab8bf9de14ebb7cf07eec0ef9c193ae4537c370183e0c8f7cd7230471b9d8e7389ac654e1b09dc9b0160c875270fdad218f0c9da735bab",
|
"envelope": "534d4f4c01e048814b56d9b82e54fd367d3c980661313cc6a3d81a80315561fc0ac87f81184e49921528d72321669ae1b275229800d8786c1ecdd7d9331bcb2cc64dc27c0399b106b5061e9105d8adf82f6b7464930fa31cb08ba85dba4764ce14cf3ddc32599f43fea73ced76ffa3a3b768e5a127034f5e82d667687369c19f060e8eafb09da0e212683290f4e1a73ce072b94f053c9088652109d3639f7b9aa0d48619626ecefe33855aade6ab8bf9de14ebb7cf07eec0ef9c193ae4537c370183e0c8f7cd7230471b9d8e7389ac654e1b09dc9b0160c875270fdad218f0c9da735bab",
|
||||||
"id": "b05d15a2ab5293164eda564de02a6901",
|
"id": "b05d15a2ab5293164eda564de02a69019aa97895ff988f3d9fa2be5126516553",
|
||||||
"unpadded_plaintext_len": 143
|
"unpadded_plaintext_len": 143
|
||||||
},
|
},
|
||||||
|
"tokens": {
|
||||||
|
"master": "fc613b4dfd6736a7bd268c8a0e74ed0d1c04a959f59dd74ef2874983fd443fc9",
|
||||||
|
"identity_seed_0": "35c2f058e7568f68e99e4fba7a730bd2d3cf7e1f6a2a266a2c5f5ca150d0074c",
|
||||||
|
"identity_seed_1": "fef4bd7f73b6459031a79dd589011a120c2cbc19b693b918f4aa9a8afe9dba7b",
|
||||||
|
"accept_key": "4fbaf4883885875100dc157864977e9a533866b15adc9daed35eedcc5f62fb20",
|
||||||
|
"correspondent": "fbaa80b2e87dc300cd33a2497dfe672c47ca8c8af099db8d45644054dff85297",
|
||||||
|
"token": "fe3085aeb2952b2bdd5abdcf8908ffd00054e871f47dfed2aa541cdb0e2006d6",
|
||||||
|
"message_id": "7c65ce5d23376fb508b4dda83a271095fd41e0ce47604213153520b0a0db50ac",
|
||||||
|
"accept_mac": "1d56673df4952dedcc1e40dfce2ad7820864090809a381e225e0f2fafa7cbfc7"
|
||||||
|
},
|
||||||
"noise": {
|
"noise": {
|
||||||
"initiator_eph_priv": "af5f15993914cfd4e308856810d483ab25a383bfb2d708a0e15f80275f1fe6c2",
|
"initiator_eph_priv": "af5f15993914cfd4e308856810d483ab25a383bfb2d708a0e15f80275f1fe6c2",
|
||||||
"responder_eph_priv": "1c21927bb3e579efb69c937a2bf2e299ca1da9c83a62fb7f8ea1a375eb6f1c80",
|
"responder_eph_priv": "1c21927bb3e579efb69c937a2bf2e299ca1da9c83a62fb7f8ea1a375eb6f1c80",
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,18 @@ for (const v of vectors.ed_to_x25519) {
|
||||||
eq(`x_pub[${v.name}]`, hex(crypto.ed25519ToX25519(crypto.ed25519PublicKey(unhex(v.seed)))), v.x_pub);
|
eq(`x_pub[${v.name}]`, hex(crypto.ed25519ToX25519(crypto.ed25519PublicKey(unhex(v.seed)))), v.x_pub);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- §2/§5.8: master-derived rotation seeds and accept tokens -----------------
|
||||||
|
|
||||||
|
{
|
||||||
|
const t = vectors.tokens;
|
||||||
|
const master = unhex(t.master);
|
||||||
|
eq("identity-seed-0", hex(proto.identitySeed(master, 0)), t.identity_seed_0);
|
||||||
|
eq("identity-seed-1", hex(proto.identitySeed(master, 1)), t.identity_seed_1);
|
||||||
|
eq("accept-key", hex(proto.acceptKeyFor(master)), t.accept_key);
|
||||||
|
eq("token-for", hex(proto.tokenFor(master, unhex(t.correspondent))), t.token);
|
||||||
|
eq("accept-mac", hex(proto.acceptMac(unhex(t.token), unhex(t.message_id))), t.accept_mac);
|
||||||
|
}
|
||||||
|
|
||||||
// --- §5 envelope --------------------------------------------------------------
|
// --- §5 envelope --------------------------------------------------------------
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
@ -125,19 +137,22 @@ for (const v of vectors.ed_to_x25519) {
|
||||||
// --- frontmatter (§5.5) -------------------------------------------------------
|
// --- frontmatter (§5.5) -------------------------------------------------------
|
||||||
|
|
||||||
{
|
{
|
||||||
const spec = "---\nSubject: Re: the thing\nIn-Reply-To: 4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d\nX-Mood: cautiously optimistic\n---\nBody text starts here.";
|
const rid = "4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d5c4b3a291807f6e5d4c3b2a1908f7e6d5"; // §5.4: 64 hex chars
|
||||||
|
const spec = `---\nSubject: Re: the thing\nIn-Reply-To: ${rid}\nX-Mood: cautiously optimistic\n---\nBody text starts here.`;
|
||||||
const { fields, body } = proto.parseFrontmatter(spec);
|
const { fields, body } = proto.parseFrontmatter(spec);
|
||||||
eq("fm-subject", fields.Subject, "Re: the thing");
|
// §5.5: keys are compared case-insensitively, so they come back lowercased.
|
||||||
eq("fm-reply", fields["In-Reply-To"], "4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d");
|
eq("fm-subject", fields.subject, "Re: the thing");
|
||||||
|
eq("fm-reply", fields["in-reply-to"], rid);
|
||||||
|
eq("fm-case-insensitive", proto.parseFrontmatter("---\nSUBJECT: hi\n---\nx").fields.subject, "hi");
|
||||||
eq("fm-body", body, "Body text starts here.");
|
eq("fm-body", body, "Body text starts here.");
|
||||||
// a malformed line invalidates the whole block, which fails closed toward display
|
// a malformed line invalidates the whole block, which fails closed toward display
|
||||||
eq("fm-malformed", proto.parseFrontmatter("---\nno colon here\n---\nrest").body, "---\nno colon here\n---\nrest");
|
eq("fm-malformed", proto.parseFrontmatter("---\nno colon here\n---\nrest").body, "---\nno colon here\n---\nrest");
|
||||||
eq("fm-unterminated", proto.parseFrontmatter("---\nSubject: x\nno end").body, "---\nSubject: x\nno end");
|
eq("fm-unterminated", proto.parseFrontmatter("---\nSubject: x\nno end").body, "---\nSubject: x\nno end");
|
||||||
eq("fm-first-wins", proto.parseFrontmatter("---\nA: 1\nA: 2\n---\ntext").fields.A, "1");
|
eq("fm-first-wins", proto.parseFrontmatter("---\nA: 1\nA: 2\n---\ntext").fields.a, "1");
|
||||||
eq("fm-escape", proto.buildFrontmatter([], "---\nactual body"), "---\n---\n---\nactual body");
|
eq("fm-escape", proto.buildFrontmatter([], "---\nactual body"), "---\n---\n---\nactual body");
|
||||||
eq("fm-build", proto.buildFrontmatter([["Subject", "hi"]], "there"), "---\nSubject: hi\n---\nthere");
|
eq("fm-build", proto.buildFrontmatter([["Subject", "hi"]], "there"), "---\nSubject: hi\n---\nthere");
|
||||||
eq("fm-no-block", proto.buildFrontmatter([], "plain"), "plain");
|
eq("fm-no-block", proto.buildFrontmatter([], "plain"), "plain");
|
||||||
eq("fm-too-many-keys", proto.parseFrontmatter("---\n" + "X: y\n".repeat(65) + "---\nbody").fields.Subject ?? "none", "none");
|
eq("fm-too-many-keys", proto.parseFrontmatter("---\n" + "X: y\n".repeat(65) + "---\nbody").fields.subject ?? "none", "none");
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- addresses (§3) -----------------------------------------------------------
|
// --- addresses (§3) -----------------------------------------------------------
|
||||||
|
|
@ -167,21 +182,23 @@ for (const v of vectors.ed_to_x25519) {
|
||||||
// --- rotation chains (§7) ------------------------------------------------------
|
// --- rotation chains (§7) ------------------------------------------------------
|
||||||
|
|
||||||
{
|
{
|
||||||
|
const user = "alice";
|
||||||
const old = proto.identityFromSeed(crypto.randomBytes(32));
|
const old = proto.identityFromSeed(crypto.randomBytes(32));
|
||||||
const mid = crypto.randomBytes(32);
|
const mid = crypto.randomBytes(32);
|
||||||
const midPub = crypto.ed25519PublicKey(mid);
|
|
||||||
const fresh = crypto.randomBytes(32);
|
const fresh = crypto.randomBytes(32);
|
||||||
const freshPub = crypto.ed25519PublicKey(fresh);
|
const freshPub = crypto.ed25519PublicKey(fresh);
|
||||||
const when = proto.nowSeconds();
|
const when = proto.nowSeconds();
|
||||||
const chain = [proto.makeCert(old, mid, when), proto.makeCert(proto.identityFromSeed(mid), fresh, when)];
|
const chain = [proto.makeCert(user, old, mid, when),
|
||||||
ok("chain-valid", proto.walkChain(old.publicKey, freshPub, chain));
|
proto.makeCert(user, proto.identityFromSeed(mid), fresh, when)];
|
||||||
ok("chain-unchanged", proto.walkChain(old.publicKey, old.publicKey, []));
|
ok("chain-valid", proto.walkChain(user, old.publicKey, freshPub, chain));
|
||||||
ok("chain-missing-link", !proto.walkChain(old.publicKey, freshPub, chain.slice(1)));
|
ok("chain-unchanged", proto.walkChain(user, old.publicKey, old.publicKey, []));
|
||||||
|
ok("chain-missing-link", !proto.walkChain(user, old.publicKey, freshPub, chain.slice(1)));
|
||||||
|
ok("chain-wrong-username", !proto.walkChain("bob", old.publicKey, freshPub, chain));
|
||||||
const forged = chain.slice();
|
const forged = chain.slice();
|
||||||
forged[1] = proto.makeCert(proto.identityFromSeed(mid), crypto.randomBytes(32), when);
|
forged[1] = proto.makeCert(user, proto.identityFromSeed(mid), crypto.randomBytes(32), when);
|
||||||
ok("chain-broken", !proto.walkChain(old.publicKey, freshPub, forged));
|
ok("chain-broken", !proto.walkChain(user, old.publicKey, freshPub, forged));
|
||||||
ok("chain-oversize", !proto.walkChain(old.publicKey, freshPub, Array(17).fill(chain[0])));
|
ok("chain-oversize", !proto.walkChain(user, old.publicKey, freshPub, Array(17).fill(chain[0])));
|
||||||
eq("cert-len", String(chain[0].length), "136");
|
eq("cert-len", String(chain[0].length), "200");
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- response framing guards (§6.1) -------------------------------------------
|
// --- response framing guards (§6.1) -------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -36,13 +36,13 @@
|
||||||
|
|
||||||
<main id="setup" class="setup" hidden>
|
<main id="setup" class="setup" hidden>
|
||||||
<h2>welcome</h2>
|
<h2>welcome</h2>
|
||||||
<p>Identity is one 32-byte seed. Back it up: it is the only secret, and losing it means losing the address.</p>
|
<p>Identity is one 32-byte master secret. Back it up: it is the only secret, and losing it means losing the address.</p>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button id="create-identity">create a new identity</button>
|
<button id="create-identity">create a new identity</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">or restore from a saved seed:</p>
|
<p class="hint">or restore from a saved master:</p>
|
||||||
<div class="row restore-row">
|
<div class="row restore-row">
|
||||||
<label>seed<input id="restore-seed" placeholder="64 hex characters" spellcheck="false"></label>
|
<label>master<input id="restore-master" placeholder="64 hex characters" spellcheck="false"></label>
|
||||||
<label>address (optional)<input id="restore-address" placeholder="alice@example.org, if already registered"
|
<label>address (optional)<input id="restore-address" placeholder="alice@example.org, if already registered"
|
||||||
spellcheck="false"></label>
|
spellcheck="false"></label>
|
||||||
<label id="restore-key-label">server's public key
|
<label id="restore-key-label">server's public key
|
||||||
|
|
@ -59,6 +59,10 @@
|
||||||
<svg class="icon"><use href="#i-inbox"/></svg><span class="label">inbox</span>
|
<svg class="icon"><use href="#i-inbox"/></svg><span class="label">inbox</span>
|
||||||
<span id="count-inbox" class="count"></span>
|
<span id="count-inbox" class="count"></span>
|
||||||
</button>
|
</button>
|
||||||
|
<button data-folder="requests" aria-label="requests">
|
||||||
|
<svg class="icon"><use href="#i-inbox"/></svg><span class="label">requests</span>
|
||||||
|
<span id="count-requests" class="count"></span>
|
||||||
|
</button>
|
||||||
<button data-folder="sent" aria-label="sent"><svg class="icon"><use href="#i-sent"/></svg><span class="label">sent</span></button>
|
<button data-folder="sent" aria-label="sent"><svg class="icon"><use href="#i-sent"/></svg><span class="label">sent</span></button>
|
||||||
<button data-folder="contacts" aria-label="contacts"><svg class="icon"><use href="#i-contacts"/></svg><span class="label">contacts</span></button>
|
<button data-folder="contacts" aria-label="contacts"><svg class="icon"><use href="#i-contacts"/></svg><span class="label">contacts</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -100,7 +104,7 @@
|
||||||
<section>
|
<section>
|
||||||
<h3><span id="step-server" class="step" data-n="1">1</span> your server</h3>
|
<h3><span id="step-server" class="step" data-n="1">1</span> your server</h3>
|
||||||
<p class="hint">Pin the key of the server that will hold your mail. Get it from its operator over a
|
<p class="hint">Pin the key of the server that will hold your mail. Get it from its operator over a
|
||||||
channel you already trust — registering, recalling and fetching all refuse an unpinned server (SPEC.md §4).</p>
|
channel you already trust — registering, recalling and fetching all refuse an unpinned server.</p>
|
||||||
<form id="pin-form">
|
<form id="pin-form">
|
||||||
<label>host<input name="host" placeholder="example.org" spellcheck="false" required></label>
|
<label>host<input name="host" placeholder="example.org" spellcheck="false" required></label>
|
||||||
<label>public key<input name="key" placeholder="base32" spellcheck="false" required></label>
|
<label>public key<input name="key" placeholder="base32" spellcheck="false" required></label>
|
||||||
|
|
@ -119,7 +123,7 @@
|
||||||
</form>
|
</form>
|
||||||
<details>
|
<details>
|
||||||
<summary>already registered elsewhere?</summary>
|
<summary>already registered elsewhere?</summary>
|
||||||
<p class="hint">Restoring a seed brings back the identity, not the memory of what address it was
|
<p class="hint">Restoring a master brings back the identity, not the memory of what address it was
|
||||||
registered under in a different browser — the server was never asked. Recall it here: this
|
registered under in a different browser — the server was never asked. Recall it here: this
|
||||||
looks the address up and checks it resolves to this identity's key, without registering anything.</p>
|
looks the address up and checks it resolves to this identity's key, without registering anything.</p>
|
||||||
<form id="recall-form">
|
<form id="recall-form">
|
||||||
|
|
@ -134,11 +138,23 @@
|
||||||
<div id="identity-card"></div>
|
<div id="identity-card"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>fetch</h3>
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" id="leave-on-server">
|
||||||
|
<span>
|
||||||
|
<span class="check-title">leave mail on server</span>
|
||||||
|
<span class="hint">Fetch keeps a copy on the server instead of deleting it; delete a message
|
||||||
|
here to remove it from both.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3>backup</h3>
|
<h3>backup</h3>
|
||||||
<p class="hint">Mail, once fetched, exists only here — the server deletes it as it's picked up
|
<p class="hint">Mail, once fetched, exists only here unless "leave mail on server" is on above.
|
||||||
(SPEC.md §5). This backs up the local inbox, sent mail, contacts and pinned servers into one
|
This backs up the local inbox, requests, sent mail, contacts and pinned servers into one file.
|
||||||
file. It never includes the seed: that has its own reveal-and-copy flow above, meant for a
|
It never includes the master: that has its own reveal-and-copy flow above, meant for a
|
||||||
password manager, not a downloadable file.</p>
|
password manager, not a downloadable file.</p>
|
||||||
<div class="backup-row">
|
<div class="backup-row">
|
||||||
<button type="button" id="export-data">export</button>
|
<button type="button" id="export-data">export</button>
|
||||||
|
|
|
||||||
393
web/js/app.js
393
web/js/app.js
|
|
@ -1,11 +1,12 @@
|
||||||
// UI wiring for the smol mail client: identity setup, folders, reading,
|
// UI wiring for the smol mail client: identity setup, folders, reading,
|
||||||
// composing, fetching with verify-before-ack, and the §4/§7 trust surfaces.
|
// composing, fetching with verify-before-ack, and the §4/§7 trust surfaces.
|
||||||
|
|
||||||
import { hex, timingSafeEqual, utf8Bytes } from "./crypto.js";
|
import { hex, timingSafeEqual, unhex, utf8Bytes } from "./crypto.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_PORT, SmolError, authenticate, b32decode, b32encode, buildFrontmatter, deleteOp,
|
DEFAULT_PORT, ID_LEN, MAX_CHAIN, TOKEN_LEN, SmolError, acceptMac, authenticate, b32decode,
|
||||||
fetchOp, fingerprint, makeCert, messageId, newIdentity, openSession, parseAddress,
|
b32encode, buildFrontmatter, deleteOp, fetchOp, fingerprint, identitySeed, identityFromSeed,
|
||||||
parseFrontmatter, registerOp, resolveOp, seal, sendOp, unseal, walkChain,
|
makeCert, messageId, newMaster, openSession, parseAddress, parseFrontmatter, registerOp,
|
||||||
|
resolveOp, seal, sendOp, tokenFor, unseal, walkChain,
|
||||||
} from "./proto.js";
|
} from "./proto.js";
|
||||||
import * as store from "./store.js";
|
import * as store from "./store.js";
|
||||||
import { connectStream } from "./transport.js";
|
import { connectStream } from "./transport.js";
|
||||||
|
|
@ -37,11 +38,25 @@ function icon(name) {
|
||||||
const placeholder = (text, warn = false) =>
|
const placeholder = (text, warn = false) =>
|
||||||
el("p", { className: warn ? "placeholder warn" : "placeholder", textContent: text });
|
el("p", { className: warn ? "placeholder warn" : "placeholder", textContent: text });
|
||||||
|
|
||||||
|
// A native <dialog> shown with showModal() renders in the browser's top
|
||||||
|
// layer, above the rest of the page — a toast anchored to <body> sits
|
||||||
|
// visually behind an open dialog's backdrop no matter its z-index. Keeping
|
||||||
|
// the toast inside whichever dialog is open puts it in that same top-layer
|
||||||
|
// subtree, so a status raised while settings or compose is open (e.g. after
|
||||||
|
// "register") actually gets seen instead of landing, barely visible, behind it.
|
||||||
|
function reparentStatus() {
|
||||||
|
const toast = $("status");
|
||||||
|
const target = document.querySelector("dialog[open]") ?? document.body;
|
||||||
|
if (toast.parentElement !== target) target.append(toast);
|
||||||
|
}
|
||||||
|
for (const dialog of document.querySelectorAll("dialog")) dialog.addEventListener("close", reparentStatus);
|
||||||
|
|
||||||
// The status line is a toast: click to dismiss, empty means hidden. Plain
|
// The status line is a toast: click to dismiss, empty means hidden. Plain
|
||||||
// notices fade; warnings stay until dismissed, since those carry the §4/§8
|
// notices fade; warnings stay until dismissed, since those carry the §4/§8
|
||||||
// trust messages the user must actually read.
|
// trust messages the user must actually read.
|
||||||
let toastTimer = null;
|
let toastTimer = null;
|
||||||
function status(text, warn = false) {
|
function status(text, warn = false) {
|
||||||
|
reparentStatus();
|
||||||
const toast = $("status");
|
const toast = $("status");
|
||||||
toast.textContent = text;
|
toast.textContent = text;
|
||||||
toast.hidden = !text;
|
toast.hidden = !text;
|
||||||
|
|
@ -125,7 +140,7 @@ async function connect(addr, { requirePin }) {
|
||||||
const opened = await openSession(stream, addr.host, pinned);
|
const opened = await openSession(stream, addr.host, pinned);
|
||||||
if (!pinned)
|
if (!pinned)
|
||||||
status(`${addr.host} is not pinned; its key is ${b32encode(opened.serverStatic)}.\n` +
|
status(`${addr.host} is not pinned; its key is ${b32encode(opened.serverStatic)}.\n` +
|
||||||
`RESOLVE results from this session are UNVERIFIED (SPEC.md §8).`, true);
|
`Lookups from this session are UNVERIFIED.`, true);
|
||||||
return opened;
|
return opened;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,18 +166,22 @@ function showApp() {
|
||||||
}
|
}
|
||||||
|
|
||||||
$("create-identity").onclick = () => {
|
$("create-identity").onclick = () => {
|
||||||
store.setIdentity(newIdentity().seed);
|
store.setIdentity(newMaster());
|
||||||
showApp();
|
showApp();
|
||||||
status("identity created. Back up the seed in settings now; it is the only secret.");
|
status("identity created. Back up the master in settings now; it is the only secret.");
|
||||||
openSettings();
|
openSettings();
|
||||||
};
|
};
|
||||||
|
|
||||||
$("restore-identity").onclick = async () => {
|
$("restore-identity").onclick = async () => {
|
||||||
const text = $("restore-seed").value.trim();
|
const text = $("restore-master").value.trim();
|
||||||
if (!/^[0-9a-fA-F]{64}$/.test(text)) return status("a seed is 64 hex characters", true);
|
if (!/^[0-9a-fA-F]{64}$/.test(text)) return status("a master is 64 hex characters", true);
|
||||||
const seedBytes = Uint8Array.from(text.match(/../g), b => parseInt(b, 16));
|
const masterBytes = Uint8Array.from(text.match(/../g), b => parseInt(b, 16));
|
||||||
try {
|
try {
|
||||||
store.setIdentity(seedBytes);
|
// §2: a master alone does not say which rotation index a server has
|
||||||
|
// bound; assume 0 (a never-rotated identity, the common case) until an
|
||||||
|
// address lets us confirm it below. Either way the accepted-correspondent
|
||||||
|
// set is unknown, so it must not overwrite the server's (syncOk: false).
|
||||||
|
store.restoreMaster(masterBytes, 0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return status(err.message, true);
|
return status(err.message, true);
|
||||||
}
|
}
|
||||||
|
|
@ -198,7 +217,7 @@ $("restore-identity").onclick = async () => {
|
||||||
|
|
||||||
if (addressText) {
|
if (addressText) {
|
||||||
try {
|
try {
|
||||||
const addr = await recallAccount(addressText);
|
const addr = await recallAndDiscoverRotation(masterBytes, addressText);
|
||||||
lines.push(`recalled ${addr.short}.`);
|
lines.push(`recalled ${addr.short}.`);
|
||||||
renderAccount();
|
renderAccount();
|
||||||
renderSettings();
|
renderSettings();
|
||||||
|
|
@ -260,6 +279,7 @@ function resetView() {
|
||||||
// leaving the inbox used to blank the badge.
|
// leaving the inbox used to blank the badge.
|
||||||
async function updateUnreadBadge() {
|
async function updateUnreadBadge() {
|
||||||
$("count-inbox").textContent = (await store.unreadCount()) || "";
|
$("count-inbox").textContent = (await store.unreadCount()) || "";
|
||||||
|
$("count-requests").textContent = (await store.requestsUnreadCount()) || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderFolder() {
|
async function renderFolder() {
|
||||||
|
|
@ -278,7 +298,8 @@ async function renderFolder() {
|
||||||
if (token !== renderToken) return;
|
if (token !== renderToken) return;
|
||||||
list.replaceChildren();
|
list.replaceChildren();
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
list.append(placeholder(folder === "inbox" ? "no mail — fetch" : "nothing sent"));
|
list.append(placeholder(folder === "inbox" ? "no mail — fetch"
|
||||||
|
: folder === "requests" ? "no unsolicited mail" : "nothing sent"));
|
||||||
return void updateListStatus();
|
return void updateListStatus();
|
||||||
}
|
}
|
||||||
for (let offset = 0; offset < rows.length; offset += BATCH) {
|
for (let offset = 0; offset < rows.length; offset += BATCH) {
|
||||||
|
|
@ -292,8 +313,9 @@ async function renderFolder() {
|
||||||
// A real button, so every row is reachable and openable from the keyboard.
|
// A real button, so every row is reachable and openable from the keyboard.
|
||||||
function messageRow(row) {
|
function messageRow(row) {
|
||||||
const opened = describe(row);
|
const opened = describe(row);
|
||||||
// Only the inbox has a read state; sent copies were never "unread".
|
// Both mail tiers have a read state; sent copies were never "unread".
|
||||||
const unread = folder === "inbox" && !store.isRead(row.id);
|
const inbound = folder === "inbox" || folder === "requests";
|
||||||
|
const unread = inbound && !store.isRead(row.id);
|
||||||
const item = el("button", {
|
const item = el("button", {
|
||||||
type: "button",
|
type: "button",
|
||||||
className: `item${row.id === selectedId ? " selected" : ""}${unread ? " unread" : ""}`,
|
className: `item${row.id === selectedId ? " selected" : ""}${unread ? " unread" : ""}`,
|
||||||
|
|
@ -302,7 +324,7 @@ function messageRow(row) {
|
||||||
if (row.id === selectedId) item.setAttribute("aria-current", "true");
|
if (row.id === selectedId) item.setAttribute("aria-current", "true");
|
||||||
const who = el("div", { className: "who",
|
const who = el("div", { className: "who",
|
||||||
textContent: folder === "sent" ? row.recipient : (opened.fromShort ?? "?") });
|
textContent: folder === "sent" ? row.recipient : (opened.fromShort ?? "?") });
|
||||||
if (folder === "inbox" && !opened.error && !opened.knownSender)
|
if (inbound && !opened.error && !opened.knownSender)
|
||||||
who.append(el("span", { className: "dot", title: "sender key not bound to an address" }));
|
who.append(el("span", { className: "dot", title: "sender key not bound to an address" }));
|
||||||
item.append(who,
|
item.append(who,
|
||||||
el("div", { className: "mid" },
|
el("div", { className: "mid" },
|
||||||
|
|
@ -356,7 +378,7 @@ function openEnvelope(row) {
|
||||||
const opened = unseal(store.identities(), row.envelope);
|
const opened = unseal(store.identities(), row.envelope);
|
||||||
const { fields, body } = parseFrontmatter(new TextDecoder().decode(opened.body));
|
const { fields, body } = parseFrontmatter(new TextDecoder().decode(opened.body));
|
||||||
entry = { ...opened, fields, body,
|
entry = { ...opened, fields, body,
|
||||||
subject: fields.Subject || "",
|
subject: fields.subject || "",
|
||||||
snippet: body.replace(/\s+/g, " ").trim().slice(0, 140) };
|
snippet: body.replace(/\s+/g, " ").trim().slice(0, 140) };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
entry = { error: err.message };
|
entry = { error: err.message };
|
||||||
|
|
@ -376,9 +398,9 @@ function describe(row) {
|
||||||
if (opened.error) return opened;
|
if (opened.error) return opened;
|
||||||
const known = store.addressForKey(opened.sender);
|
const known = store.addressForKey(opened.sender);
|
||||||
let replyAddress = null;
|
let replyAddress = null;
|
||||||
if (opened.fields["Reply-To"]) {
|
if (opened.fields["reply-to"]) {
|
||||||
try {
|
try {
|
||||||
const claimed = parseAddress(opened.fields["Reply-To"]);
|
const claimed = parseAddress(opened.fields["reply-to"]);
|
||||||
// Only a full smol:// address whose key matches the signer counts;
|
// Only a full smol:// address whose key matches the signer counts;
|
||||||
// anything else is ordinary text.
|
// anything else is ordinary text.
|
||||||
if (claimed.identity && timingSafeEqual(claimed.identity, opened.sender))
|
if (claimed.identity && timingSafeEqual(claimed.identity, opened.sender))
|
||||||
|
|
@ -419,7 +441,8 @@ async function openMessage(id) {
|
||||||
onclick: () => { $("app").classList.remove("reading"); } }, icon("back"), "messages");
|
onclick: () => { $("app").classList.remove("reading"); } }, icon("back"), "messages");
|
||||||
if (opened.error) return void view.replaceChildren(back, placeholder(opened.error, true));
|
if (opened.error) return void view.replaceChildren(back, placeholder(opened.error, true));
|
||||||
|
|
||||||
const fields = Object.entries(opened.fields).filter(([k]) => k !== "Subject");
|
// "accept" and "in-reply-to" are protocol machinery (§5.5, §5.8), not content.
|
||||||
|
const fields = Object.entries(opened.fields).filter(([k]) => k !== "subject" && k !== "accept");
|
||||||
const actions = el("div", { className: "actions" });
|
const actions = el("div", { className: "actions" });
|
||||||
view.replaceChildren(...[
|
view.replaceChildren(...[
|
||||||
back,
|
back,
|
||||||
|
|
@ -433,7 +456,32 @@ async function openMessage(id) {
|
||||||
el("pre", { textContent: opened.body }),
|
el("pre", { textContent: opened.body }),
|
||||||
actions,
|
actions,
|
||||||
].filter(node => node != null));
|
].filter(node => node != null));
|
||||||
if (folder !== "inbox") return;
|
|
||||||
|
// Deleting removes the local copy, and the server's too if "leave mail on
|
||||||
|
// server" left one there to remove (settings; SPEC.md §10).
|
||||||
|
actions.append(el("button", { type: "button", onclick: async () => {
|
||||||
|
try {
|
||||||
|
await deleteMessage(folder, row);
|
||||||
|
status("message deleted");
|
||||||
|
selectedId = null;
|
||||||
|
resetView();
|
||||||
|
renderFolder();
|
||||||
|
} catch (err) {
|
||||||
|
status(err.message, true); // the local copy is kept if a needed server delete failed
|
||||||
|
}
|
||||||
|
} }, "delete"));
|
||||||
|
|
||||||
|
if (folder !== "inbox" && folder !== "requests") return;
|
||||||
|
|
||||||
|
if (folder === "requests" && opened.knownSender) {
|
||||||
|
const known = store.addressForKey(opened.sender);
|
||||||
|
if (!store.accepted(known)?.active) {
|
||||||
|
actions.before(placeholder(
|
||||||
|
`unsolicited mail from ${known}, not yet accepted into your main tier`));
|
||||||
|
actions.append(el("button", { type: "button", onclick: () => acceptContact(known) },
|
||||||
|
"accept — future mail lands in your main tier"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (opened.knownSender) {
|
if (opened.knownSender) {
|
||||||
actions.append(el("button", { type: "button", onclick: () => startReply(row) },
|
actions.append(el("button", { type: "button", onclick: () => startReply(row) },
|
||||||
|
|
@ -479,6 +527,28 @@ async function openMessage(id) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deletes a message locally, and from the server too if it might still be
|
||||||
|
// sitting there (only possible when "leave mail on server" was on when it was
|
||||||
|
// fetched — SPEC.md §10). Sent copies are local-only; there is nothing
|
||||||
|
// server-side to remove for them (§5.6). Throws, leaving the local copy in
|
||||||
|
// place, if a needed server-side delete fails — otherwise a message could
|
||||||
|
// look gone locally while silently persisting on the server.
|
||||||
|
async function deleteMessage(folder, row) {
|
||||||
|
if (folder !== "sent" && row.keptOnServer) {
|
||||||
|
const me = store.identity();
|
||||||
|
const addr = accountAddress();
|
||||||
|
if (!me || !addr) throw new SmolError("not registered; cannot reach the server to delete this message");
|
||||||
|
const opened = await connect(addr, { requirePin: true });
|
||||||
|
try {
|
||||||
|
await authenticate(opened.session, opened.handshakeHash, addr.user, me);
|
||||||
|
await deleteOp(opened.session, [unhex(row.id)]);
|
||||||
|
} finally {
|
||||||
|
opened.session.stream.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await store.removeMessage(folder, row.id);
|
||||||
|
}
|
||||||
|
|
||||||
// §4/§8: a signature always verifies here, because unseal() throws otherwise.
|
// §4/§8: a signature always verifies here, because unseal() throws otherwise.
|
||||||
// What can be weak is the binding between that key and an address, so that is
|
// What can be weak is the binding between that key and an address, so that is
|
||||||
// what the chip reports.
|
// what the chip reports.
|
||||||
|
|
@ -585,7 +655,8 @@ compose.querySelector("form").onsubmit = async (event) => {
|
||||||
|
|
||||||
async function sendMail(toText, subject, body, replyTo, anonymous) {
|
async function sendMail(toText, subject, body, replyTo, anonymous) {
|
||||||
const me = store.identity();
|
const me = store.identity();
|
||||||
if (!me) throw new SmolError("no identity yet");
|
const master = store.master();
|
||||||
|
if (!me || !master) throw new SmolError("no identity yet");
|
||||||
const addr = parseAddress(toText);
|
const addr = parseAddress(toText);
|
||||||
const recipient = await resolveRecipient(addr);
|
const recipient = await resolveRecipient(addr);
|
||||||
const account = accountAddress();
|
const account = accountAddress();
|
||||||
|
|
@ -593,12 +664,19 @@ async function sendMail(toText, subject, body, replyTo, anonymous) {
|
||||||
// A signed Reply-To lets a first-time recipient name and answer us (§5.5
|
// A signed Reply-To lets a first-time recipient name and answer us (§5.5
|
||||||
// allows unknown keys); "anonymous" omits it.
|
// allows unknown keys); "anonymous" omits it.
|
||||||
if (account && !anonymous) fields.push(["Reply-To", account.uri(me.publicKey)]);
|
if (account && !anonymous) fields.push(["Reply-To", account.uri(me.publicKey)]);
|
||||||
|
// §5.8: hand an accepted correspondent the token for our own mailbox, so a
|
||||||
|
// first reply from them reaches our main tier.
|
||||||
|
const accepted = store.accepted(addr.short);
|
||||||
|
if (accepted?.active) fields.push(["Accept", b32encode(tokenFor(master, accepted.identity))]);
|
||||||
const bodyBytes = utf8Bytes(buildFrontmatter(fields.filter(([, v]) => v),
|
const bodyBytes = utf8Bytes(buildFrontmatter(fields.filter(([, v]) => v),
|
||||||
body.replace(/\s+$/, "") + "\n"));
|
body.replace(/\s+$/, "") + "\n"));
|
||||||
const envelope = seal(me, recipient, bodyBytes);
|
const envelope = seal(me, recipient, bodyBytes);
|
||||||
|
// §5.8: our token for their mailbox, if they have given us one.
|
||||||
|
const held = store.tokenFrom(addr.short);
|
||||||
|
const mac = held ? acceptMac(held, messageId(envelope)) : null;
|
||||||
const opened = await connect(addr, { requirePin: false });
|
const opened = await connect(addr, { requirePin: false });
|
||||||
try {
|
try {
|
||||||
await sendOp(opened.session, envelope);
|
await sendOp(opened.session, envelope, mac);
|
||||||
} finally {
|
} finally {
|
||||||
opened.session.stream.close();
|
opened.session.stream.close();
|
||||||
}
|
}
|
||||||
|
|
@ -609,7 +687,7 @@ async function sendMail(toText, subject, body, replyTo, anonymous) {
|
||||||
envelope: seal(me, me.publicKey, bodyBytes),
|
envelope: seal(me, me.publicKey, bodyBytes),
|
||||||
sentAt: Math.floor(Date.now() / 1000),
|
sentAt: Math.floor(Date.now() / 1000),
|
||||||
});
|
});
|
||||||
status(`sent to ${addr.short}`);
|
status(`sent to ${addr.short}${mac ? " (accepted)" : ""}`);
|
||||||
if (folder === "sent") renderFolder();
|
if (folder === "sent") renderFolder();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -635,10 +713,41 @@ async function resolveRecipient(addr) {
|
||||||
|
|
||||||
// --- fetch ----------------------------------------------------------------------
|
// --- fetch ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// The address we know a signer by: a contact, or the Reply-To it signed for
|
||||||
|
// itself. Naming a mailbox is not trusting a key, so nothing is pinned here
|
||||||
|
// (§5.7, §8).
|
||||||
|
function addressOfSigner(sender, replyToField) {
|
||||||
|
const known = store.addressForKey(sender);
|
||||||
|
if (known) return known;
|
||||||
|
if (!replyToField) return null;
|
||||||
|
try {
|
||||||
|
const parsed = parseAddress(replyToField);
|
||||||
|
if (parsed.identity && timingSafeEqual(parsed.identity, sender)) return parsed.short;
|
||||||
|
} catch { /* malformed claim: no address to learn a token under */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// §5.8: an Accept field is bound to the signer of the message that carried
|
||||||
|
// it, which unseal() has already verified.
|
||||||
|
function learnTokenFrom(unsealed, opened) {
|
||||||
|
const raw = opened.fields["accept"];
|
||||||
|
if (!raw) return;
|
||||||
|
let token;
|
||||||
|
try {
|
||||||
|
token = b32decode(raw);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (token.length !== TOKEN_LEN) return;
|
||||||
|
const address = addressOfSigner(unsealed.sender, opened.fields["reply-to"]);
|
||||||
|
if (address) store.learnToken(address, token);
|
||||||
|
}
|
||||||
|
|
||||||
$("fetch").onclick = async () => {
|
$("fetch").onclick = async () => {
|
||||||
const me = store.identity();
|
const me = store.identity();
|
||||||
|
const master = store.master();
|
||||||
const addr = accountAddress();
|
const addr = accountAddress();
|
||||||
if (!me) return status("no identity yet", true);
|
if (!me || !master) return status("no identity yet", true);
|
||||||
if (!addr) return status("not registered; register in settings first", true);
|
if (!addr) return status("not registered; register in settings first", true);
|
||||||
const button = $("fetch"), label = button.querySelector(".label");
|
const button = $("fetch"), label = button.querySelector(".label");
|
||||||
const resting = label.textContent;
|
const resting = label.textContent;
|
||||||
|
|
@ -647,15 +756,24 @@ $("fetch").onclick = async () => {
|
||||||
status("fetching…");
|
status("fetching…");
|
||||||
let stored = 0, verified = 0;
|
let stored = 0, verified = 0;
|
||||||
const rejected = [];
|
const rejected = [];
|
||||||
|
// §10: acknowledging (deleting) is the default; "leave mail on server" pages
|
||||||
|
// forward by cursor instead, so already-fetched mail is never re-downloaded
|
||||||
|
// even though it isn't deleted (storeIfNew also dedupes, as a second line
|
||||||
|
// of defense).
|
||||||
|
const leaveOnServer = store.leaveOnServer();
|
||||||
|
let [afterTime, afterId] = store.cursor();
|
||||||
try {
|
try {
|
||||||
const opened = await connect(addr, { requirePin: true });
|
const opened = await connect(addr, { requirePin: true });
|
||||||
try {
|
try {
|
||||||
await authenticate(opened.session, opened.handshakeHash, addr.user, me);
|
const { sync, tokens } = store.tokenSet(master);
|
||||||
|
await authenticate(opened.session, opened.handshakeHash, addr.user, me, { sync, tokens });
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const records = await fetchOp(opened.session);
|
const records = await fetchOp(opened.session, afterTime, afterId);
|
||||||
if (!records.length) break;
|
if (!records.length) break;
|
||||||
const acked = [];
|
const acked = [];
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
|
afterTime = record.receivedAt;
|
||||||
|
afterId = record.id;
|
||||||
let unsealed;
|
let unsealed;
|
||||||
try {
|
try {
|
||||||
if (!timingSafeEqual(messageId(record.envelope), record.id))
|
if (!timingSafeEqual(messageId(record.envelope), record.id))
|
||||||
|
|
@ -668,29 +786,39 @@ $("fetch").onclick = async () => {
|
||||||
rejected.push(`${hex(record.id)}: ${err.message}`);
|
rejected.push(`${hex(record.id)}: ${err.message}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const row = { id: hex(record.id), envelope: record.envelope, receivedAt: record.receivedAt };
|
const row = { id: hex(record.id), envelope: record.envelope, receivedAt: record.receivedAt,
|
||||||
|
tier: record.isRequest ? store.TIER_REQUESTS : store.TIER_MAIN, keptOnServer: leaveOnServer };
|
||||||
if (await store.storeIfNew("inbox", row)) {
|
if (await store.storeIfNew("inbox", row)) {
|
||||||
stored++;
|
stored++;
|
||||||
// A signed Reply-To is exactly as verified as the signature
|
// A signed Reply-To is exactly as verified as the signature
|
||||||
// unseal() just checked — see bindReplyAddress(). describe()
|
// unseal() just checked — see bindReplyAddress(). describe()
|
||||||
// both parses the same claim the reader would show and warms its
|
// both parses the same claim the reader would show and warms its
|
||||||
// cache, so opening the message right after costs nothing extra.
|
// cache, so opening the message right after costs nothing extra.
|
||||||
const claim = describe(row).replyAddress;
|
const described = describe(row);
|
||||||
if (claim) {
|
if (described.replyAddress) {
|
||||||
try {
|
try {
|
||||||
bindReplyAddress(claim, unsealed.sender);
|
bindReplyAddress(described.replyAddress, unsealed.sender);
|
||||||
verified++;
|
verified++;
|
||||||
} catch { /* already known under a different key: leave it be */ }
|
} catch { /* already known under a different key: leave it be */ }
|
||||||
}
|
}
|
||||||
|
learnTokenFrom(unsealed, described);
|
||||||
}
|
}
|
||||||
acked.push(record.id);
|
acked.push(record.id);
|
||||||
}
|
}
|
||||||
if (!acked.length) break;
|
if (leaveOnServer) {
|
||||||
await deleteOp(opened.session, acked);
|
// Persisted per batch, so an interrupted fetch resumes here rather
|
||||||
|
// than re-paging from the start next time.
|
||||||
|
store.setCursor(afterTime, afterId);
|
||||||
|
} else if (acked.length) {
|
||||||
|
await deleteOp(opened.session, acked);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
opened.session.stream.close();
|
opened.session.stream.close();
|
||||||
}
|
}
|
||||||
|
// Everything acknowledged is deleted, so the next fetch starts fresh; a
|
||||||
|
// record left on the server (rejected above) simply resurfaces then.
|
||||||
|
if (!leaveOnServer) store.setCursor(0, new Uint8Array(ID_LEN));
|
||||||
status([`${stored} new, ${rejected.length} rejected`,
|
status([`${stored} new, ${rejected.length} rejected`,
|
||||||
verified ? `${verified} sender${verified === 1 ? "" : "s"} verified from a signed Reply-To` : null,
|
verified ? `${verified} sender${verified === 1 ? "" : "s"} verified from a signed Reply-To` : null,
|
||||||
...rejected.map(line => ` ${line}`),
|
...rejected.map(line => ` ${line}`),
|
||||||
|
|
@ -710,6 +838,7 @@ $("fetch").onclick = async () => {
|
||||||
function renderContacts(list) {
|
function renderContacts(list) {
|
||||||
const contacts = store.allContacts();
|
const contacts = store.allContacts();
|
||||||
list.replaceChildren(...contacts.map(({ address, key, verified, history }) => {
|
list.replaceChildren(...contacts.map(({ address, key, verified, history }) => {
|
||||||
|
const accepted = store.accepted(address);
|
||||||
const item = el("button", {
|
const item = el("button", {
|
||||||
type: "button",
|
type: "button",
|
||||||
className: `item contact${address === selectedId ? " selected" : ""}`,
|
className: `item contact${address === selectedId ? " selected" : ""}`,
|
||||||
|
|
@ -722,6 +851,7 @@ function renderContacts(list) {
|
||||||
el("div", { textContent: fingerprint(b32decode(key)) }),
|
el("div", { textContent: fingerprint(b32decode(key)) }),
|
||||||
el("span", { className: verified ? "badge ok" : "badge",
|
el("span", { className: verified ? "badge ok" : "badge",
|
||||||
textContent: verified ? "verified key" : "tofu" }),
|
textContent: verified ? "verified key" : "tofu" }),
|
||||||
|
accepted?.active ? el("span", { className: "badge ok", textContent: "accepted" }) : null,
|
||||||
history.length ? el("span", { className: "badge",
|
history.length ? el("span", { className: "badge",
|
||||||
textContent: `${history.length} previous` }) : null));
|
textContent: `${history.length} previous` }) : null));
|
||||||
item.dataset.search =
|
item.dataset.search =
|
||||||
|
|
@ -753,6 +883,18 @@ function openContact(address) {
|
||||||
}
|
}
|
||||||
} }, icon("refresh"), "re-resolve");
|
} }, icon("refresh"), "re-resolve");
|
||||||
|
|
||||||
|
const isAccepted = Boolean(store.accepted(address)?.active);
|
||||||
|
const tokenButton = el("button", { type: "button",
|
||||||
|
textContent: isAccepted ? "block" : "accept",
|
||||||
|
onclick: async (event) => {
|
||||||
|
event.target.disabled = true;
|
||||||
|
try {
|
||||||
|
if (isAccepted) await blockContact(address); else await acceptContact(address);
|
||||||
|
} finally {
|
||||||
|
event.target.disabled = false;
|
||||||
|
}
|
||||||
|
} });
|
||||||
|
|
||||||
view.replaceChildren(...[
|
view.replaceChildren(...[
|
||||||
el("button", { type: "button", className: "back",
|
el("button", { type: "button", className: "back",
|
||||||
onclick: () => { $("app").classList.remove("reading"); } }, icon("back"), "contacts"),
|
onclick: () => { $("app").classList.remove("reading"); } }, icon("back"), "contacts"),
|
||||||
|
|
@ -763,10 +905,16 @@ function openContact(address) {
|
||||||
field("fingerprint", fingerprint(known.key)),
|
field("fingerprint", fingerprint(known.key)),
|
||||||
field("public key", b32encode(known.key)),
|
field("public key", b32encode(known.key)),
|
||||||
field("address", addr.uri(known.key), false, copyButton(addr.uri(known.key), "the address")),
|
field("address", addr.uri(known.key), false, copyButton(addr.uri(known.key), "the address")),
|
||||||
|
el("div", { className: "seed-card" },
|
||||||
|
el("h4", { textContent: "accept token" }),
|
||||||
|
el("p", { className: "hint", textContent: isAccepted
|
||||||
|
? "This contact's mail lands in your main tier."
|
||||||
|
: "This contact's mail lands in requests until accepted." }),
|
||||||
|
el("div", { className: "actions" }, tokenButton)),
|
||||||
known.history.length ? el("section", { className: "history" },
|
known.history.length ? el("section", { className: "history" },
|
||||||
el("h3", { textContent: `previous keys (${known.history.length})` }),
|
el("h3", { textContent: `previous keys (${known.history.length})` }),
|
||||||
el("p", { className: "hint", textContent:
|
el("p", { className: "hint", textContent:
|
||||||
"Replaced by a signed rotation chain (SPEC.md §7). Mail signed with these was sent " +
|
"Replaced by a signed rotation chain. Mail signed with these was sent " +
|
||||||
"before the change; rotation is not revocation, so a stolen key can still rotate onward." }),
|
"before the change; rotation is not revocation, so a stolen key can still rotate onward." }),
|
||||||
...known.history.map(({ key, until }) => el("div", { className: "keyblock" },
|
...known.history.map(({ key, until }) => el("div", { className: "keyblock" },
|
||||||
el("div", { textContent: fingerprint(b32decode(key)) }),
|
el("div", { textContent: fingerprint(b32decode(key)) }),
|
||||||
|
|
@ -796,7 +944,7 @@ async function refreshContact(address) {
|
||||||
status(`${addr.short} pinned (trust on first use${opened.pinned ? "" : ", UNVERIFIED server"})`);
|
status(`${addr.short} pinned (trust on first use${opened.pinned ? "" : ", UNVERIFIED server"})`);
|
||||||
} else if (timingSafeEqual(known.key, current)) {
|
} else if (timingSafeEqual(known.key, current)) {
|
||||||
status(`${addr.short}: key unchanged`);
|
status(`${addr.short}: key unchanged`);
|
||||||
} else if (walkChain(known.key, current, chain)) {
|
} else if (walkChain(addr.user, known.key, current, chain)) {
|
||||||
store.saveContact(addr.short, current, known.verified);
|
store.saveContact(addr.short, current, known.verified);
|
||||||
status(`${addr.short} rotated its key; a signed chain confirms it.\nnow ${b32encode(current)}`, true);
|
status(`${addr.short} rotated its key; a signed chain confirms it.\nnow ${b32encode(current)}`, true);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -810,6 +958,59 @@ async function refreshContact(address) {
|
||||||
else renderFolder();
|
else renderFolder();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- accept tokens (§5.8) --------------------------------------------------------
|
||||||
|
|
||||||
|
// An accept or a block only takes effect once the server holds the changed
|
||||||
|
// set, so it is pushed right away rather than at the next fetch.
|
||||||
|
async function pushTokens() {
|
||||||
|
const me = store.identity();
|
||||||
|
const master = store.master();
|
||||||
|
const addr = accountAddress();
|
||||||
|
if (!me || !master) throw new SmolError("no identity yet");
|
||||||
|
if (!addr) {
|
||||||
|
status("not registered; the set will be pushed with your first fetch", true);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const opened = await connect(addr, { requirePin: true });
|
||||||
|
try {
|
||||||
|
const { sync, tokens } = store.tokenSet(master);
|
||||||
|
return await authenticate(opened.session, opened.handshakeHash, addr.user, me, { sync, tokens });
|
||||||
|
} finally {
|
||||||
|
opened.session.stream.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admit a contact to the main tier; their token travels in our next message
|
||||||
|
// to them.
|
||||||
|
async function acceptContact(address) {
|
||||||
|
try {
|
||||||
|
const key = store.contact(address)?.key;
|
||||||
|
if (!key) throw new SmolError(`no key for ${address} yet`);
|
||||||
|
store.accept(address, key);
|
||||||
|
store.setSyncOk(true);
|
||||||
|
const held = await pushTokens();
|
||||||
|
status(`${address} accepted; server now holds ${held} accept token(s)`);
|
||||||
|
renderFolder();
|
||||||
|
if (folder === "contacts" && selectedId === address) openContact(address);
|
||||||
|
} catch (err) {
|
||||||
|
status(err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Withdraw a contact's accept token; their mail lands in requests from their
|
||||||
|
// next message on.
|
||||||
|
async function blockContact(address) {
|
||||||
|
try {
|
||||||
|
store.block(address);
|
||||||
|
const held = await pushTokens();
|
||||||
|
status(`${address} blocked; server now holds ${held} accept token(s)`);
|
||||||
|
renderFolder();
|
||||||
|
if (folder === "contacts" && selectedId === address) openContact(address);
|
||||||
|
} catch (err) {
|
||||||
|
status(err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- settings ---------------------------------------------------------------------
|
// --- settings ---------------------------------------------------------------------
|
||||||
|
|
||||||
const settings = $("settings");
|
const settings = $("settings");
|
||||||
|
|
@ -822,6 +1023,8 @@ function openSettings() {
|
||||||
$("settings-open").onclick = openSettings;
|
$("settings-open").onclick = openSettings;
|
||||||
$("settings-close").onclick = () => settings.close();
|
$("settings-close").onclick = () => settings.close();
|
||||||
|
|
||||||
|
$("leave-on-server").onchange = (event) => store.setLeaveOnServer(event.target.checked);
|
||||||
|
|
||||||
// Registering against an unpinned server is refused, so the dialog is ordered
|
// Registering against an unpinned server is refused, so the dialog is ordered
|
||||||
// by what depends on what; the chips say how far along that order you are.
|
// by what depends on what; the chips say how far along that order you are.
|
||||||
function step(id, done) {
|
function step(id, done) {
|
||||||
|
|
@ -835,6 +1038,8 @@ function renderSettings() {
|
||||||
const account = store.account();
|
const account = store.account();
|
||||||
const pins = store.allPins();
|
const pins = store.allPins();
|
||||||
|
|
||||||
|
$("leave-on-server").checked = store.leaveOnServer();
|
||||||
|
|
||||||
step("step-server", pins.length > 0);
|
step("step-server", pins.length > 0);
|
||||||
step("step-address", Boolean(account));
|
step("step-address", Boolean(account));
|
||||||
|
|
||||||
|
|
@ -850,16 +1055,18 @@ function renderSettings() {
|
||||||
const card = $("identity-card");
|
const card = $("identity-card");
|
||||||
card.replaceChildren();
|
card.replaceChildren();
|
||||||
if (!me) return;
|
if (!me) return;
|
||||||
|
const masterBytes = store.master();
|
||||||
const share = account ? accountAddress().uri(me.publicKey) : null;
|
const share = account ? accountAddress().uri(me.publicKey) : null;
|
||||||
const retired = store.identities().length - 1;
|
const rotations = store.rotations();
|
||||||
|
const acceptedCount = store.allAccepted().filter(a => a.active).length;
|
||||||
|
|
||||||
// Settings opens by itself right after an identity is created, which is a poor
|
// Settings opens by itself right after an identity is created, which is a poor
|
||||||
// moment to paint the only secret across the screen — so it is asked for.
|
// moment to paint the only secret across the screen — so it is asked for.
|
||||||
const masked = "\u2022".repeat(64);
|
const masked = "\u2022".repeat(64);
|
||||||
const seed = el("div", { className: "seed", textContent: masked });
|
const master = el("div", { className: "seed", textContent: masked });
|
||||||
const reveal = el("button", { type: "button", textContent: "reveal", onclick: () => {
|
const reveal = el("button", { type: "button", textContent: "reveal", onclick: () => {
|
||||||
const showing = seed.textContent !== masked;
|
const showing = master.textContent !== masked;
|
||||||
seed.textContent = showing ? masked : hex(me.seed);
|
master.textContent = showing ? masked : hex(masterBytes);
|
||||||
reveal.textContent = showing ? "reveal" : "hide";
|
reveal.textContent = showing ? "reveal" : "hide";
|
||||||
} });
|
} });
|
||||||
|
|
||||||
|
|
@ -869,27 +1076,30 @@ function renderSettings() {
|
||||||
field("shareable", share ?? "register above to get one", !share,
|
field("shareable", share ?? "register above to get one", !share,
|
||||||
share ? copyButton(share, "your address") : null),
|
share ? copyButton(share, "your address") : null),
|
||||||
el("div", { className: "seed-card" },
|
el("div", { className: "seed-card" },
|
||||||
el("h4", { textContent: "recovery seed" }),
|
el("h4", { textContent: "recovery master" }),
|
||||||
el("div", { className: "row" }, seed, reveal, copyButton(hex(me.seed), "the seed")),
|
el("div", { className: "row" }, master, reveal, copyButton(hex(masterBytes), "the master")),
|
||||||
el("p", { textContent: "The only secret there is. Anyone who holds it can read your mail and " +
|
el("p", { textContent: "The only secret there is. Anyone who holds it can read your mail and " +
|
||||||
"write as you, and losing it loses the address with it — there is no reset. Copy it " +
|
"write as you, and losing it loses the address with it — there is no reset. Copy it " +
|
||||||
"somewhere safe now." })),
|
"somewhere safe now." })),
|
||||||
field("retired keys", String(retired)),
|
field("rotations", String(rotations)),
|
||||||
retired ? el("p", { className: "hint",
|
rotations ? el("p", { className: "hint",
|
||||||
textContent: "Kept so mail sealed to a superseded key stays readable." }) : null,
|
textContent: "Earlier keys are re-derived on demand from the master, so mail sealed to a " +
|
||||||
|
"superseded key stays readable." }) : null,
|
||||||
|
field("accepted correspondents", String(acceptedCount) +
|
||||||
|
(store.syncOk() ? "" : " (not yet pushed to the server — accept them again)")),
|
||||||
el("details", {},
|
el("details", {},
|
||||||
el("summary", { textContent: "advanced" }),
|
el("summary", { textContent: "advanced" }),
|
||||||
el("p", { className: "hint", textContent:
|
el("p", { className: "hint", textContent:
|
||||||
"Rotating issues a fresh key with a signed certificate proving it replaces this one. " +
|
"Rotating issues a fresh key — the next one derivable from the master — with a signed " +
|
||||||
"Contacts accept the change from that chain, and the old seed is kept, since mail sealed " +
|
"certificate proving it replaces this one. Contacts accept the change from that chain, and " +
|
||||||
"to it is readable with nothing else. Rotation is not revocation (SPEC.md \u00a77)." }),
|
"the old key stays readable with nothing else. Rotation is not revocation." }),
|
||||||
el("button", { type: "button", textContent: "rotate identity", onclick: rotateIdentity }),
|
el("button", { type: "button", textContent: "rotate identity", onclick: rotateIdentity }),
|
||||||
el("p", { className: "hint", textContent:
|
el("p", { className: "hint", textContent:
|
||||||
"Log out to remove this identity from this browser entirely: the seed, pins, contacts and " +
|
"Log out to remove this identity from this browser entirely: the master, pins, contacts and " +
|
||||||
"every cached message. Without a backup of the seed there is no way back in — same as any " +
|
"every cached message. Without a backup of the master there is no way back in — same as any " +
|
||||||
"other loss of it. Fetching deletes from the server once acknowledged (SPEC.md §5), so a " +
|
"other loss of it. Fetching deletes from the server once acknowledged unless \u201cleave mail " +
|
||||||
"message already fetched here has no other copy anywhere; anything still unfetched stays " +
|
"on server\u201d is on, so a message already fetched here may have no other " +
|
||||||
"on the server and comes back on the next fetch after logging back in." }),
|
"copy anywhere; anything still on the server comes back on the next fetch after logging back in." }),
|
||||||
el("button", { type: "button", className: "danger", textContent: "log out & erase this browser",
|
el("button", { type: "button", className: "danger", textContent: "log out & erase this browser",
|
||||||
onclick: logout })),
|
onclick: logout })),
|
||||||
].filter(node => node != null));
|
].filter(node => node != null));
|
||||||
|
|
@ -948,13 +1158,18 @@ $("import-form").onsubmit = (event) => {
|
||||||
$("register-form").onsubmit = async (event) => {
|
$("register-form").onsubmit = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const form = event.target;
|
const form = event.target;
|
||||||
|
const submit = form.querySelector("button");
|
||||||
|
const resting = submit.textContent;
|
||||||
|
submit.disabled = true; // a round trip to the server takes a moment; say so rather than sit inert
|
||||||
|
submit.textContent = "registering…";
|
||||||
try {
|
try {
|
||||||
const me = store.identity();
|
const me = store.identity();
|
||||||
if (!me) throw new SmolError("no identity yet");
|
if (!me) throw new SmolError("no identity yet");
|
||||||
const addr = parseAddress(form.address.value.trim());
|
const addr = parseAddress(form.address.value.trim());
|
||||||
const opened = await connect(addr, { requirePin: true });
|
const opened = await connect(addr, { requirePin: true });
|
||||||
try {
|
try {
|
||||||
await registerOp(opened.session, { username: addr.user, identity: me, token: form.token.value.trim() });
|
await registerOp(opened.session, opened.serverStatic,
|
||||||
|
{ username: addr.user, identity: me, token: form.token.value.trim() });
|
||||||
} finally {
|
} finally {
|
||||||
opened.session.stream.close();
|
opened.session.stream.close();
|
||||||
}
|
}
|
||||||
|
|
@ -965,10 +1180,13 @@ $("register-form").onsubmit = async (event) => {
|
||||||
form.reset();
|
form.reset();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
status(err.message, true);
|
status(err.message, true);
|
||||||
|
} finally {
|
||||||
|
submit.disabled = false;
|
||||||
|
submit.textContent = resting;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Restoring a seed brings back the identity, not the memory of what address a
|
// Restoring a master brings back the identity, not the memory of what address a
|
||||||
// *different* browser registered it under — "account" is local-only state,
|
// *different* browser registered it under — "account" is local-only state,
|
||||||
// never asked of the server. This binds it without REGISTER: RESOLVE the
|
// never asked of the server. This binds it without REGISTER: RESOLVE the
|
||||||
// address and require it name this exact key, so a typo or someone else's
|
// address and require it name this exact key, so a typo or someone else's
|
||||||
|
|
@ -991,9 +1209,41 @@ async function recallAccount(text) {
|
||||||
return addr;
|
return addr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// §2: a master alone does not say which rotation index a server has bound,
|
||||||
|
// so restoring resolves the address and walks indices 0..MAX_CHAIN until one
|
||||||
|
// derives the key RESOLVE returned, correcting restore-identity's default
|
||||||
|
// guess of index 0. Also binds "account", like recallAccount().
|
||||||
|
async function recallAndDiscoverRotation(masterBytes, text) {
|
||||||
|
const addr = parseAddress(text.trim());
|
||||||
|
const opened = await connect(addr, { requirePin: true });
|
||||||
|
let current;
|
||||||
|
try {
|
||||||
|
({ identity: current } = await resolveOp(opened.session, addr.user));
|
||||||
|
} finally {
|
||||||
|
opened.session.stream.close();
|
||||||
|
}
|
||||||
|
let found = null;
|
||||||
|
for (let n = 0; n <= MAX_CHAIN; n++) {
|
||||||
|
if (timingSafeEqual(identityFromSeed(identitySeed(masterBytes, n)).publicKey, current)) {
|
||||||
|
found = n;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found === null)
|
||||||
|
throw new SmolError(`the key bound to ${addr.short} is not derived from this master ` +
|
||||||
|
`within ${MAX_CHAIN} rotations`);
|
||||||
|
store.setRotationIndex(found);
|
||||||
|
store.setAccount(addr);
|
||||||
|
return addr;
|
||||||
|
}
|
||||||
|
|
||||||
$("recall-form").onsubmit = async (event) => {
|
$("recall-form").onsubmit = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const form = event.target;
|
const form = event.target;
|
||||||
|
const submit = form.querySelector("button");
|
||||||
|
const resting = submit.textContent;
|
||||||
|
submit.disabled = true; // a round trip to the server takes a moment; say so rather than sit inert
|
||||||
|
submit.textContent = "recalling…";
|
||||||
try {
|
try {
|
||||||
const me = store.identity();
|
const me = store.identity();
|
||||||
const addr = await recallAccount(form.address.value);
|
const addr = await recallAccount(form.address.value);
|
||||||
|
|
@ -1003,6 +1253,9 @@ $("recall-form").onsubmit = async (event) => {
|
||||||
form.reset();
|
form.reset();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
status(err.message, true);
|
status(err.message, true);
|
||||||
|
} finally {
|
||||||
|
submit.disabled = false;
|
||||||
|
submit.textContent = resting;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1017,7 +1270,7 @@ $("export-data").onclick = async () => {
|
||||||
// No counts in the message: the file itself is sealed precisely so an
|
// No counts in the message: the file itself is sealed precisely so an
|
||||||
// observer learns nothing from it, and a summary here would just move
|
// observer learns nothing from it, and a summary here would just move
|
||||||
// that same leak from the file to the screen right next to it.
|
// that same leak from the file to the screen right next to it.
|
||||||
status("exported — sealed to this identity; only a browser with this seed restored can open it");
|
status("exported — sealed to this identity; only a browser with this master restored can open it");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
status(err.message, true);
|
status(err.message, true);
|
||||||
}
|
}
|
||||||
|
|
@ -1046,40 +1299,50 @@ $("import-data").onchange = async (event) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async function rotateIdentity() {
|
// §7: rotate to the next index's derived key and rebind the account with a
|
||||||
|
// signed certificate. The superseded key stays derivable from the master,
|
||||||
|
// since mail sealed to it stays readable with nothing else.
|
||||||
|
async function rotateIdentity(event) {
|
||||||
const me = store.identity();
|
const me = store.identity();
|
||||||
|
const master = store.master();
|
||||||
const addr = accountAddress();
|
const addr = accountAddress();
|
||||||
if (!me || !addr) return status("rotate needs a registered account", true);
|
if (!me || !master || !addr) return status("rotate needs a registered account", true);
|
||||||
if (!window.confirm("Rotate the identity key?\n\nContacts accept the change from the signed " +
|
if (!window.confirm("Rotate the identity key?\n\nContacts accept the change from the signed " +
|
||||||
"chain. The old key is kept, since mail sealed to it stays readable with nothing else."))
|
"chain. The old key is kept, since mail sealed to it stays readable with nothing else."))
|
||||||
return;
|
return;
|
||||||
|
const button = event.target;
|
||||||
|
button.disabled = true; // a round trip to the server takes a moment; say so rather than sit inert
|
||||||
|
button.textContent = "rotating…";
|
||||||
try {
|
try {
|
||||||
const fresh = newIdentity();
|
const freshSeed = identitySeed(master, store.rotations() + 1);
|
||||||
const cert = makeCert(me, fresh.seed);
|
const fresh = identityFromSeed(freshSeed);
|
||||||
|
const cert = makeCert(addr.user, me, freshSeed);
|
||||||
const opened = await connect(addr, { requirePin: true });
|
const opened = await connect(addr, { requirePin: true });
|
||||||
try {
|
try {
|
||||||
await registerOp(opened.session, { username: addr.user, identity: fresh, cert });
|
await registerOp(opened.session, opened.serverStatic, { username: addr.user, identity: fresh, cert });
|
||||||
} finally {
|
} finally {
|
||||||
opened.session.stream.close();
|
opened.session.stream.close();
|
||||||
}
|
}
|
||||||
store.rotateIdentity(fresh.seed);
|
store.advanceRotation();
|
||||||
// The key set grew, so a cached "not one of our keys" failure may be stale.
|
// The key set grew, so a cached "not one of our keys" failure may be stale.
|
||||||
openedCache.clear();
|
openedCache.clear();
|
||||||
status(`rotated ${addr.short}\nnew key: ${b32encode(fresh.publicKey)}`);
|
status(`rotated ${addr.short}\nnew key: ${b32encode(fresh.publicKey)}`);
|
||||||
renderAccount();
|
renderAccount();
|
||||||
renderSettings();
|
renderSettings(); // rebuilds the identity card, this button included
|
||||||
renderFolder();
|
renderFolder();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = "rotate identity";
|
||||||
status(err.message, true);
|
status(err.message, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unlike rotate, nothing is kept: this is the seed's only local copy, gone.
|
// Unlike rotate, nothing is kept: this is the master's only local copy, gone.
|
||||||
async function logout() {
|
async function logout() {
|
||||||
if (!window.confirm(
|
if (!window.confirm(
|
||||||
"Log out and erase this browser?\n\n" +
|
"Log out and erase this browser?\n\n" +
|
||||||
"This deletes the seed, pins, contacts and every cached message from this browser. Make sure " +
|
"This deletes the master, pins, contacts and every cached message from this browser. Make sure " +
|
||||||
"the seed is backed up elsewhere first — without it there is no way back in."
|
"the master is backed up elsewhere first — without it there is no way back in."
|
||||||
)) return;
|
)) return;
|
||||||
await store.clearAll();
|
await store.clearAll();
|
||||||
location.reload();
|
location.reload();
|
||||||
|
|
|
||||||
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
|
// Smol Mail protocol, version 1.1 (../smolmail SPEC.md): addresses, sealed and
|
||||||
// signed envelopes, body frontmatter, key rotation, and the framed request
|
// signed envelopes, body frontmatter, key rotation, accept tokens, and the
|
||||||
// and response bodies of the five operations.
|
// framed request and response bodies of the five operations.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
aeadDecrypt, aeadEncrypt, concat, ed25519PublicKey, ed25519SeedToX25519,
|
aeadDecrypt, aeadEncrypt, concat, ed25519PublicKey, ed25519SeedToX25519,
|
||||||
ed25519Sign, ed25519ToX25519, ed25519Verify, hkdfSha256, randomBytes, sha256, timingSafeEqual,
|
ed25519Sign, ed25519ToX25519, ed25519Verify, hkdfSha256, hmacSha256, randomBytes, sha256,
|
||||||
utf8Bytes, x25519, x25519Base,
|
timingSafeEqual, utf8Bytes, x25519, x25519Base,
|
||||||
} from "./crypto.js";
|
} from "./crypto.js";
|
||||||
import { NxInitiator } from "./noise.js";
|
import { NxInitiator } from "./noise.js";
|
||||||
|
|
||||||
export const DEFAULT_PORT = 1961;
|
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 MAX_FRAME = 1 << 20, NOISE_PAYLOAD = 65535 - 16, PAD_TO = 1024;
|
||||||
export const ENVELOPE_HEADER = 69, PAYLOAD_HEADER = 45, MAX_CHAIN = 16;
|
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;
|
const FRONTMATTER_MAX = 4096, FRONTMATTER_KEYS = 64;
|
||||||
|
|
||||||
export const OP = { AUTH: 0x00, RESOLVE: 0x01, SEND: 0x02, FETCH: 0x03, DELETE: 0x04, REGISTER: 0x05 };
|
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({
|
const LABEL = Object.freeze({
|
||||||
auth: utf8Bytes("smolmail/1 auth"), seal: utf8Bytes("smolmail/1 seal"),
|
auth: utf8Bytes("smolmail/1 auth"), seal: utf8Bytes("smolmail/1 seal"),
|
||||||
msg: utf8Bytes("smolmail/1 msg"), id: utf8Bytes("smolmail/1 id"),
|
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 -------------------------------------------------------
|
// --- encoding helpers -------------------------------------------------------
|
||||||
|
|
@ -89,16 +93,37 @@ export class Reader {
|
||||||
get done() { return this.pos === this.buf.length; }
|
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) {
|
export function identityFromSeed(seed) {
|
||||||
if (seed.length !== KEY_LEN) throw new SmolError(`identity seed must be ${KEY_LEN} bytes`);
|
if (seed.length !== KEY_LEN) throw new SmolError(`identity seed must be ${KEY_LEN} bytes`);
|
||||||
return { seed, publicKey: ed25519PublicKey(seed) };
|
return { seed, publicKey: ed25519PublicKey(seed) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newIdentity() {
|
export function newMaster() {
|
||||||
return identityFromSeed(randomBytes(KEY_LEN));
|
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) --------------------------------------------------------
|
// --- addressing (§3) --------------------------------------------------------
|
||||||
|
|
@ -132,8 +157,10 @@ export function parseAddress(text) {
|
||||||
|
|
||||||
// --- message format (§5) ----------------------------------------------------
|
// --- message format (§5) ----------------------------------------------------
|
||||||
|
|
||||||
|
// §5.4: derived from the envelope so no sender can choose it; used whole,
|
||||||
|
// nothing truncates it.
|
||||||
export function messageId(envelope) {
|
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
|
// §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
|
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))
|
if (!ed25519Verify(sender, concat(LABEL.msg, to, epk, plaintext.slice(0, PAYLOAD_HEADER), body), signature))
|
||||||
throw new SmolError("signature does not verify");
|
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) };
|
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
|
// A flat `Key: value` block, deliberately not YAML. Any malformed line
|
||||||
// invalidates the whole block, which is then returned as ordinary body text:
|
// 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) {
|
export function parseFrontmatter(text) {
|
||||||
if (!text.startsWith("---\n")) return { fields: {}, body: text };
|
if (!text.startsWith("---\n")) return { fields: {}, body: text };
|
||||||
const lines = text.split("\n");
|
const lines = text.split("\n");
|
||||||
|
|
@ -197,7 +226,7 @@ export function parseFrontmatter(text) {
|
||||||
for (let i = 0; i < block.length; i++) {
|
for (let i = 0; i < block.length; i++) {
|
||||||
const line = block[i], colon = line.indexOf(":");
|
const line = block[i], colon = line.indexOf(":");
|
||||||
if (colon < 0 || !FM_KEY.test(line.slice(0, colon))) return { fields: {}, body: text };
|
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
|
if (!(key in fields)) fields[key] = line.slice(colon + 1).trim(); // first occurrence wins
|
||||||
}
|
}
|
||||||
return { fields, body: rest };
|
return { fields, body: rest };
|
||||||
|
|
@ -212,28 +241,35 @@ export function buildFrontmatter(fields, body) {
|
||||||
|
|
||||||
// --- key rotation (§7) -------------------------------------------------------
|
// --- 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 newPub = ed25519PublicKey(newSeed), time = i64BE(when);
|
||||||
|
const signed = concat(LABEL.rotate, utf8Bytes(username), oldIdentity.publicKey, newPub, time);
|
||||||
return concat(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
|
// Accept a key change only when a signed chain leads from the key we hold to
|
||||||
// the one the server now returns (§7).
|
// the one the server now returns, both keys signing each link (§7).
|
||||||
export function walkChain(pinned, current, chain) {
|
export function walkChain(username, pinned, current, chain) {
|
||||||
const same = (a, b) => timingSafeEqual(a, b);
|
const same = (a, b) => timingSafeEqual(a, b);
|
||||||
if (same(pinned, current)) return true;
|
if (same(pinned, current)) return true;
|
||||||
if (!chain.length || chain.length > MAX_CHAIN) return false;
|
if (!chain.length || chain.length > MAX_CHAIN) return false;
|
||||||
let key = pinned, started = false;
|
let key = pinned, started = false;
|
||||||
for (const cert of chain) {
|
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 (!started) {
|
||||||
if (!same(old, key)) continue; // a link predating the key we hold
|
if (!same(old, key)) continue; // a link predating the key we hold
|
||||||
started = true;
|
started = true;
|
||||||
} else if (!same(old, key)) {
|
} else if (!same(old, key)) {
|
||||||
return false; // the chain is not continuous
|
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;
|
key = next;
|
||||||
}
|
}
|
||||||
return started && same(key, current);
|
return started && same(key, current);
|
||||||
|
|
@ -356,13 +392,20 @@ export function expectOk(status, what) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// §4 session authentication: sign the handshake hash, which binds the
|
// §4 session authentication: sign the handshake hash, which binds the
|
||||||
// signature to this session's server ephemeral and cannot be replayed.
|
// signature to this session's server ephemeral and cannot be replayed, and
|
||||||
export async function authenticate(session, handshakeHash, username, identity) {
|
// 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);
|
const name = utf8Bytes(username);
|
||||||
if (name.length > 255) throw new SmolError("username too long");
|
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,
|
const body = concat(Uint8Array.of(name.length), name, identity.publicKey,
|
||||||
ed25519Sign(identity.seed, concat(LABEL.auth, handshakeHash)));
|
ed25519Sign(identity.seed, concat(LABEL.auth, handshakeHash)),
|
||||||
expectOk((await session.call(OP.AUTH, body)).status, "authentication");
|
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).
|
// 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)) };
|
return { identity: r.take(KEY_LEN), chain: Array.from({ length: r.u8() }, () => r.take(CERT_LEN)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendOp(session, envelope) {
|
// §5.8: `mac` is the sender's proof of an accept token, absent or TOKEN_LEN bytes.
|
||||||
const { status, body } = await session.call(OP.SEND, envelope);
|
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");
|
expectOk(status, "sending");
|
||||||
return body.length === ID_LEN ? body : messageId(envelope);
|
return reply.length === ID_LEN ? reply : messageId(envelope);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchOp(session) {
|
// §6.1: pages forward from a cursor; an all-zero id starts at the beginning.
|
||||||
const { status, body } = await session.call(OP.FETCH);
|
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");
|
expectOk(status, "fetching");
|
||||||
const r = new Reader(body);
|
const r = new Reader(reply);
|
||||||
return Array.from({ length: r.u16() }, () => {
|
return Array.from({ length: r.u16() }, () => {
|
||||||
const id = r.take(ID_LEN), receivedAt = Number(r.i64());
|
const id = r.take(ID_LEN), receivedAt = Number(r.i64()), flags = r.u8();
|
||||||
return { id, receivedAt, envelope: r.take(r.u32()) };
|
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();
|
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 name = utf8Bytes(username);
|
||||||
const tokenBytes = utf8Bytes(token);
|
const tokenBytes = utf8Bytes(token);
|
||||||
if (name.length > 255 || tokenBytes.length > 255 || cert && cert.length > 255)
|
if (name.length > 255 || tokenBytes.length > 255 || cert && cert.length > 255)
|
||||||
throw new SmolError("REGISTER field too long");
|
throw new SmolError("REGISTER field too long");
|
||||||
const body = concat(Uint8Array.of(name.length), name, identity.publicKey,
|
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(tokenBytes.length), tokenBytes,
|
||||||
Uint8Array.of(cert ? cert.length : 0), cert ?? new Uint8Array(0));
|
Uint8Array.of(cert ? cert.length : 0), cert ?? new Uint8Array(0));
|
||||||
const { status } = await session.call(OP.REGISTER, body);
|
const { status } = await session.call(OP.REGISTER, body);
|
||||||
|
|
|
||||||
266
web/js/store.js
266
web/js/store.js
|
|
@ -1,12 +1,14 @@
|
||||||
// Browser state: identity, pins, contacts and read markers in localStorage;
|
// Browser state: identity, pins, contacts and read markers in localStorage;
|
||||||
// sealed envelopes in IndexedDB, opened only on demand, so nothing at rest
|
// sealed envelopes in IndexedDB, opened only on demand, so nothing at rest
|
||||||
// is plaintext (the seed excepted — it is the user's browser profile).
|
// 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 { aeadDecrypt, aeadEncrypt, hex, hkdfSha256, randomBytes, unhex, utf8Bytes } from "./crypto.js";
|
||||||
import { b32decode, b32encode, identityFromSeed } from "./proto.js";
|
import { ID_LEN, MAX_CHAIN, b32decode, b32encode, identityFromSeed, identitySeed, tokenFor } from "./proto.js";
|
||||||
|
|
||||||
const KEY = "gsmol";
|
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
|
// 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
|
// parse is cached. `derived` holds the identities that follow from it, which
|
||||||
// cost an Ed25519 scalar multiplication each.
|
// cost an Ed25519 scalar multiplication each.
|
||||||
|
|
@ -39,44 +41,82 @@ function update(fn) {
|
||||||
save(next ?? state);
|
save(next ?? state);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- identity ----------------------------------------------------------------
|
// --- identity (§2) -------------------------------------------------------------
|
||||||
|
|
||||||
export function seed() {
|
export function master() {
|
||||||
const raw = load().seed;
|
const raw = load().master;
|
||||||
return raw ? unhex(raw) : null;
|
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() {
|
export function identity() {
|
||||||
return identities()[0] ?? null;
|
return identities()[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setIdentity(newSeed) {
|
// §7: every key rotated away from is re-derivable from the master, since mail
|
||||||
if (seed()) throw new Error("an identity already exists; rotate it instead");
|
// sealed to a superseded key is readable with nothing else.
|
||||||
update(state => { state.seed = hex(newSeed); });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rotation (§7): the old seed is retained, since mail sealed to a superseded
|
|
||||||
// key is readable with nothing else.
|
|
||||||
export function rotateIdentity(newSeed) {
|
|
||||||
const old = seed();
|
|
||||||
if (!old) throw new Error("no identity to rotate");
|
|
||||||
update(state => {
|
|
||||||
state.retired = [...(state.retired || []), { seed: hex(old), at: Date.now() }];
|
|
||||||
state.seed = hex(newSeed);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// §7: seeds rotated away from are retained, since mail sealed to a superseded
|
|
||||||
// key is readable with nothing else.
|
|
||||||
export function identities() {
|
export function identities() {
|
||||||
if (derived) return derived;
|
if (derived) return derived;
|
||||||
const raw = seed();
|
const m = master();
|
||||||
if (!raw) return [];
|
if (!m) return [];
|
||||||
const retired = (load().retired || []).map(({ seed: s }) => identityFromSeed(unhex(s)));
|
derived = [];
|
||||||
derived = [identityFromSeed(raw), ...retired];
|
for (let n = rotations(); n >= 0; n--) derived.push(identityFromSeed(identitySeed(m, n)));
|
||||||
return derived;
|
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 ---------------------------------------------------
|
// --- account and server pins ---------------------------------------------------
|
||||||
|
|
||||||
export function account() {
|
export function account() {
|
||||||
|
|
@ -111,6 +151,32 @@ export function allPins() {
|
||||||
return Object.entries(load().servers || {}).map(([host, key]) => ({ host, key }));
|
return Object.entries(load().servers || {}).map(([host, key]) => ({ host, key }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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; });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ------------------------------------------------------------------
|
// --- contacts ------------------------------------------------------------------
|
||||||
|
|
||||||
export function contact(address) {
|
export function contact(address) {
|
||||||
|
|
@ -144,6 +210,70 @@ export function allContacts() {
|
||||||
.map(([address, c]) => ({ address, key: c.key, verified: c.verified, history: c.history ?? [] }));
|
.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 ---------------------------------------------------------------
|
// --- read markers ---------------------------------------------------------------
|
||||||
|
|
||||||
export function markRead(idHex) {
|
export function markRead(idHex) {
|
||||||
|
|
@ -201,6 +331,11 @@ function tx(db, box, mode, fn) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "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) {
|
export async function storeMessage(box, record) {
|
||||||
const db = await openDb();
|
const db = await openDb();
|
||||||
await tx(db, box, "readwrite", store => store.put(record));
|
await tx(db, box, "readwrite", store => store.put(record));
|
||||||
|
|
@ -215,27 +350,37 @@ export async function storeIfNew(box, record) {
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listMessages(box) {
|
export async function listMessages(folder) {
|
||||||
|
const physical = physicalFolder(folder);
|
||||||
const db = await openDb();
|
const db = await openDb();
|
||||||
const rows = await tx(db, box, "readonly", store => store.getAll());
|
const rows = await tx(db, physical, "readonly", store => store.getAll());
|
||||||
return rows.sort((a, b) => (b.receivedAt ?? b.sentAt) - (a.receivedAt ?? a.sentAt)); // newest first
|
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(box, id) {
|
export async function getMessage(folder, id) {
|
||||||
const db = await openDb();
|
const db = await openDb();
|
||||||
return await tx(db, box, "readonly", store => store.get(id)) ?? null;
|
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
|
// The unread badge must survive leaving the inbox, so it is counted from ids
|
||||||
// and read markers alone — no envelope is opened.
|
// and read markers alone — no envelope is opened.
|
||||||
export async function unreadCount() {
|
async function unreadIn(folder) {
|
||||||
const db = await openDb();
|
const rows = await listMessages(folder);
|
||||||
const ids = await tx(db, "inbox", "readonly", store => store.getAllKeys());
|
|
||||||
const read = load().read || {};
|
const read = load().read || {};
|
||||||
return ids.filter(id => !read[id]).length;
|
return rows.filter(row => !read[row.id]).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- export / import: mail, contacts, pins — never the seed ---------------------
|
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
|
// 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
|
// byte); this just bridges that to Uint8Array. Browser-only, like the rest of
|
||||||
|
|
@ -246,38 +391,42 @@ const fromBase64 = (text) => Uint8Array.from(atob(text), c => c.charCodeAt(0));
|
||||||
// A gsmol-only construction, not the wire protocol's own HKDF namespace
|
// A gsmol-only construction, not the wire protocol's own HKDF namespace
|
||||||
// ("smolmail/1 ..." in proto.js) — export/import has no counterpart in
|
// ("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.
|
// SPEC.md, so it gets its own label rather than borrowing the protocol's.
|
||||||
// Sealing to a key derived from the identity's own seed means the file is
|
// 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
|
// opaque without it — no new passphrase to manage, and it can only ever be
|
||||||
// opened where that seed is already restored, exactly the situation import
|
// opened where that master is already restored, exactly the situation import
|
||||||
// already assumes.
|
// already assumes.
|
||||||
const EXPORT_LABEL = utf8Bytes("gsmol/1 export");
|
const EXPORT_LABEL = utf8Bytes("gsmol/1 export");
|
||||||
const exportKey = (seed) => hkdfSha256(seed, new Uint8Array(0), EXPORT_LABEL, 32);
|
const exportKey = (masterBytes) => hkdfSha256(masterBytes, new Uint8Array(0), EXPORT_LABEL, 32);
|
||||||
|
|
||||||
// Deliberately excludes the seed: it already has its own reveal-and-copy flow
|
// Deliberately excludes the master: it already has its own reveal-and-copy
|
||||||
// in settings, meant for a password manager, not a downloadable file. Beyond
|
// flow in settings, meant for a password manager, not a downloadable file.
|
||||||
// that, everything else in here was worth hiding too — contacts and pins are
|
// Beyond that, everything else in here was worth hiding too — contacts and
|
||||||
// a social graph and a list of which mail servers you use, not just the mail
|
// pins are a social graph and a list of which mail servers you use, not just
|
||||||
// SPEC.md §5 makes irreplaceable once fetched — so the whole payload is
|
// the mail SPEC.md §5 makes irreplaceable once fetched — so the whole payload
|
||||||
// sealed, not just the parts that were already ciphertext at rest.
|
// is sealed, not just the parts that were already ciphertext at rest.
|
||||||
export async function exportData() {
|
export async function exportData() {
|
||||||
const me = identity();
|
const m = master();
|
||||||
if (!me) throw new Error("no identity yet");
|
if (!m) throw new Error("no identity yet");
|
||||||
const [inbox, sent] = await Promise.all([listMessages("inbox"), listMessages("sent")]);
|
const [inbox, requests, sent] = await Promise.all(
|
||||||
|
[listMessages("inbox"), listMessages("requests"), listMessages("sent")]);
|
||||||
const payload = {
|
const payload = {
|
||||||
servers: Object.fromEntries(allPins().map(({ host, key }) => [host, key])),
|
servers: Object.fromEntries(allPins().map(({ host, key }) => [host, key])),
|
||||||
contacts: Object.fromEntries(allContacts().map(({ address, key, verified, history }) =>
|
contacts: Object.fromEntries(allContacts().map(({ address, key, verified, history }) =>
|
||||||
[address, { key, verified, history }])),
|
[address, { key, verified, history }])),
|
||||||
inbox: inbox.map(row => ({ id: row.id, receivedAt: row.receivedAt, envelope: toBase64(row.envelope) })),
|
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 =>
|
sent: sent.map(row =>
|
||||||
({ id: row.id, recipient: row.recipient, sentAt: row.sentAt, envelope: toBase64(row.envelope) })),
|
({ 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
|
// Random per export: the key is the same every time (deterministic from the
|
||||||
// seed), so nonce reuse has to be ruled out the way it always is under a
|
// 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
|
// 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
|
// 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).
|
// makes the derived key itself unique, so a zero nonce is safe).
|
||||||
const nonce = randomBytes(12);
|
const nonce = randomBytes(12);
|
||||||
const ciphertext = aeadEncrypt(exportKey(me.seed), nonce, utf8Bytes(JSON.stringify(payload)), new Uint8Array(0));
|
const ciphertext = aeadEncrypt(exportKey(m), nonce, utf8Bytes(JSON.stringify(payload)), new Uint8Array(0));
|
||||||
return {
|
return {
|
||||||
gsmolExport: 2,
|
gsmolExport: 2,
|
||||||
exportedAt: Date.now(),
|
exportedAt: Date.now(),
|
||||||
|
|
@ -300,11 +449,11 @@ const isRecord = (value) => typeof value === "object" && value !== null && !Arra
|
||||||
export async function importData(data) {
|
export async function importData(data) {
|
||||||
let payload;
|
let payload;
|
||||||
if (data?.gsmolExport === 2) {
|
if (data?.gsmolExport === 2) {
|
||||||
const me = identity();
|
const m = master();
|
||||||
if (!me) throw new Error("no identity yet — restore it before importing");
|
if (!m) throw new Error("no identity yet — restore it before importing");
|
||||||
try {
|
try {
|
||||||
const plaintext = aeadDecrypt(
|
const plaintext = aeadDecrypt(
|
||||||
exportKey(me.seed), fromBase64(data.nonce), fromBase64(data.ciphertext), new Uint8Array(0));
|
exportKey(m), fromBase64(data.nonce), fromBase64(data.ciphertext), new Uint8Array(0));
|
||||||
payload = JSON.parse(new TextDecoder().decode(plaintext));
|
payload = JSON.parse(new TextDecoder().decode(plaintext));
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("couldn't decrypt — exported by a different identity, or the file is corrupted");
|
throw new Error("couldn't decrypt — exported by a different identity, or the file is corrupted");
|
||||||
|
|
@ -352,7 +501,8 @@ export async function importData(data) {
|
||||||
for (const row of Array.isArray(rows) ? rows : []) {
|
for (const row of Array.isArray(rows) ? rows : []) {
|
||||||
try {
|
try {
|
||||||
const record = box === "inbox"
|
const record = box === "inbox"
|
||||||
? { id: row.id, receivedAt: row.receivedAt, envelope: fromBase64(row.envelope) }
|
? { 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) };
|
: { id: row.id, recipient: row.recipient, sentAt: row.sentAt, envelope: fromBase64(row.envelope) };
|
||||||
if (await storeIfNew(box, record)) summary.mailAdded++;
|
if (await storeIfNew(box, record)) summary.mailAdded++;
|
||||||
} catch { summary.malformed++; }
|
} catch { summary.malformed++; }
|
||||||
|
|
@ -362,8 +512,8 @@ export async function importData(data) {
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logout: erases the seed, pins, contacts and every cached message from this
|
// Logout: erases the master, pins, contacts and every cached message from
|
||||||
// browser. Closes the held connection first so the delete isn't left
|
// this browser. Closes the held connection first so the delete isn't left
|
||||||
// "blocked" waiting for a handle that never closes on its own.
|
// "blocked" waiting for a handle that never closes on its own.
|
||||||
export async function clearAll() {
|
export async function clearAll() {
|
||||||
localStorage.removeItem(KEY);
|
localStorage.removeItem(KEY);
|
||||||
|
|
|
||||||
|
|
@ -356,6 +356,14 @@ dialog::backdrop { background: var(--backdrop); }
|
||||||
#compose textarea { font-family: inherit; resize: vertical; }
|
#compose textarea { font-family: inherit; resize: vertical; }
|
||||||
#compose footer { display: flex; gap: 8px; align-items: center; justify-content: flex-end; padding-top: 4px; flex-wrap: wrap; }
|
#compose footer { display: flex; gap: 8px; align-items: center; justify-content: flex-end; padding-top: 4px; flex-wrap: wrap; }
|
||||||
#compose .anon { font-size: 13px; color: var(--muted); margin-right: auto; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
|
#compose .anon { font-size: 13px; color: var(--muted); margin-right: auto; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* A checkbox with a multi-line label, e.g. settings' "leave mail on server" —
|
||||||
|
the checkbox sits at the label's cap height rather than centered against
|
||||||
|
the full wrapped block, which is what made it look adrift. */
|
||||||
|
#settings label.check-row { flex-direction: row; align-items: flex-start; gap: 10px; flex: none; }
|
||||||
|
#settings label.check-row input { flex: none; margin: 3px 0 0; }
|
||||||
|
.check-row .check-title { display: block; color: var(--fg); font-size: 14px; }
|
||||||
|
.check-row .hint { display: block; margin: 2px 0 0; }
|
||||||
/* Without this every action read exactly like "cancel" and "close". */
|
/* Without this every action read exactly like "cancel" and "close". */
|
||||||
.filled { background: var(--accent); color: var(--on-accent); font-weight: 500; padding: 8px 20px; }
|
.filled { background: var(--accent); color: var(--on-accent); font-weight: 500; padding: 8px 20px; }
|
||||||
.filled:hover { background: var(--accent); box-shadow: var(--shadow); }
|
.filled:hover { background: var(--accent); box-shadow: var(--shadow); }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue