Give an account an identity no server issued
A relay's `user_id` is a row number it assigned itself, and the account service's pseudonym is HMAC'd per product precisely so two services cannot correlate their users. Neither survives a trip to a second relay, so nothing addressed by either can cross a relay boundary — which is the first thing federation needs, and the reason a deleted relay currently takes its users' names with it. So identity becomes a key the client derives and holds: Ed25519, stretched from the passphrase under `flamenet/account/identity/v1`, alongside the per-product keys that already work this way. The public half, Crockford base32, is the address. Derived rather than generated, so the same passphrase reproduces the same fnid on a device that never held it — re-register the same key at a new relay and contacts who pinned it are still talking to the same person. Relay: `users.account_key` / `users.fnid` (unique in the database, not just in the handler), `POST /e2e/account/identity` to claim by proving possession, and `GET /e2e/account/identity/:handle` to resolve. The claim signature binds the audience, the local user id and the key itself; dropping any one of the three lets a claim be replayed at another relay, lifted onto another account here, or transplanted onto another key. Verification uses the very key being registered, so the relay confirms possession and never acquires an opinion about whose key it is. Rotation is refused rather than silently overwritten — a new address strands every contact that pinned the old one, and that needs a migration record they can follow. Log leaves go v1 -> v2, binding a subject instead of a row number: `fnid:<b32>` where an account has enrolled, `local:<audience>/<user_id>` where it has not. The fallback is qualified rather than bare because a device can be registered before its key is claimed, and the alternatives were worse in both directions — skipping those devices leaves a hole in the one record that must not have holes, and logging them unqualified produces leaves that mean different things depending on origin with nothing in the leaf to say so. This is not a server-side change. All three engines hash the leaf and two of them verify it against the relay, and a published vector pins the exact bytes, so the format moves in one commit or not at all. Existing v1 leaves keep their hashes and are backfilled to a self-describing `legacy-v1/` subject: the log is append-only, and a format change is not a licence to rewrite history. The version prefix sits inside the hashed bytes so a v1 leaf recomputed under v2 rules fails rather than verifying against a different subject. Green: engine conformance 80, relay §8 suite 235 (10 new), JS<->Swift interop 74, Kotlin published vectors. Two things found on the way, neither addressed here: - iOS never calls `enforceKeyLog`. It and `requireKeyLog` are both declared in E2EInbox and nothing reads either, so §19 is dead code on that client while the JS one wires it. The subject is threaded through ready for a caller. - `engine/test/interop/build.sh` prefers ~/Code/flamenetmessenger over the checkout it is run from, so in a worktree it silently builds the wrong sources and reports a *skip* rather than a failure. Set FNMSG_APP_DIR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e7862c0d95
commit
6f83248e1c
@@ -40,11 +40,21 @@ object KeyLog {
|
||||
*/
|
||||
fun canonicalLeaf(
|
||||
kind: String,
|
||||
userId: Long,
|
||||
subject: String,
|
||||
deviceId: String,
|
||||
ikSigB64: String,
|
||||
ikDhB64: String,
|
||||
): ByteArray = "v1|$kind|$userId|$deviceId|$ikSigB64|$ikDhB64".toByteArray(Charsets.UTF_8)
|
||||
): ByteArray = "v2|$kind|$subject|$deviceId|$ikSigB64|$ikDhB64".toByteArray(Charsets.UTF_8)
|
||||
|
||||
/**
|
||||
* The subject a leaf names — the account's fnid where one has been
|
||||
* published, and the relay-qualified row number where it has not.
|
||||
*
|
||||
* Shared rather than rebuilt per call site, so the three engines cannot
|
||||
* drift on the fallback format.
|
||||
*/
|
||||
fun logSubject(fnid: String?, audience: String, userId: Long): String =
|
||||
if (!fnid.isNullOrEmpty()) fnid else "local:$audience/$userId"
|
||||
|
||||
/**
|
||||
* Recompute the root from a leaf and its audit path.
|
||||
|
||||
Reference in New Issue
Block a user