From d51410be462fc2df508ce55d9cddbfaac7f0c513 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Tue, 29 Sep 2026 22:43:40 +0200 Subject: [PATCH 1/3] docs: propose ADR 0058 on the bind of the identity subject to the account at login Record the bind of the identity subject to the account at the first login that presents an identity token, and the device registration that reads it. Correct ADR 0039 residuals E2 and E4 to the code of PR 2092, amend ADR 0039 D2, and add the single-use rule to the blueprint. Part of #2012. --- blueprint/api.md | 4 + ...ount-and-desktop-only-requests-approval.md | 15 ++- ...nd-a-device-registration-reads-the-bind.md | 116 ++++++++++++++++++ decisions/README.md | 1 + 4 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 decisions/0058-the-identity-subject-binds-to-the-account-at-login-and-a-device-registration-reads-the-bind.md diff --git a/blueprint/api.md b/blueprint/api.md index 98eb098591..5f2f4b8e66 100644 --- a/blueprint/api.md +++ b/blueprint/api.md @@ -79,6 +79,10 @@ What left the API relative to v1 — with the design that removed it: the recovery phrase (ADR 0009 D2). Revocation is a hard delete, and it is honest about what it does — the device stops approving from now on; nothing it already holds is un-shared (D5). +- **The identity token is single-use at device registration**: a registration + spends it by its `jti` in `spent_identity_tokens`, which holds the `jti` and + the expiry only, and a spent token is refused. `POST /device-approval/session` + accepts it more than once (ADR 0058). - Tables: `users` (keyed by `publicKey`; carries quota-limit override and BYO flag), `auth_methods`, `refresh_tokens`, `accelerator_tokens`, `account_devices`, `device_approvals`, `identity_subjects`. diff --git a/decisions/0039-a-device-key-registers-to-one-account-and-desktop-only-requests-approval.md b/decisions/0039-a-device-key-registers-to-one-account-and-desktop-only-requests-approval.md index a764bb5fe9..82f3e3528f 100644 --- a/decisions/0039-a-device-key-registers-to-one-account-and-desktop-only-requests-approval.md +++ b/decisions/0039-a-device-key-registers-to-one-account-and-desktop-only-requests-approval.md @@ -89,7 +89,10 @@ the identity token, and the row records the identity subject the device signed i subject is fixed at registration: a re-registration of the same key under another subject is refused and changes nothing. One key registers to one account. A per-account cap bounds the rows. Revocation is a hard delete, with the effect ADR 0009 D5 states. The rule landed with -FSM1/cipher-box#1312; this ADR records it. +FSM1/cipher-box#1312; this ADR records it. Amended by +[ADR 0058](./0058-the-identity-subject-binds-to-the-account-at-login-and-a-device-registration-reads-the-bind.md) +D3 on 2026-09-29: the row records the subject that the account bound at login, and a +registration from an unbound account, or with a token of another subject, is refused. **D3 — One identity subject reaches at most one account, and an identity with no registered device gets no rendezvous.** The registry refuses a registration whose identity subject is @@ -268,7 +271,8 @@ beside it is unsalted SHA-256 of Google's `sub`, a normalized email or an EIP-55 guesses in a narrow domain, and can recover a wallet address by hashing the public on-chain addresses that match the prefix and the suffix. `account_devices.identity_subject_id` joins each subject to a `user_id`, so the partial identifier attaches to an account. -FSM1/cipher-box#2011 tracks the removal of the column or a keyed hash. +Closed on 2026-09-29: the owner chose the drop over a keyed hash, and FSM1/cipher-box#2092 drops +the column. The unsalted hash stays. **E3 — The operator can steer a pre-reconstruction device.** The operator issues the identity token (ADR 0008 D1) and runs the registry. It can map a subject to any account. D2 and D3 do @@ -276,14 +280,17 @@ not defend against the operator; ADR 0009 D3, the comparison value on both scree defence. **E4 — A leaked identity token lets another account claim a member's subject first.** The -identity token is a 300-second bearer, and the API accepts it more than once +identity token is a 300-second bearer. A device registration spends it by its `jti`, and +`POST /device-approval/session` accepts it more than once (`apps/api/src/auth/services/identity-token.service.ts`). At registration the registry does not check that the presented token belongs to the account of the session (`apps/api/src/device-approval/services/account-device.service.ts`). A holder of a full session on their own account and a leaked identity token of another member can therefore register a device under that member's subject first. D3 then refuses every device registration of the member, and the member's approval requests post to the wrong account. No key is disclosed: a -factor for another TSS key does not open the member's account. FSM1/cipher-box#2012 tracks it. +factor for another TSS key does not open the member's account. The spend stops a replay of a +spent token, not the first claim. ADR 0058 proposes the bind of the subject to the account at +login; FSM1/cipher-box#2012 tracks it. **E5 — Methods do not cross-link.** Google and email for the same person yield two subjects and two accounts. D1 leaves linking open and builds none (FSM1/cipher-box#1273). diff --git a/decisions/0058-the-identity-subject-binds-to-the-account-at-login-and-a-device-registration-reads-the-bind.md b/decisions/0058-the-identity-subject-binds-to-the-account-at-login-and-a-device-registration-reads-the-bind.md new file mode 100644 index 0000000000..85177e4c8f --- /dev/null +++ b/decisions/0058-the-identity-subject-binds-to-the-account-at-login-and-a-device-registration-reads-the-bind.md @@ -0,0 +1,116 @@ +# ADR 0058 — The identity subject binds to the account at login, and a device registration reads the bind + +- **Status:** Proposed +- **Date:** 2026-09-29 +- **Relates to:** + [ADR 0039](./0039-a-device-key-registers-to-one-account-and-desktop-only-requests-approval.md) + D1 (`identity_subjects` holds no account), D3 (one identity subject reaches at most one + account) and E4 (a leaked identity token lets another account claim a member's subject first), + [ADR 0008](./0008-cipherbox-issues-the-identity-token.md) D1 (CipherBox issues the identity + token), [ADR 0009](./0009-device-approval-is-a-bound-rendezvous.md) D3 (the comparison value) + and D4 (device keys sign both halves), and the `blueprint/api.md` section "Identity and auth" +- **Implemented by:** not built; one later slice under FSM1/cipher-box#2012. The spend at + registration landed with FSM1/cipher-box#2092. +- **Amends:** ADR 0039 D2 + +## Context + +The account is the secp256k1 identity key, and the engine adopts the Core Kit export as that +key. `POST /auth/login` takes a public key, a challenge and a signature, and no identity token. +So the API learns the identity subject of an account only when a device registers, and it cannot +compute the key that a subject derives. A registration therefore cannot tell a member's own +identity token from a leaked one: an account with a full session and another member's unspent +token claims that member's subject first (ADR 0039 E4). The spend of FSM1/cipher-box#2092 stops +a replay of a spent token, not the first claim. The claim stays open from the mint to the +member's first registration, which can come days after the sign-in, or never. + +## Decision + +**D1 — `users.identity_subject_id` holds the one identity subject of an account.** The column is +nullable, unique, and a foreign key to `identity_subjects`. The bind writes it once, nothing +rewrites it, and the account hard delete removes it with the row. It holds the subject id only: +no hash and no display form of the provider identifier. One account holds one subject, because +the Core Kit derives the key from the pair (verifier, subject): a second subject derives a +second key, which is a second account. A method link points another provider identity at the +same subject (ADR 0039 D1), so the rule refuses no real method. `identity_subjects` still holds +no `user_id`. + +**D2 — A login that follows an identity exchange presents the identity token, and binds an +unbound account to an unbound subject.** `POST /auth/login` takes an optional `identityToken`, +which the API verifies as a registration does. A token that does not verify refuses the login +with 401. When the account and the subject are both unbound, the login writes the bind in the +transaction that creates or touches the account, under the subject lock that registration also +takes. When either one is already bound elsewhere, the login proceeds and changes no bind. A +start that follows no exchange presents no token and binds nothing: a restored Core Kit session, +a desktop relaunch, and the staging-gated test-login. + +**D3 — A device registration reads the bind.** The API refuses a registration from an unbound +account, and a registration whose identity token names a subject other than the bound subject. +Both refusals answer 409 and write nothing. The row records the bound subject. The registration +still spends the token. + +## Alternatives considered + +- **Keep the claim at registration, as ADR 0039 D2 built it.** The claim stays open until the + member's first registration, and a member who never registers keeps a claimable subject. +- **Refuse a registration whose subject differs from the subject of the account's devices.** An + attacker makes a new account with no device, so the check never fires. +- **A `user_id` on `identity_subjects`, or a separate bind table.** ADR 0039 alternative (b) + rejects the first. A table earns its place only for many subjects per account, which D1 rules + out. +- **A hash of the subject id in place of the id.** A database reader hashes every subject id and + joins. It hides nothing, and it loses the foreign key. +- **Bind the token to the login key at mint, as a claim with the hash of the public key.** The + login key is the Core Kit export, which exists only after `loginWithJWT` redeems the token. At + mint the API knows the subject and nothing of the key. +- **The login signature covers the token's `jti`.** That proves the signer held the token, not + that the token's subject derives the signer's key. An attacker signs with an own key. +- **Refuse the login when the subject is bound to another account.** A member who lost the + first-claim race is then locked out of the vault, not only out of device registration. +- **Spend the token at login.** The web presents one token at login and then at registration, so + every registration would fail. The unique bind already makes the token useless at every other + account. + +## Consequences + +1. `blueprint/api.md` "Identity and auth" gains the bind, the login rule and the registration + rule in the slice that builds them. This PR adds only the single-use line, which is true today. +2. `blueprint/api.md` "Data model (complete)" names the `users` column in the same slice. +3. `POST /auth/login` takes the optional `identityToken`, and `POST /devices` gains the two 409 + refusals. The OpenAPI document carries both. +4. The engine API client sends the optional token in `login_identity`, and the engine start + command takes it as an optional parameter. +5. `packages/client` carries the optional token from the facade start to the worker. +6. `packages/login` hands the token of the identity credential to the start that follows an + exchange, and no token to the start of a restored session. +7. `apps/web` registers only in a sign-in that holds the token, as it does today. `apps/desktop` + carries the optional token in its Tauri start command beside the raw secret. +8. The contract suite proves the bind at the first login, no rebind on a conflict, and both + registration refusals. The web e2e device-approval legs sign in through an exchange. +9. Single use: a registration spends the token by its `jti` in `spent_identity_tokens`, which + holds the `jti` and the expiry only. Login does not spend it. `POST /device-approval/session` + accepts it more than once. +10. The token lifetime stays 300 seconds. A spent row lives until its token expires, plus a grace. +11. No backfill: an existing account stays unbound until its next login that follows an + exchange, and until then its registrations are refused. The rendezvous session still maps a + subject to an account through `account_devices`. +12. Privacy: today the API links an account to a subject, and so to the unsalted hash of its + provider identifier, only when a device registers. After the bind it links every account that + signs in through an exchange. +13. ADR 0039 D1 holds as written. ADR 0039 E4 narrows to the window before the member's bind. + +## Residuals + +**E1 — Should the identity token bind at mint to a key that the client holds before the mint?** +The bind does not close the first-claim race. An attacker with a leaked, unspent token who logs +in with a fresh key before the member does binds the member's subject. The member's account then +stays unbound and cannot register a device, and no key is disclosed. The window shrinks from +"until the first registration" to the seconds between the mint and the member's first login. +For an account that existed before the bind, it lasts until its next login through an exchange. +A proof-of-possession claim, checked by a signature at login and at registration, closes it for +a leaked token string. The web holds a device identity key before reconstruction (ADR 0009 D4); +desktop holds none (ADR 0039 E1). The owner decides whether to close it, and with which key. + +**E2 — How does a member whose subject is bound to another account recover?** D1 never rewrites +a bind, and no unbind path exists. The owner decides whether an operator unbind exists, and what +proves the member to the operator. diff --git a/decisions/README.md b/decisions/README.md index 350ebc8833..dea164af76 100644 --- a/decisions/README.md +++ b/decisions/README.md @@ -68,3 +68,4 @@ The wayfinder threads and the as-built v1 spec corpus stay in the archived | [0053](./0053-the-staging-soak-signs-in-as-durable-accounts-whose-login-secrets-live-in-the-staging-scope.md) | The staging soak signs in as durable accounts whose login secrets live in the staging scope | Accepted on 2026-09-29 | | [0054](./0054-a-dropped-versions-debt-carries-its-target-set-and-settles-above-the-acknowledged-sequence.md) | A dropped version's debt carries its target set and settles above the acknowledged sequence | Accepted on 2026-09-27 | | [0055](./0055-a-manual-refresh-fails-after-one-record-request-deadline-and-the-pass-runs-on.md) | A manual refresh fails after one record-request deadline, and the pass runs on | Accepted on 2026-09-27 | +| [0058](./0058-the-identity-subject-binds-to-the-account-at-login-and-a-device-registration-reads-the-bind.md) | The identity subject binds to the account at login, and a device registration reads the bind | Proposed | From e60f048838fa74dcbb12f13cd063c63e660f52c7 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Tue, 29 Sep 2026 23:23:53 +0200 Subject: [PATCH 2/3] docs: accept ADR 0058 and list spent_identity_tokens in the data model --- blueprint/api.md | 1 + ...account-at-login-and-a-device-registration-reads-the-bind.md | 2 +- decisions/README.md | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/blueprint/api.md b/blueprint/api.md index 5f2f4b8e66..74efa39736 100644 --- a/blueprint/api.md +++ b/blueprint/api.md @@ -348,6 +348,7 @@ rate limiting must be verified effective in e2e); staging test hooks `users`, `auth_methods`, `identity_subjects`, `refresh_tokens`, `accelerator_tokens`, `account_devices`, `device_approvals`, +`spent_identity_tokens (token_id, expires_at)`, `name_inventory (account, ipnsName)`, `pinned_cids (account, cid, size, advisory)`, `pin_references (account, ipnsName, cid)`, `mailbox_messages`, diff --git a/decisions/0058-the-identity-subject-binds-to-the-account-at-login-and-a-device-registration-reads-the-bind.md b/decisions/0058-the-identity-subject-binds-to-the-account-at-login-and-a-device-registration-reads-the-bind.md index 85177e4c8f..8bab0629cd 100644 --- a/decisions/0058-the-identity-subject-binds-to-the-account-at-login-and-a-device-registration-reads-the-bind.md +++ b/decisions/0058-the-identity-subject-binds-to-the-account-at-login-and-a-device-registration-reads-the-bind.md @@ -1,6 +1,6 @@ # ADR 0058 — The identity subject binds to the account at login, and a device registration reads the bind -- **Status:** Proposed +- **Status:** Accepted on 2026-09-29 - **Date:** 2026-09-29 - **Relates to:** [ADR 0039](./0039-a-device-key-registers-to-one-account-and-desktop-only-requests-approval.md) diff --git a/decisions/README.md b/decisions/README.md index dea164af76..861bc6dfd5 100644 --- a/decisions/README.md +++ b/decisions/README.md @@ -68,4 +68,4 @@ The wayfinder threads and the as-built v1 spec corpus stay in the archived | [0053](./0053-the-staging-soak-signs-in-as-durable-accounts-whose-login-secrets-live-in-the-staging-scope.md) | The staging soak signs in as durable accounts whose login secrets live in the staging scope | Accepted on 2026-09-29 | | [0054](./0054-a-dropped-versions-debt-carries-its-target-set-and-settles-above-the-acknowledged-sequence.md) | A dropped version's debt carries its target set and settles above the acknowledged sequence | Accepted on 2026-09-27 | | [0055](./0055-a-manual-refresh-fails-after-one-record-request-deadline-and-the-pass-runs-on.md) | A manual refresh fails after one record-request deadline, and the pass runs on | Accepted on 2026-09-27 | -| [0058](./0058-the-identity-subject-binds-to-the-account-at-login-and-a-device-registration-reads-the-bind.md) | The identity subject binds to the account at login, and a device registration reads the bind | Proposed | +| [0058](./0058-the-identity-subject-binds-to-the-account-at-login-and-a-device-registration-reads-the-bind.md) | The identity subject binds to the account at login, and a device registration reads the bind | Accepted on 2026-09-29 | From c654533a8772083823aad48f922192c5f25f7908 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Wed, 30 Sep 2026 02:42:09 +0200 Subject: [PATCH 3/3] docs: keep one spent identity token rule in the API blueprint --- blueprint/api.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/blueprint/api.md b/blueprint/api.md index 3c5b31a431..68a3e6e2a3 100644 --- a/blueprint/api.md +++ b/blueprint/api.md @@ -79,10 +79,6 @@ What left the API relative to v1 — with the design that removed it: the recovery phrase (ADR 0009 D2). Revocation is a hard delete, and it is honest about what it does — the device stops approving from now on; nothing it already holds is un-shared (D5). -- **The identity token is single-use at device registration**: a registration - spends it by its `jti` in `spent_identity_tokens`, which holds the `jti` and - the expiry only, and a spent token is refused. `POST /device-approval/session` - accepts it more than once (ADR 0058). - Tables: `users` (keyed by `publicKey`; carries quota-limit override and BYO flag), `auth_methods`, `refresh_tokens`, `accelerator_tokens`, `account_devices`, `device_approvals`, `identity_subjects`, `spent_identity_tokens`. @@ -97,7 +93,7 @@ What left the API relative to v1 — with the design that removed it: - **`spent_identity_tokens`**: a device registration spends the identity token it presents, and records the token's `jti` and expiry here, and nothing else. A replay of a spent token answers 401. `POST /auth/login` and - `POST /device-approval/session` do not spend the token. + `POST /device-approval/session` do not spend the token (ADR 0058). ## Pin/name registry