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
|
|
@ -36,7 +36,7 @@ class OpenedRecord {
|
|||
const OpenedRecord(this.id,
|
||||
{this.sender, this.time, this.fields = const {}, this.body = "", this.error});
|
||||
|
||||
String get subject => error == null ? (fields["Subject"] ?? "") : "";
|
||||
String get subject => error == null ? (fields["subject"] ?? "") : "";
|
||||
}
|
||||
|
||||
class SmolClient {
|
||||
|
|
@ -58,6 +58,8 @@ class SmolClient {
|
|||
|
||||
SmolIdentity? get identity => store.identity();
|
||||
|
||||
Uint8List? get master => store.master();
|
||||
|
||||
SmolAddress? accountAddress() {
|
||||
final account = store.account();
|
||||
if (account == null) return null;
|
||||
|
|
@ -91,25 +93,51 @@ class SmolClient {
|
|||
|
||||
// --- identity setup ------------------------------------------------------------
|
||||
|
||||
SmolIdentity createIdentity() {
|
||||
final fresh = newIdentity();
|
||||
store.setIdentity(fresh.seed);
|
||||
/// A fresh master secret at rotation index 0. Only this local step; nothing
|
||||
/// is sent until [registerAccount].
|
||||
Uint8List createIdentity() {
|
||||
final fresh = randomBytes(keyLen);
|
||||
store.setMaster(fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
SmolIdentity restoreIdentity(String seedHex) {
|
||||
Uint8List seed;
|
||||
/// §2: a master alone does not say which rotation index a server has bound,
|
||||
/// so restoring resolves the address and walks indices 0..[maxChain] until
|
||||
/// one derives the key RESOLVE returned. Also binds "account" locally, like
|
||||
/// [recallAccount] — restoring on a new device knows the identity but not
|
||||
/// the address it was registered under.
|
||||
Future<SmolAddress> restoreAndRecall(String masterHex, String addressText) async {
|
||||
Uint8List master;
|
||||
try {
|
||||
seed = unhex(seedHex.trim());
|
||||
master = unhex(masterHex.trim());
|
||||
} on Exception {
|
||||
throw const SmolError("seed must be 64 hex characters");
|
||||
throw const SmolError("master must be 64 hex characters");
|
||||
}
|
||||
if (seed.length != keyLen) {
|
||||
throw SmolError("seed is ${seed.length} bytes, expected $keyLen");
|
||||
if (master.length != keyLen) {
|
||||
throw SmolError("master is ${master.length} bytes, expected $keyLen");
|
||||
}
|
||||
final restored = identityFromSeed(seed);
|
||||
store.setIdentity(seed);
|
||||
return restored;
|
||||
final addr = parseAddress(addressText);
|
||||
final opened = await connect(addr, requirePin: true);
|
||||
Uint8List current;
|
||||
try {
|
||||
current = (await resolveOp(opened.session, addr.user)).identity;
|
||||
} finally {
|
||||
opened.session.wire.close();
|
||||
}
|
||||
int? found;
|
||||
for (var n = 0; n <= maxChain; n++) {
|
||||
if (timingSafeEqual(identityFromSeed(identitySeed(master, n)).publicKey, current)) {
|
||||
found = n;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found == null) {
|
||||
throw SmolError("the key bound to ${addr.short} is not derived from "
|
||||
"this master within $maxChain rotations");
|
||||
}
|
||||
store.restoreMaster(master, found);
|
||||
store.setAccount(addr);
|
||||
return addr;
|
||||
}
|
||||
|
||||
void pinServer(String host, String keyB32) {
|
||||
|
|
@ -126,7 +154,7 @@ class SmolClient {
|
|||
final addr = parseAddress(addressText);
|
||||
final opened = await connect(addr, requirePin: true);
|
||||
try {
|
||||
await registerOp(opened.session, addr.user, me,
|
||||
await registerOp(opened.session, opened.serverStatic, addr.user, me,
|
||||
RegisterOptions(token: token));
|
||||
} finally {
|
||||
opened.session.wire.close();
|
||||
|
|
@ -134,7 +162,7 @@ class SmolClient {
|
|||
store.setAccount(addr);
|
||||
}
|
||||
|
||||
/// Restoring a seed brings back the identity, not the memory of what
|
||||
/// Restoring a master brings back the identity, not the memory of what
|
||||
/// address a *different device* registered it under — "account" is
|
||||
/// local-only state, never asked of the server. This binds it without
|
||||
/// REGISTER: RESOLVE the address and require it name this exact key, so a
|
||||
|
|
@ -162,46 +190,178 @@ class SmolClient {
|
|||
|
||||
Future<FetchSummary> fetch() async {
|
||||
final me = identity;
|
||||
final master = this.master;
|
||||
final addr = accountAddress();
|
||||
if (me == null) throw const SmolError("no identity yet");
|
||||
if (me == null || master == null) throw const SmolError("no identity yet");
|
||||
if (addr == null) {
|
||||
throw const SmolError("not registered; register an address first");
|
||||
}
|
||||
var stored = 0;
|
||||
final rejected = <String>[];
|
||||
// §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 (store.storeIfNew also
|
||||
// dedupes, as a second line of defense).
|
||||
final leaveOnServer = store.leaveOnServer();
|
||||
var (afterTime, afterId) = store.cursor();
|
||||
final opened = await connect(addr, requirePin: true);
|
||||
try {
|
||||
await authenticate(opened.session, opened.handshakeHash, addr.user, me);
|
||||
final (sync, tokens) = store.tokenSet(master);
|
||||
await authenticate(opened.session, opened.handshakeHash, addr.user, me,
|
||||
sync: sync, tokens: tokens);
|
||||
while (true) {
|
||||
final records = await fetchOp(opened.session);
|
||||
final records = await fetchOp(opened.session, afterTime, afterId);
|
||||
if (records.isEmpty) break;
|
||||
final acked = <Uint8List>[];
|
||||
for (final record in records) {
|
||||
afterTime = record.receivedAt;
|
||||
afterId = record.id;
|
||||
OpenedMessage msg;
|
||||
try {
|
||||
if (!timingSafeEqual(messageId(record.envelope), record.id)) {
|
||||
throw const SmolError("id does not match the envelope");
|
||||
}
|
||||
unseal(store.identities(), record.envelope);
|
||||
msg = unseal(store.identities(), record.envelope);
|
||||
} on SmolError catch (err) {
|
||||
// Left on the server rather than destroyed, so a client-side bug
|
||||
// cannot lose mail.
|
||||
rejected.add("${hex(record.id)}: ${err.message}");
|
||||
continue;
|
||||
}
|
||||
final fresh = await store.storeIfNew("inbox", MailRecord(hex(record.id), record.envelope,
|
||||
receivedAt: record.receivedAt));
|
||||
if (fresh != null) stored++;
|
||||
final fresh = await store.storeIfNew(
|
||||
"inbox",
|
||||
MailRecord(hex(record.id), record.envelope,
|
||||
receivedAt: record.receivedAt,
|
||||
tier: record.isRequest ? tierRequests : tierMain,
|
||||
keptOnServer: leaveOnServer));
|
||||
if (fresh != null) {
|
||||
stored++;
|
||||
_learnToken(msg);
|
||||
}
|
||||
acked.add(record.id);
|
||||
}
|
||||
if (acked.isEmpty) break;
|
||||
await deleteOp(opened.session, acked);
|
||||
if (leaveOnServer) {
|
||||
// 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.isNotEmpty) {
|
||||
await deleteOp(opened.session, acked);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
opened.session.wire.close();
|
||||
}
|
||||
if (!leaveOnServer) {
|
||||
// Everything acknowledged is deleted, so the next fetch starts fresh; a
|
||||
// record left on the server (rejected above) simply resurfaces then.
|
||||
store.setCursor(0, Uint8List(idLen));
|
||||
}
|
||||
return FetchSummary(stored, rejected);
|
||||
}
|
||||
|
||||
// --- delete ------------------------------------------------------------------
|
||||
|
||||
/// 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 — §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.
|
||||
Future<void> deleteMessage(String folder, MailRecord record) async {
|
||||
if (folder != "sent" && record.keptOnServer) {
|
||||
final me = identity;
|
||||
final addr = accountAddress();
|
||||
if (me == null || addr == null) {
|
||||
throw const SmolError(
|
||||
"not registered; cannot reach the server to delete this message");
|
||||
}
|
||||
final opened = await connect(addr, requirePin: true);
|
||||
try {
|
||||
await authenticate(opened.session, opened.handshakeHash, addr.user, me,
|
||||
sync: 0, tokens: const []);
|
||||
await deleteOp(opened.session, [unhex(record.id)]);
|
||||
} finally {
|
||||
opened.session.wire.close();
|
||||
}
|
||||
}
|
||||
await store.deleteMessage(folder, record.id);
|
||||
}
|
||||
|
||||
// §5.8: an Accept field is bound to the signer of the message that carried
|
||||
// it, which unseal() has already verified.
|
||||
void _learnToken(OpenedMessage msg) {
|
||||
final parsed = parseFrontmatter(utf8.decode(msg.body, allowMalformed: true));
|
||||
final raw = parsed.fields["accept"];
|
||||
if (raw == null) return;
|
||||
Uint8List token;
|
||||
try {
|
||||
token = b32decode(raw);
|
||||
} on SmolError {
|
||||
return;
|
||||
}
|
||||
if (token.length != tokenLen) return;
|
||||
final address = _addressOfSigner(msg.sender, parsed.fields["reply-to"]);
|
||||
if (address == null) return; // no address to send to, so no use for a token
|
||||
store.learnToken(address, token);
|
||||
}
|
||||
|
||||
/// 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).
|
||||
String? _addressOfSigner(Uint8List sender, String? replyTo) {
|
||||
final known = store.addressForKey(sender);
|
||||
if (known != null) return known;
|
||||
if (replyTo == null) return null;
|
||||
try {
|
||||
final parsed = parseAddress(replyTo);
|
||||
if (parsed.identity != null && timingSafeEqual(parsed.identity!, sender)) {
|
||||
return parsed.short;
|
||||
}
|
||||
} on SmolError {
|
||||
// malformed claim: no address to learn a token under
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- accept tokens (§5.8) --------------------------------------------------------
|
||||
|
||||
/// Admit a contact to the main tier; their token travels in our next
|
||||
/// message to them. Pushes the change to the server right away, since an
|
||||
/// accept or a block only takes effect once it holds the changed set.
|
||||
Future<int> acceptContact(String address) async {
|
||||
final key = store.contact(address)?.key;
|
||||
if (key == null) throw SmolError("no key for $address yet");
|
||||
store.accept(address, key);
|
||||
store.setSyncOk(true);
|
||||
return _pushTokens();
|
||||
}
|
||||
|
||||
/// Withdraw a contact's accept token; their mail lands in requests from
|
||||
/// their next message on.
|
||||
Future<int> blockContact(String address) async {
|
||||
store.block(address);
|
||||
return _pushTokens();
|
||||
}
|
||||
|
||||
Future<int> _pushTokens() async {
|
||||
final me = identity;
|
||||
final master = this.master;
|
||||
final addr = accountAddress();
|
||||
if (me == null || master == null) throw const SmolError("no identity yet");
|
||||
if (addr == null) {
|
||||
_warn("not registered; the set will be pushed with your first fetch");
|
||||
return 0;
|
||||
}
|
||||
final opened = await connect(addr, requirePin: true);
|
||||
try {
|
||||
final (sync, tokens) = store.tokenSet(master);
|
||||
return await authenticate(opened.session, opened.handshakeHash, addr.user, me,
|
||||
sync: sync, tokens: tokens);
|
||||
} finally {
|
||||
opened.session.wire.close();
|
||||
}
|
||||
}
|
||||
|
||||
// --- compose and send -----------------------------------------------------------
|
||||
|
||||
// Prefer a key we already trust; fall back to RESOLVE with trust on first
|
||||
|
|
@ -227,7 +387,8 @@ class SmolClient {
|
|||
Future<String> send(String toText, String subject, String body,
|
||||
{String? replyTo, bool anonymous = false}) async {
|
||||
final me = identity;
|
||||
if (me == null) throw const SmolError("no identity yet");
|
||||
final master = this.master;
|
||||
if (me == null || master == null) throw const SmolError("no identity yet");
|
||||
final addr = parseAddress(toText);
|
||||
final recipient = await resolveRecipient(addr);
|
||||
final account = accountAddress();
|
||||
|
|
@ -237,12 +398,21 @@ class SmolClient {
|
|||
if (account != null && !anonymous) {
|
||||
fields["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.
|
||||
final accepted = store.accepted(addr.short);
|
||||
if (accepted != null && accepted.active) {
|
||||
fields["Accept"] = b32encode(tokenFor(master, accepted.identity));
|
||||
}
|
||||
final bodyBytes = utf8Bytes(buildFrontmatter(
|
||||
fields, "${body.replaceFirst(RegExp(r"\s+$"), "")}\n"));
|
||||
final envelope = seal(me, recipient, bodyBytes);
|
||||
// §5.8: our token for their mailbox, if they have given us one.
|
||||
final held = store.tokenFrom(addr.short);
|
||||
final mac = held == null ? null : acceptMac(held, messageId(envelope));
|
||||
final opened = await connect(addr, requirePin: false);
|
||||
try {
|
||||
await sendOp(opened.session, envelope);
|
||||
await sendOp(opened.session, envelope, mac: mac);
|
||||
} finally {
|
||||
opened.session.wire.close();
|
||||
}
|
||||
|
|
@ -293,7 +463,7 @@ class SmolClient {
|
|||
/// full smol:// URI whose key matches the signer (§5.7); anything else is
|
||||
/// ordinary text.
|
||||
SmolAddress? replyAddress(OpenedRecord opened) {
|
||||
final claim = opened.fields["Reply-To"];
|
||||
final claim = opened.fields["reply-to"];
|
||||
if (claim == null || opened.sender == null) return null;
|
||||
try {
|
||||
final parsed = parseAddress(claim);
|
||||
|
|
@ -370,7 +540,7 @@ class SmolClient {
|
|||
if (timingSafeEqual(known.key, resolved.identity)) {
|
||||
return RefreshOutcome("${addr.short}: key unchanged", false);
|
||||
}
|
||||
if (walkChain(known.key, resolved.identity, resolved.chain)) {
|
||||
if (walkChain(addr.user, known.key, resolved.identity, resolved.chain)) {
|
||||
store.saveContact(addr.short, resolved.identity, known.verified);
|
||||
return RefreshOutcome(
|
||||
"${addr.short} rotated its key; a signed chain confirms it.\n"
|
||||
|
|
@ -395,25 +565,27 @@ class SmolClient {
|
|||
|
||||
// --- rotation -----------------------------------------------------------------
|
||||
|
||||
// §7: rotate to a fresh seed and rebind the account with a signed
|
||||
// certificate. The old seed is kept by the store, since mail sealed to it
|
||||
// stays readable with nothing else.
|
||||
// §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.
|
||||
Future<SmolIdentity> rotateIdentity() async {
|
||||
final me = identity;
|
||||
final master = this.master;
|
||||
final addr = accountAddress();
|
||||
if (me == null || addr == null) {
|
||||
if (me == null || master == null || addr == null) {
|
||||
throw const SmolError("rotate needs a registered account");
|
||||
}
|
||||
final fresh = newIdentity();
|
||||
final cert = makeCert(me, fresh.seed);
|
||||
final freshSeed = identitySeed(master, store.rotations() + 1);
|
||||
final fresh = identityFromSeed(freshSeed);
|
||||
final cert = makeCert(addr.user, me, freshSeed);
|
||||
final opened = await connect(addr, requirePin: true);
|
||||
try {
|
||||
await registerOp(opened.session, addr.user, fresh,
|
||||
await registerOp(opened.session, opened.serverStatic, addr.user, fresh,
|
||||
RegisterOptions(cert: cert));
|
||||
} finally {
|
||||
opened.session.wire.close();
|
||||
}
|
||||
store.rotateIdentity(fresh.seed);
|
||||
store.advanceRotation();
|
||||
_openedCache.clear();
|
||||
return fresh;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Device state: identity, pins, contacts and read markers in one JSON blob;
|
||||
// sealed envelopes in a second Hive box, opened only on demand, so nothing at
|
||||
// rest is plaintext (the seed excepted — the device's app storage is the trust
|
||||
// boundary, like gsmol's browser profile).
|
||||
// rest is plaintext (the master secret excepted — the device's app storage is
|
||||
// the trust boundary, like gsmol's browser profile).
|
||||
|
||||
import "dart:convert";
|
||||
import "dart:typed_data";
|
||||
|
|
@ -42,14 +42,36 @@ class StoredContact {
|
|||
}
|
||||
|
||||
class MailRecord {
|
||||
final String id; // hex of the 16-byte message id
|
||||
final String id; // hex of the 32-byte message id
|
||||
final Uint8List envelope;
|
||||
final int? receivedAt;
|
||||
final String? recipient; // sent copies only
|
||||
final int? sentAt;
|
||||
final int tier; // §5.8: tierMain or tierRequests; meaningless for sent copies
|
||||
|
||||
/// Whether "leave mail on server" was on when this was fetched, so a
|
||||
/// manual delete still has a server-side copy to remove. Always false for
|
||||
/// sent copies, which never had one (§5.6).
|
||||
final bool keptOnServer;
|
||||
|
||||
const MailRecord(this.id, this.envelope,
|
||||
{this.receivedAt, this.recipient, this.sentAt});
|
||||
{this.receivedAt,
|
||||
this.recipient,
|
||||
this.sentAt,
|
||||
this.tier = tierMain,
|
||||
this.keptOnServer = false});
|
||||
}
|
||||
|
||||
const tierMain = 0, tierRequests = 1;
|
||||
|
||||
/// A correspondent admitted to this mailbox's main tier (§5.8). The identity
|
||||
/// is frozen at acceptance because the token is derived from it: a contact's
|
||||
/// later rotation must not change the token they already hold.
|
||||
class AcceptedContact {
|
||||
final Uint8List identity;
|
||||
final bool active;
|
||||
|
||||
const AcceptedContact(this.identity, this.active);
|
||||
}
|
||||
|
||||
class ImportSummary {
|
||||
|
|
@ -87,49 +109,66 @@ class SmolStore {
|
|||
_state.put(_stateKey, next);
|
||||
}
|
||||
|
||||
// --- identity --------------------------------------------------------------
|
||||
// --- identity (§2) -----------------------------------------------------------
|
||||
|
||||
Uint8List? seed() {
|
||||
final raw = _load()["seed"];
|
||||
/// The 32-byte master secret, or null before the user creates or restores
|
||||
/// one. Every signing key is derived from it plus the rotation index.
|
||||
Uint8List? master() {
|
||||
final raw = _load()["master"];
|
||||
return raw == null ? null : unhex(raw as String);
|
||||
}
|
||||
|
||||
/// The rotation index (§7) of the identity currently in use.
|
||||
int rotations() => (_load()["rotations"] as int?) ?? 0;
|
||||
|
||||
/// The active identity, or null before the user creates or restores one.
|
||||
SmolIdentity? identity() {
|
||||
final s = seed();
|
||||
return s == null ? null : identityFromSeed(s);
|
||||
final m = master();
|
||||
return m == null ? null : identityFromSeed(identitySeed(m, rotations()));
|
||||
}
|
||||
|
||||
void setIdentity(Uint8List newSeed) {
|
||||
if (seed() != null) {
|
||||
throw const SmolIdentityExistsException();
|
||||
/// 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).
|
||||
bool syncOk() => (_load()["syncOk"] as bool?) ?? true;
|
||||
|
||||
void setSyncOk(bool ok) => _update((state) => state..["syncOk"] = ok);
|
||||
|
||||
void _bindMaster(Uint8List newMaster, int rotations, bool syncOk) {
|
||||
if (master() != null) throw const SmolIdentityExistsException();
|
||||
_update((state) => state
|
||||
..["master"] = hex(newMaster)
|
||||
..["rotations"] = rotations
|
||||
..["syncOk"] = syncOk);
|
||||
setCursor(0, Uint8List(idLen));
|
||||
}
|
||||
|
||||
/// A fresh identity: rotation index 0, and an empty accepted set is
|
||||
/// already complete, so it may sync.
|
||||
void setMaster(Uint8List 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.
|
||||
void restoreMaster(Uint8List newMaster, int rotationIndex) =>
|
||||
_bindMaster(newMaster, rotationIndex, false);
|
||||
|
||||
// Rotation (§7): only the index advances; the superseded key stays
|
||||
// derivable from the master, so nothing has to be archived.
|
||||
void advanceRotation() {
|
||||
final current = rotations();
|
||||
if (master() == null) throw const SmolNoIdentityException();
|
||||
if (current >= maxChain) {
|
||||
throw SmolError("the rotation chain is full at $maxChain links");
|
||||
}
|
||||
_update((state) => state..["seed"] = hex(newSeed));
|
||||
_update((state) => state..["rotations"] = current + 1);
|
||||
}
|
||||
|
||||
// Rotation (§7): the old seed is retained, since mail sealed to a
|
||||
// superseded key is readable with nothing else.
|
||||
void rotateIdentity(Uint8List newSeed) {
|
||||
final old = seed();
|
||||
if (old == null) throw const SmolNoIdentityException();
|
||||
_update((state) {
|
||||
final retired = (state["retired"] as List? ?? [])
|
||||
..add({"seed": hex(old), "at": DateTime.now().millisecondsSinceEpoch});
|
||||
state["retired"] = retired;
|
||||
state["seed"] = hex(newSeed);
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
/// §7: seeds rotated away from are retained, since mail sealed to a
|
||||
/// superseded key is readable with nothing else.
|
||||
/// §7: every key rotated away from is re-derivable from the master, since
|
||||
/// mail sealed to a superseded key is readable with nothing else.
|
||||
List<SmolIdentity> identities() {
|
||||
final s = seed();
|
||||
if (s == null) return const [];
|
||||
final retired = (_load()["retired"] as List? ?? const [])
|
||||
.whereType<Map>()
|
||||
.map((entry) => identityFromSeed(unhex(entry["seed"] as String)));
|
||||
return [identityFromSeed(s), ...retired];
|
||||
final m = master();
|
||||
if (m == null) return const [];
|
||||
return [for (var n = rotations(); n >= 0; n--) identityFromSeed(identitySeed(m, n))];
|
||||
}
|
||||
|
||||
// --- account and server pins -------------------------------------------------
|
||||
|
|
@ -176,6 +215,31 @@ class SmolStore {
|
|||
return [for (final e in servers.entries) (e.key, b32decode(e.value))];
|
||||
}
|
||||
|
||||
// --- FETCH behavior --------------------------------------------------------
|
||||
|
||||
/// 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.
|
||||
bool leaveOnServer() => (_load()["leaveOnServer"] as bool?) ?? false;
|
||||
|
||||
void setLeaveOnServer(bool value) =>
|
||||
_update((state) => state..["leaveOnServer"] = value);
|
||||
|
||||
// --- FETCH cursor (§6.1) -------------------------------------------------------
|
||||
|
||||
(int, Uint8List) cursor() {
|
||||
final state = _load();
|
||||
final afterId = state["afterId"] as String?;
|
||||
return (
|
||||
(state["afterTime"] as int?) ?? 0,
|
||||
afterId == null ? Uint8List(idLen) : unhex(afterId),
|
||||
);
|
||||
}
|
||||
|
||||
void setCursor(int afterTime, Uint8List afterId) => _update((state) => state
|
||||
..["afterTime"] = afterTime
|
||||
..["afterId"] = hex(afterId));
|
||||
|
||||
// --- contacts ------------------------------------------------------------------
|
||||
|
||||
StoredContact? contact(String address) {
|
||||
|
|
@ -233,6 +297,82 @@ class SmolStore {
|
|||
return [for (final entry in contacts.entries) (entry.key, contact(entry.key)!)];
|
||||
}
|
||||
|
||||
// --- accept tokens (§5.8) --------------------------------------------------------
|
||||
|
||||
AcceptedContact? accepted(String address) {
|
||||
final a = ((_load()["accepted"] as Map?) ?? {})[address];
|
||||
if (a is! Map) return null;
|
||||
return AcceptedContact(b32decode(a["identity"] as String), a["active"] as bool);
|
||||
}
|
||||
|
||||
/// Admit a contact to the main tier. The identity is frozen at acceptance —
|
||||
/// re-accepting after a block must not change which key the token is
|
||||
/// derived from (§5.8).
|
||||
void accept(String address, Uint8List identity) {
|
||||
_update((state) {
|
||||
final accepted = (state["accepted"] as Map? ?? {}).cast<String, Map>();
|
||||
final previous = accepted[address];
|
||||
accepted[address] = {
|
||||
"identity": previous?["identity"] ?? b32encode(identity),
|
||||
"active": true,
|
||||
"addedAt": previous?["addedAt"] ?? DateTime.now().millisecondsSinceEpoch,
|
||||
};
|
||||
state["accepted"] = accepted;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
/// 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.
|
||||
void block(String address) {
|
||||
final accepted = (_load()["accepted"] as Map? ?? {}).cast<String, Map>();
|
||||
if (!accepted.containsKey(address)) {
|
||||
throw SmolError("$address was never accepted");
|
||||
}
|
||||
_update((state) {
|
||||
final accepted = (state["accepted"] as Map? ?? {}).cast<String, Map>();
|
||||
accepted[address] = {...accepted[address]!, "active": false};
|
||||
state["accepted"] = accepted;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
List<(String, AcceptedContact)> allAccepted() {
|
||||
final accepted = ((_load()["accepted"] as Map?) ?? {}).cast<String, Map>();
|
||||
return [for (final e in accepted.entries) (e.key, this.accepted(e.key)!)];
|
||||
}
|
||||
|
||||
/// §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.
|
||||
(int, List<Uint8List>) tokenSet(Uint8List master) {
|
||||
if (!syncOk()) return (0, const []);
|
||||
final active = allAccepted().where((e) => e.$2.active).toList()
|
||||
..sort((a, b) => a.$1.compareTo(b.$1));
|
||||
return (1, [for (final e in active) tokenFor(master, e.$2.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).
|
||||
Uint8List? tokenFrom(String address) {
|
||||
final raw = ((_load()["tokens"] as Map?) ?? {})[address];
|
||||
if (raw is! Map) return null;
|
||||
return b32decode(raw["token"] as String);
|
||||
}
|
||||
|
||||
void learnToken(String address, Uint8List token) {
|
||||
_update((state) {
|
||||
final tokens = (state["tokens"] as Map? ?? {}).cast<String, Map>();
|
||||
tokens[address] = {
|
||||
"token": b32encode(token),
|
||||
"seenAt": DateTime.now().millisecondsSinceEpoch,
|
||||
};
|
||||
state["tokens"] = tokens;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
// --- read markers ---------------------------------------------------------------
|
||||
|
||||
void markRead(String idHex) {
|
||||
|
|
@ -255,6 +395,8 @@ class SmolStore {
|
|||
"receivedAt": record.receivedAt,
|
||||
"recipient": record.recipient,
|
||||
"sentAt": record.sentAt,
|
||||
"tier": record.tier,
|
||||
"keptOnServer": record.keptOnServer,
|
||||
};
|
||||
|
||||
MailRecord _mapToRecord(Map map) => MailRecord(
|
||||
|
|
@ -263,10 +405,18 @@ class SmolStore {
|
|||
receivedAt: map["receivedAt"] as int?,
|
||||
recipient: map["recipient"] as String?,
|
||||
sentAt: map["sentAt"] as int?,
|
||||
tier: (map["tier"] as int?) ?? tierMain,
|
||||
keptOnServer: (map["keptOnServer"] as bool?) ?? false,
|
||||
);
|
||||
|
||||
static String mailKey(String folder, String id) => "$folder/$id";
|
||||
|
||||
// "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.
|
||||
static String _physicalFolder(String folder) =>
|
||||
folder == "requests" ? "inbox" : folder;
|
||||
|
||||
Future<void> storeMessage(String folder, MailRecord record) =>
|
||||
_mail.put(mailKey(folder, record.id), _recordToMap(record));
|
||||
|
||||
|
|
@ -279,12 +429,17 @@ class SmolStore {
|
|||
}
|
||||
|
||||
List<MailRecord> listMessages(String folder) {
|
||||
final prefix = "$folder/";
|
||||
final physical = _physicalFolder(folder);
|
||||
final prefix = "$physical/";
|
||||
final wantTier = folder == "requests" ? tierRequests : tierMain;
|
||||
final rows = <MailRecord>[];
|
||||
for (final key in _mail.keys.cast<String>()) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
final row = _mail.get(key);
|
||||
if (row is Map) rows.add(_mapToRecord(row));
|
||||
if (row is! Map) continue;
|
||||
final record = _mapToRecord(row);
|
||||
if (physical == "inbox" && record.tier != wantTier) continue;
|
||||
rows.add(record);
|
||||
}
|
||||
rows.sort((a, b) =>
|
||||
(b.receivedAt ?? b.sentAt ?? 0).compareTo(a.receivedAt ?? a.sentAt ?? 0));
|
||||
|
|
@ -292,29 +447,29 @@ class SmolStore {
|
|||
}
|
||||
|
||||
MailRecord? getMessage(String folder, String id) {
|
||||
final row = _mail.get(mailKey(folder, id));
|
||||
final row = _mail.get(mailKey(_physicalFolder(folder), id));
|
||||
return row is Map ? _mapToRecord(row) : null;
|
||||
}
|
||||
|
||||
Future<void> deleteMessage(String folder, String id) =>
|
||||
_mail.delete(mailKey(folder, id));
|
||||
_mail.delete(mailKey(_physicalFolder(folder), id));
|
||||
|
||||
// --- export / import: mail, contacts, pins — never the seed --------------------
|
||||
|
||||
/// Label kept as gsmol wrote it originally; the export format version
|
||||
/// (gsmolExport) is what actually changed between v1 and v2.
|
||||
static final _exportLabel = utf8Bytes("gsmol/1 export");
|
||||
Uint8List _exportKey(Uint8List seed) =>
|
||||
hkdfSha256(seed, Uint8List(0), _exportLabel, 32);
|
||||
Uint8List _exportKey(Uint8List master) =>
|
||||
hkdfSha256(master, Uint8List(0), _exportLabel, 32);
|
||||
|
||||
/// v2 matches gsmol's own current export: the whole payload — mail,
|
||||
/// contacts, pins — is sealed to a key derived from the identity's seed, so
|
||||
/// a backup file is only readable by whoever holds that seed. Deliberately
|
||||
/// excludes the seed itself: it has its own reveal-and-copy flow in
|
||||
/// settings, meant for a password manager, not a shareable file.
|
||||
/// contacts, pins — is sealed to a key derived from the identity's master,
|
||||
/// so a backup file is only readable by whoever holds that master.
|
||||
/// Deliberately excludes the master itself: it has its own reveal-and-copy
|
||||
/// flow in settings, meant for a password manager, not a shareable file.
|
||||
Map<String, dynamic> exportData() {
|
||||
final seed = this.seed();
|
||||
if (seed == null) throw const SmolError("no identity yet");
|
||||
final master = this.master();
|
||||
if (master == null) throw const SmolError("no identity yet");
|
||||
final state = _load();
|
||||
final contacts = ((state["contacts"] as Map?) ?? {}).cast<String, Map>();
|
||||
final payload = {
|
||||
|
|
@ -329,11 +484,13 @@ class SmolStore {
|
|||
}
|
||||
},
|
||||
"inbox": [
|
||||
for (final row in listMessages("inbox"))
|
||||
for (final row in [...listMessages("inbox"), ...listMessages("requests")])
|
||||
{
|
||||
"id": row.id,
|
||||
"receivedAt": row.receivedAt,
|
||||
"envelope": base64Encode(row.envelope),
|
||||
"tier": row.tier,
|
||||
"keptOnServer": row.keptOnServer,
|
||||
}
|
||||
],
|
||||
"sent": [
|
||||
|
|
@ -348,7 +505,7 @@ class SmolStore {
|
|||
};
|
||||
final nonce = randomBytes(12);
|
||||
final ciphertext = aeadEncrypt(
|
||||
_exportKey(seed), nonce, utf8Bytes(jsonEncode(payload)), Uint8List(0));
|
||||
_exportKey(master), nonce, utf8Bytes(jsonEncode(payload)), Uint8List(0));
|
||||
return {
|
||||
"gsmolExport": 2,
|
||||
"exportedAt": DateTime.now().millisecondsSinceEpoch,
|
||||
|
|
@ -364,13 +521,13 @@ class SmolStore {
|
|||
Future<ImportSummary> importData(Map data) async {
|
||||
Map payload;
|
||||
if (data["gsmolExport"] == 2) {
|
||||
final seed = this.seed();
|
||||
if (seed == null) {
|
||||
final master = this.master();
|
||||
if (master == null) {
|
||||
throw const SmolError("no identity yet — restore it before importing");
|
||||
}
|
||||
try {
|
||||
final plaintext = aeadDecrypt(
|
||||
_exportKey(seed),
|
||||
_exportKey(master),
|
||||
base64Decode(data["nonce"] as String),
|
||||
base64Decode(data["ciphertext"] as String),
|
||||
Uint8List(0),
|
||||
|
|
@ -447,6 +604,8 @@ class SmolStore {
|
|||
receivedAt: folder == "inbox" ? map["receivedAt"] as int? : null,
|
||||
recipient: folder == "sent" ? map["recipient"] as String? : null,
|
||||
sentAt: folder == "sent" ? map["sentAt"] as int? : null,
|
||||
tier: (map["tier"] as int?) ?? tierMain,
|
||||
keptOnServer: (map["keptOnServer"] as bool?) ?? false,
|
||||
);
|
||||
if (await storeIfNew(folder, record) != null) summary.mailAdded++;
|
||||
} on Exception {
|
||||
|
|
@ -481,6 +640,14 @@ class SmolStore {
|
|||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
int requestsUnreadCount() {
|
||||
var count = 0;
|
||||
for (final row in listMessages("requests")) {
|
||||
if (!isRead(row.id)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
/// The store throws these typed errors so the UI can tell "no identity yet"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue