Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion blueprint/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -268,22 +271,26 @@ 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
not defend against the operator; ADR 0009 D3, the comparison value on both screens, is the
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).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# ADR 0058 — The identity subject binds to the account at login, and a device registration reads the bind

- **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)
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.
1 change: 1 addition & 0 deletions decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,5 +69,6 @@ The wayfinder threads and the as-built v1 spec corpus stay in the archived
| [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 |
| [0056](./0056-the-bin-index-mint-mark-is-raised-just-before-the-put-and-a-seal-counter-gives-the-revision.md) | The bin index mint mark is raised just before the PUT, and a seal counter gives the revision | Accepted on 2026-09-29 |
| [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 |
| [0059](./0059-a-dropped-version-whose-staged-root-does-not-read-journals-its-root-alone.md) | A dropped version whose staged root does not read journals its root alone | Accepted on 2026-09-29 |
| [0060](./0060-a-stated-refusal-from-every-endpoint-supersedes-the-bin-index-mint-mark.md) | A stated refusal from every endpoint supersedes the bin index mint mark | Accepted on 2026-09-29 |
Loading