feat: adopt smolmail protocol 1.1, adaptive nav shell, and server mail retention
This commit is contained in:
parent
eaaa3f2ede
commit
c693e6fcb9
27 changed files with 1190 additions and 592 deletions
|
|
@ -1,6 +1,6 @@
|
|||
// Smol Mail protocol, version 1 (../smolmail SPEC.md): addresses, sealed and
|
||||
// signed envelopes, body frontmatter, key rotation, and the framed request
|
||||
// and response bodies of the five operations.
|
||||
// Smol Mail protocol, version 1.1 (../smolmail SPEC.md): addresses, sealed
|
||||
// and signed envelopes, body frontmatter, key rotation, accept tokens, and
|
||||
// the framed request and response bodies of the five operations.
|
||||
|
||||
import "dart:math";
|
||||
import "dart:typed_data";
|
||||
|
|
@ -10,9 +10,11 @@ import "package:smol_mail/smol/errors.dart";
|
|||
import "package:smol_mail/smol/noise.dart";
|
||||
|
||||
const defaultPort = 1961;
|
||||
const keyLen = 32, sigLen = 64, certLen = 136, idLen = 16;
|
||||
const keyLen = 32, sigLen = 64, certLen = 200, idLen = 32, tokenLen = 32;
|
||||
const maxFrame = 1 << 20, noisePayload = 65535 - 16, padTo = 1024;
|
||||
const envelopeHeader = 69, payloadHeader = 45, maxChain = 16;
|
||||
const maxSkew = 86400; // §5.3: how far ahead of our clock a payload may be dated
|
||||
const flagRequests = 0x01; // §6.1: set when a FETCH record missed an accept token
|
||||
const _frontmatterMax = 4096, _frontmatterKeys = 64;
|
||||
|
||||
const opAuth = 0x00, opResolve = 0x01, opSend = 0x02, opFetch = 0x03,
|
||||
|
|
@ -32,6 +34,10 @@ final _label = (
|
|||
msg: utf8Bytes("smolmail/1 msg"),
|
||||
id: utf8Bytes("smolmail/1 id"),
|
||||
rotate: utf8Bytes("smolmail/1 rotate"),
|
||||
identity: utf8Bytes("smolmail/1 identity"),
|
||||
accept: utf8Bytes("smolmail/1 accept"),
|
||||
mac: utf8Bytes("smolmail/1 mac"),
|
||||
register: utf8Bytes("smolmail/1 register"),
|
||||
);
|
||||
|
||||
// --- encoding helpers ---------------------------------------------------------
|
||||
|
|
@ -139,11 +145,44 @@ SmolIdentity identityFromSeed(Uint8List seed) {
|
|||
|
||||
SmolIdentity newIdentity() => identityFromSeed(randomBytes(keyLen));
|
||||
|
||||
// §2: the only secret a user holds. Everything else — every rotation index's
|
||||
// signing seed, and the accept key — is derived from it with HKDF.
|
||||
Uint8List identitySeed(Uint8List master, int index) =>
|
||||
hkdfSha256(master, Uint8List(0), concat([_label.identity, u32be(index)]));
|
||||
|
||||
Uint8List acceptKeyFor(Uint8List master) =>
|
||||
hkdfSha256(master, Uint8List(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.
|
||||
Uint8List tokenFor(Uint8List master, Uint8List correspondentIdentity) =>
|
||||
hmacSha256(acceptKeyFor(master), correspondentIdentity);
|
||||
|
||||
// §5.8: what a sender attaches to SEND to reach the recipient's main tier.
|
||||
Uint8List acceptMac(Uint8List token, Uint8List id) =>
|
||||
hmacSha256(token, concat([_label.mac, id]));
|
||||
|
||||
// --- addressing (§3) -----------------------------------------------------------
|
||||
|
||||
final _address =
|
||||
RegExp(r"^(?<user>[a-z0-9._-]{1,63})@(?<host>[^/:]+)(?::(?<port>\d+))?$");
|
||||
|
||||
const _separators = "._-";
|
||||
|
||||
// §3: alphanumeric at both ends, never two separators in a row.
|
||||
bool validUsername(String name) {
|
||||
if (name.isEmpty) return false;
|
||||
if (_separators.contains(name[0]) || _separators.contains(name[name.length - 1])) {
|
||||
return false;
|
||||
}
|
||||
for (var i = 0; i < name.length - 1; i++) {
|
||||
if (_separators.contains(name[i]) && _separators.contains(name[i + 1])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
class SmolAddress {
|
||||
final String user;
|
||||
final String host;
|
||||
|
|
@ -179,8 +218,9 @@ SmolAddress parseAddress(String text) {
|
|||
if (m == null) throw SmolError("'$text' is not a valid address");
|
||||
final user = m.namedGroup("user")!;
|
||||
final host = m.namedGroup("host")!;
|
||||
if ("._-".contains(user[0]) || "._-".contains(user[user.length - 1])) {
|
||||
throw SmolError("$user may not begin or end with a separator");
|
||||
if (!validUsername(user)) {
|
||||
throw SmolError("$user must begin and end with a letter or digit "
|
||||
"and may not contain two separators in a row");
|
||||
}
|
||||
final portText = m.namedGroup("port");
|
||||
final port = portText != null ? int.parse(portText) : defaultPort;
|
||||
|
|
@ -189,8 +229,9 @@ SmolAddress parseAddress(String text) {
|
|||
|
||||
// --- message format (§5) -------------------------------------------------------
|
||||
|
||||
Uint8List messageId(List<int> envelope) =>
|
||||
sha256(concat([_label.id, envelope])).sublist(0, idLen);
|
||||
// §5.4: derived from the envelope so no sender can choose it; used whole,
|
||||
// nothing truncates it.
|
||||
Uint8List messageId(List<int> envelope) => sha256(concat([_label.id, envelope]));
|
||||
|
||||
class OpenedMessage {
|
||||
final Uint8List sender;
|
||||
|
|
@ -288,6 +329,9 @@ OpenedMessage unseal(List<SmolIdentity> identities, Uint8List envelope) {
|
|||
signature)) {
|
||||
throw const SmolError("signature does not verify");
|
||||
}
|
||||
if (when > nowSeconds() + maxSkew) {
|
||||
throw const SmolError("payload is dated in the future");
|
||||
}
|
||||
return OpenedMessage(sender, when, body, messageId(envelope));
|
||||
}
|
||||
|
||||
|
|
@ -304,7 +348,8 @@ class Frontmatter {
|
|||
|
||||
// A flat `Key: value` block, deliberately not YAML. Any malformed line
|
||||
// invalidates the whole block, which is then returned as ordinary body text:
|
||||
// frontmatter fails closed toward display, never toward silent discard.
|
||||
// frontmatter fails closed toward display, never toward silent discard. Keys
|
||||
// are compared case-insensitively (§5.5), so they are kept lowercased.
|
||||
Frontmatter parseFrontmatter(String text) {
|
||||
if (!text.startsWith("---\n")) return Frontmatter(const {}, text);
|
||||
final lines = text.split("\n");
|
||||
|
|
@ -327,7 +372,7 @@ Frontmatter parseFrontmatter(String text) {
|
|||
return Frontmatter(const {}, text);
|
||||
}
|
||||
// first occurrence wins
|
||||
fields.putIfAbsent(head, () => line.substring(colon + 1).trim());
|
||||
fields.putIfAbsent(head.toLowerCase(), () => line.substring(colon + 1).trim());
|
||||
}
|
||||
return Frontmatter(fields, rest);
|
||||
}
|
||||
|
|
@ -343,35 +388,45 @@ String buildFrontmatter(Map<String, String> fields, String body) {
|
|||
|
||||
// --- key rotation (§7) ---------------------------------------------------------
|
||||
|
||||
Uint8List makeCert(SmolIdentity oldIdentity, Uint8List newSeed, [int? when]) {
|
||||
final newPub = ed25519PublicKey(newSeed);
|
||||
// §7: old_pub 32 || new_pub 32 || time 8 || sig_old 64 || sig_new 64. Both
|
||||
// keys sign, so the old key alone cannot hand the username to a key nobody
|
||||
// controls; the username is covered but not carried, so a verifier always
|
||||
// supplies the one it is checking.
|
||||
Uint8List makeCert(
|
||||
String username, SmolIdentity oldIdentity, Uint8List newSeed, [int? when]) {
|
||||
final newIdentity = identityFromSeed(newSeed);
|
||||
final time = i64be(BigInt.from(when ?? nowSeconds()));
|
||||
final signed = concat(
|
||||
[_label.rotate, utf8Bytes(username), oldIdentity.publicKey, newIdentity.publicKey, time]);
|
||||
return concat([
|
||||
oldIdentity.publicKey,
|
||||
newPub,
|
||||
newIdentity.publicKey,
|
||||
time,
|
||||
ed25519Sign(oldIdentity.seed,
|
||||
concat([_label.rotate, oldIdentity.publicKey, newPub, time])),
|
||||
ed25519Sign(oldIdentity.seed, signed),
|
||||
ed25519Sign(newIdentity.seed, signed),
|
||||
]);
|
||||
}
|
||||
|
||||
// Accept a key change only when a signed chain leads from the key we hold to
|
||||
// the one the server now returns (§7).
|
||||
bool walkChain(Uint8List pinned, Uint8List current, List<Uint8List> chain) {
|
||||
// the one the server now returns, both keys signing each link (§7).
|
||||
bool walkChain(
|
||||
String username, Uint8List pinned, Uint8List current, List<Uint8List> chain) {
|
||||
if (timingSafeEqual(pinned, current)) return true;
|
||||
if (chain.isEmpty || chain.length > maxChain) return false;
|
||||
var key = pinned;
|
||||
var started = false;
|
||||
for (final cert in chain) {
|
||||
final old = cert.sublist(0, 32), next = cert.sublist(32, 64);
|
||||
final when = cert.sublist(64, 72), sig = cert.sublist(72);
|
||||
final when = cert.sublist(64, 72);
|
||||
final sigOld = cert.sublist(72, 136), sigNew = cert.sublist(136, 200);
|
||||
if (!started) {
|
||||
if (!timingSafeEqual(old, key)) continue; // a link predating the key we hold
|
||||
started = true;
|
||||
} else if (!timingSafeEqual(old, key)) {
|
||||
return false; // the chain is not continuous
|
||||
}
|
||||
if (!ed25519Verify(old, concat([_label.rotate, old, next, when]), sig)) {
|
||||
final signed = concat([_label.rotate, utf8Bytes(username), old, next, when]);
|
||||
if (!ed25519Verify(old, signed, sigOld) || !ed25519Verify(next, signed, sigNew)) {
|
||||
return false;
|
||||
}
|
||||
key = next;
|
||||
|
|
@ -482,18 +537,27 @@ void expectOk(int status, String what) {
|
|||
}
|
||||
|
||||
// §4 session authentication: sign the handshake hash, which binds the
|
||||
// signature to this session's server ephemeral and cannot be replayed.
|
||||
Future<void> authenticate(
|
||||
Session session, Uint8List handshakeHash, String username, SmolIdentity identity) async {
|
||||
// signature to this session's server ephemeral and cannot be replayed, and
|
||||
// push the accept token set (§5.8). `sync = 0` leaves the server's stored set
|
||||
// untouched and `tokens` MUST then be empty; `sync = 1` replaces it exactly.
|
||||
// Returns the number of accept tokens the server now holds.
|
||||
Future<int> authenticate(Session session, Uint8List handshakeHash, String username,
|
||||
SmolIdentity identity, {required int sync, List<Uint8List> tokens = const []}) async {
|
||||
final name = utf8Bytes(username);
|
||||
if (name.length > 255) throw const SmolError("username too long");
|
||||
if (tokens.length > 0xffff) throw const SmolError("too many accept tokens for one AUTH");
|
||||
final body = concat([
|
||||
Uint8List.fromList([name.length]),
|
||||
name,
|
||||
identity.publicKey,
|
||||
ed25519Sign(identity.seed, concat([_label.auth, handshakeHash])),
|
||||
Uint8List.fromList([sync]),
|
||||
u16be(tokens.length),
|
||||
...tokens,
|
||||
]);
|
||||
expectOk((await session.call(opAuth, body)).status, "authentication");
|
||||
final response = await session.call(opAuth, body);
|
||||
expectOk(response.status, "authentication");
|
||||
return Reader(response.body).u16();
|
||||
}
|
||||
|
||||
class Resolved {
|
||||
|
|
@ -516,8 +580,14 @@ Future<Resolved> resolveOp(Session session, String user) async {
|
|||
List.generate(r.u8(), (_) => r.take(certLen)));
|
||||
}
|
||||
|
||||
Future<Uint8List> sendOp(Session session, Uint8List envelope) async {
|
||||
final response = await session.call(opSend, envelope);
|
||||
// §5.8: [mac] is the sender's proof of an accept token, 0 or 32 bytes.
|
||||
Future<Uint8List> sendOp(Session session, Uint8List envelope, {Uint8List? mac}) async {
|
||||
final macBytes = mac ?? Uint8List(0);
|
||||
if (macBytes.isNotEmpty && macBytes.length != tokenLen) {
|
||||
throw const SmolError("accept MAC must be $tokenLen bytes");
|
||||
}
|
||||
final body = concat([Uint8List.fromList([macBytes.length]), macBytes, envelope]);
|
||||
final response = await session.call(opSend, body);
|
||||
expectOk(response.status, "sending");
|
||||
return response.body.length == idLen
|
||||
? response.body
|
||||
|
|
@ -527,19 +597,27 @@ Future<Uint8List> sendOp(Session session, Uint8List envelope) async {
|
|||
class FetchedRecord {
|
||||
final Uint8List id;
|
||||
final int receivedAt;
|
||||
final int flags;
|
||||
final Uint8List envelope;
|
||||
|
||||
const FetchedRecord(this.id, this.receivedAt, this.envelope);
|
||||
const FetchedRecord(this.id, this.receivedAt, this.flags, this.envelope);
|
||||
|
||||
// §6.1: bit 0 is set when the message arrived without a matching accept token.
|
||||
bool get isRequest => flags & flagRequests != 0;
|
||||
}
|
||||
|
||||
Future<List<FetchedRecord>> fetchOp(Session session) async {
|
||||
final response = await session.call(opFetch);
|
||||
// §6.1: pages forward from a cursor; an all-zero id starts at the beginning.
|
||||
Future<List<FetchedRecord>> fetchOp(
|
||||
Session session, int afterReceivedAt, Uint8List afterId) async {
|
||||
final body = concat([i64be(BigInt.from(afterReceivedAt)), afterId]);
|
||||
final response = await session.call(opFetch, body);
|
||||
expectOk(response.status, "fetching");
|
||||
final r = Reader(response.body);
|
||||
return List.generate(r.u16(), (_) {
|
||||
final id = r.take(idLen);
|
||||
final receivedAt = r.i64();
|
||||
return FetchedRecord(id, receivedAt, r.take(r.u32()));
|
||||
final flags = r.u8();
|
||||
return FetchedRecord(id, receivedAt, flags, r.take(r.u32()));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -558,9 +636,13 @@ class RegisterOptions {
|
|||
const RegisterOptions({this.token = "", this.cert});
|
||||
}
|
||||
|
||||
Future<void> registerOp(
|
||||
Session session, String username, SmolIdentity identity,
|
||||
[RegisterOptions opts = const RegisterOptions()]) async {
|
||||
// §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.
|
||||
Uint8List registerSigned(Uint8List serverStatic, String username, Uint8List identity) =>
|
||||
concat([_label.register, serverStatic, utf8Bytes(username), identity]);
|
||||
|
||||
Future<void> registerOp(Session session, Uint8List serverStatic, String username,
|
||||
SmolIdentity identity, [RegisterOptions opts = const RegisterOptions()]) async {
|
||||
final name = utf8Bytes(username);
|
||||
final tokenBytes = utf8Bytes(opts.token);
|
||||
final cert = opts.cert ?? Uint8List(0);
|
||||
|
|
@ -571,6 +653,7 @@ Future<void> registerOp(
|
|||
Uint8List.fromList([name.length]),
|
||||
name,
|
||||
identity.publicKey,
|
||||
ed25519Sign(identity.seed, registerSigned(serverStatic, username, identity.publicKey)),
|
||||
Uint8List.fromList([tokenBytes.length]),
|
||||
tokenBytes,
|
||||
Uint8List.fromList([cert.length]),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue