registry: bind first/operator tokens and the owners action to a second factor, step-up on minting #274

Closed
opened 2026-10-09 16:44:19 +00:00 by orion · 5 comments
Collaborator

Goal

Close the three limits PR #273 (#228) documented: (1) tokens that are not bound to a factor, (2) the owners action, which a stolen token can use to re-point trusted publishing or add a publisher, (3) minting a publishing token, which checks that a factor is enrolled but not that the caller just proved it. After this a stolen token alone can neither mint a token that publishes or manages owners, nor change an owner; the thief also needs a current code of the account's second factor. Parent: #16.

Findings that shape the decisions (checked at d87348e)

  • A token is a bearer string; an API call has no session, so "the caller just proved the factor" can only mean a code sent with the call. Registry.acceptedCode(account, code) (#227) already checks a TOTP or recovery code with replay protection and the 5-in-15-minutes limit, so no new factor machinery is needed.
  • The account page does not mint tokens (it lists and revokes). The only mint path is POST /api/1/tokens, and the only owners calls are POST /api/1/owners, PUT/DELETE .../owners/<owner>/members/<account> and PUT .../packages/<owner>/<name>/trusted-publishers (Registry.createOrganisation, setMember, removeMember, setTrustedPublishing).
  • A provider sign-up makes no token at all. The first token exists only for operator-made accounts (POST /api/1/accounts), and such an account has no linked identity, so it cannot sign in and cannot enrol a factor. That is why the first token has to stay unbound (point 1); the way out is #276.
  • Use-time binding alone would not stop a thief (the account does have a factor); only a per-call proof does. So points 2 and 3 are one mechanism.

Decisions (loop decision, recorded; none changes a published contract with real users: the registry's only callers today are the operator and the account's own scripts)

Mechanism (points 2 and 3): a code in the Torb-Code header. The value is a current TOTP code or an unused recovery code of the token's account, checked by acceptedCode (so single use per 30-second step, constant-time, rate limit with 429). A header, not a body field, because DELETE and some PUT calls have no usable body, and one place in api.trb reads it for every call. The registry never logs or stores it.

Guarded actions are publish and owners. yank and tokens stay unguarded: a token with tokens can mint only what its maker has, and minting a guarded action needs the code anyway; yank is reversible (unyank) and was never in the factor requirement.

Point 3, step-up on minting. POST /api/1/tokens with publish or owners among the actions needs, for an account token: a factor on the account (as today, 403 otherwise) and a valid Torb-Code (403 "needs a current code", or the refusal of acceptedCode). The code is checked last, after scope, validation and the factor check, so a refused request burns no code. The operator is exempt, as today (its own act).

Point 2, the owners action. Each owners call by an account token needs a valid Torb-Code when the account has a factor, checked after the token's scope, the membership and the request validation and before anything is written. The operator is exempt; trusted tokens already cannot call these. Minting a token with owners is guarded as above, and the use-time rule of #228 applies to it as to publish: a token made with a factor (factor = 1) is refused for owners calls once its account has none.

Point 1, unbound tokens.

  • The first token of an operator-made account and a token the operator makes for an account without a factor stay unbound, because that account cannot sign in or enrol (see findings). They are the bootstrap path of docs/contributing/releasing.md steps 6 and 7 (create an organisation, set trusted publishing, publish the first release) and cannot be removed without making the registry unusable before a person can sign in.
  • Compensating rules: (a) such a token lives at most 30 days (the first token was 90; the operator's POST /api/1/tokens for an account without a factor refuses more than 30 with 400; an account with a factor is not affected); (b) an owners call by any token needs the code as soon as the account has a factor, so an unbound token stops being enough the moment the account enrols one; (c) such a token cannot mint a guarded action (the account has no factor, so the mint rule refuses it); (d) the event log marks it (create-token detail unbound). The first token keeps every action, so the bootstrap steps keep working.
  • Tokens made before this change keep working until their expiry (owner decision on #16, #228): no migration, no new column. An owners call by a token with factor = 0 on an account without a factor is allowed as today.

Out of scope.

  • Passkey-only accounts cannot give the proof through the API (no browser); they are refused the guarded mint and the owners calls with a message saying so. Closing that is #275 (passkey step-up). This narrows what #228 allowed (a passkey-only account could mint publish tokens); no such account can hold a token today, since a provider sign-up gets none, so nobody loses a working path.
  • Claiming operator-made accounts so they can enrol a factor: #276.
  • Binding yank and the read calls; a step-up on the account page (it has no mint form); a second proof at sign-in (sign-in second step, still "Not built yet" in RELEASE.md).
  • torb owner (#22) will have to send Torb-Code; noted there when that command is refined.

Existing behaviour that changes

  • POST /api/1/tokens with publish or owners: new Torb-Code requirement for account tokens (breaking for scripts of accounts that mint such tokens; none known).
  • Owners calls (POST /api/1/owners, member PUT/DELETE, trusted-publishers PUT): Torb-Code required once the account has a factor.
  • The first token of POST /api/1/accounts lives 30 days, not 90.
  • README.md table rows, RELEASE.md 7.6 and the write-service table change accordingly.

Pinned records

Acceptance criteria

  • api.trb reads the Torb-Code header once and passes it to createToken, createOrganisation, setMember, removeMember and setTrustedPublishing; the value is in no response, event, log line or database row (a test checks the event detail and the database for it).
  • Mint: POST /api/1/tokens with publish or owners by an account token with a factor succeeds with a valid TOTP code and with an unused recovery code (which is consumed); is a 403 without the header; a 403 with a wrong code; a 403 with a replayed TOTP code (same step); a 429 after 5 wrong codes in 15 minutes without looking at the code; unchanged for a token with only yank/tokens; unchanged for the operator (no code).
  • Mint by an account without a factor stays a 403 (no code can help), and the code is not consumed when the request is refused for scope, validation, the factor check or the missing tokens action (test: the same code works on the next valid request).
  • Owners calls by an account token whose account has a factor: each of the four calls succeeds with a valid code and is refused without one, with a wrong or replayed one, and past the rate limit; refused calls change nothing (owners, members, trusted publishers and required are read back unchanged); a call refused for role or scope or a 400 validation burns no code.
  • A passkey-only account (no TOTP) is refused the guarded mint and the owners calls with a message that names the missing proof; no attempt is counted.
  • Unbound tokens: the first token of POST /api/1/accounts has all actions, 30 days, and the event detail unbound; with it, creating an organisation and setting trusted publishing work with no code while the account has no factor; once a factor is enrolled the same token's owners calls need the code; POST /api/1/tokens by the operator for an account without a factor with more than 30 days is a 400, with 30 or fewer it succeeds, and for an account with a factor 365 days still works.
  • A token of factor = 1 whose account lost its factor is refused for owners calls (the use-time rule of #228 extended to owners); a token made before this (factor = 0, any actions, account without a factor) works as before.
  • Trusted publishing exchange and publishing with a token are unaffected (existing tests unchanged and green); torb test tools/registry/tests green natively and in the VM.
  • Tests live in tests/factor-before-tokens.test.trb (or a new tests/step-up.test.trb), each rule once as it works and once as it is refused, with a fixed clock for the TOTP codes as in totp-account.test.trb; existing tests that mint publish or call owners with factor accounts are adjusted.
  • Docs: tools/registry/README.md (the call table rows for tokens and owners, the header, the design bullet that lists the known limits of #228, the unbound-token rule), RELEASE.md 7.6 "A stolen token" row and the write-service Tokens row, docs/contributing/releasing.md steps 6 and 7 (30-day first token, Torb-Code once a factor exists), and the "Not built yet" paragraph; skills/ regenerated, pdisc restamped.

Seed impact

None, checked: TorbScript in tools/registry (registry.trb, api.trb) on acceptedCode that exists; no syntax change, no native, no driver flag, no std name the compiler looks up, no new std surface; no schema change (no new column or table). No seed refresh, so no two-commit rule.

Size and dependencies

One PR, one or two Conventional Commits (feat(registry): ..., and docs(registry): ... if separate). Depends on #272 and #273 (both landed). Follow-ups filed with label loop: #275 (passkey step-up), #276 (claim operator-made accounts). Parent: #16. Security-relevant: architect review of the diff (implementer opus/high).

## Goal Close the three limits PR #273 (#228) documented: (1) tokens that are not bound to a factor, (2) the `owners` action, which a stolen token can use to re-point trusted publishing or add a publisher, (3) minting a publishing token, which checks that a factor is enrolled but not that the caller just proved it. After this a stolen token alone can neither mint a token that publishes or manages owners, nor change an owner; the thief also needs a current code of the account's second factor. Parent: #16. ## Findings that shape the decisions (checked at `d87348e`) - A token is a bearer string; an API call has no session, so "the caller just proved the factor" can only mean a code sent with the call. `Registry.acceptedCode(account, code)` (#227) already checks a TOTP or recovery code with replay protection and the 5-in-15-minutes limit, so no new factor machinery is needed. - The account page does not mint tokens (it lists and revokes). The only mint path is `POST /api/1/tokens`, and the only owners calls are `POST /api/1/owners`, `PUT`/`DELETE .../owners/<owner>/members/<account>` and `PUT .../packages/<owner>/<name>/trusted-publishers` (`Registry.createOrganisation`, `setMember`, `removeMember`, `setTrustedPublishing`). - A provider sign-up makes no token at all. The first token exists only for operator-made accounts (`POST /api/1/accounts`), and such an account has no linked identity, so it cannot sign in and cannot enrol a factor. That is why the first token has to stay unbound (point 1); the way out is #276. - Use-time binding alone would not stop a thief (the account does have a factor); only a per-call proof does. So points 2 and 3 are one mechanism. ## Decisions (loop decision, recorded; none changes a published contract with real users: the registry's only callers today are the operator and the account's own scripts) **Mechanism (points 2 and 3): a code in the `Torb-Code` header.** The value is a current TOTP code or an unused recovery code of the token's account, checked by `acceptedCode` (so single use per 30-second step, constant-time, rate limit with `429`). A header, not a body field, because `DELETE` and some `PUT` calls have no usable body, and one place in `api.trb` reads it for every call. The registry never logs or stores it. **Guarded actions** are `publish` and `owners`. `yank` and `tokens` stay unguarded: a token with `tokens` can mint only what its maker has, and minting a guarded action needs the code anyway; `yank` is reversible (`unyank`) and was never in the factor requirement. **Point 3, step-up on minting.** `POST /api/1/tokens` with `publish` or `owners` among the actions needs, for an account token: a factor on the account (as today, `403` otherwise) and a valid `Torb-Code` (`403` "needs a current code", or the refusal of `acceptedCode`). The code is checked last, after scope, validation and the factor check, so a refused request burns no code. The operator is exempt, as today (its own act). **Point 2, the owners action.** Each owners call by an account token needs a valid `Torb-Code` when the account has a factor, checked after the token's scope, the membership and the request validation and before anything is written. The operator is exempt; trusted tokens already cannot call these. Minting a token with `owners` is guarded as above, and the use-time rule of #228 applies to it as to `publish`: a token made with a factor (`factor` = 1) is refused for owners calls once its account has none. **Point 1, unbound tokens.** - The first token of an operator-made account and a token the operator makes for an account without a factor **stay unbound**, because that account cannot sign in or enrol (see findings). They are the bootstrap path of `docs/contributing/releasing.md` steps 6 and 7 (create an organisation, set trusted publishing, publish the first release) and cannot be removed without making the registry unusable before a person can sign in. - **Compensating rules:** (a) such a token lives at most **30 days** (the first token was 90; the operator's `POST /api/1/tokens` for an account without a factor refuses more than 30 with `400`; an account with a factor is not affected); (b) an owners call by any token needs the code **as soon as the account has a factor**, so an unbound token stops being enough the moment the account enrols one; (c) such a token cannot mint a guarded action (the account has no factor, so the mint rule refuses it); (d) the event log marks it (`create-token` detail `unbound`). The first token keeps every action, so the bootstrap steps keep working. - Tokens made before this change keep working until their expiry (owner decision on #16, #228): no migration, no new column. An owners call by a token with `factor` = 0 on an account without a factor is allowed as today. **Out of scope.** - Passkey-only accounts cannot give the proof through the API (no browser); they are refused the guarded mint and the owners calls with a message saying so. Closing that is #275 (passkey step-up). This narrows what #228 allowed (a passkey-only account could mint `publish` tokens); no such account can hold a token today, since a provider sign-up gets none, so nobody loses a working path. - Claiming operator-made accounts so they can enrol a factor: #276. - Binding `yank` and the read calls; a step-up on the account page (it has no mint form); a second proof at sign-in (sign-in second step, still "Not built yet" in RELEASE.md). - `torb owner` (#22) will have to send `Torb-Code`; noted there when that command is refined. ## Existing behaviour that changes - `POST /api/1/tokens` with `publish` or `owners`: new `Torb-Code` requirement for account tokens (breaking for scripts of accounts that mint such tokens; none known). - Owners calls (`POST /api/1/owners`, member `PUT`/`DELETE`, trusted-publishers `PUT`): `Torb-Code` required once the account has a factor. - The first token of `POST /api/1/accounts` lives 30 days, not 90. - `README.md` table rows, RELEASE.md 7.6 and the write-service table change accordingly. ## Pinned records - [RELEASE.md 7.6 Security, the table](https://git.torb.dev/torbscript/language/src/commit/d87348ed7176aaf25f864f2c956b5004a9d601c4/docs/design/RELEASE.md#L1045) and [its "A stolen token" row](https://git.torb.dev/torbscript/language/src/commit/d87348ed7176aaf25f864f2c956b5004a9d601c4/docs/design/RELEASE.md#L1052) - [RELEASE.md, write service as built, Tokens row](https://git.torb.dev/torbscript/language/src/commit/d87348ed7176aaf25f864f2c956b5004a9d601c4/docs/design/RELEASE.md#L1326) and ["Not built yet"](https://git.torb.dev/torbscript/language/src/commit/d87348ed7176aaf25f864f2c956b5004a9d601c4/docs/design/RELEASE.md#L1350) - [releasing.md, the first accounts](https://git.torb.dev/torbscript/language/src/commit/d87348ed7176aaf25f864f2c956b5004a9d601c4/docs/contributing/releasing.md#L449) - #16 decisions (a factor before minting or using publishing tokens; existing tokens run to expiry), PR #273 "Known limits", PR #272 (`acceptedCode`) ## Acceptance criteria - [ ] `api.trb` reads the `Torb-Code` header once and passes it to `createToken`, `createOrganisation`, `setMember`, `removeMember` and `setTrustedPublishing`; the value is in no response, event, log line or database row (a test checks the event detail and the database for it). - [ ] Mint: `POST /api/1/tokens` with `publish` or `owners` by an account token with a factor succeeds with a valid TOTP code and with an unused recovery code (which is consumed); is a `403` without the header; a `403` with a wrong code; a `403` with a replayed TOTP code (same step); a `429` after 5 wrong codes in 15 minutes without looking at the code; unchanged for a token with only `yank`/`tokens`; unchanged for the operator (no code). - [ ] Mint by an account without a factor stays a `403` (no code can help), and the code is not consumed when the request is refused for scope, validation, the factor check or the missing `tokens` action (test: the same code works on the next valid request). - [ ] Owners calls by an account token whose account has a factor: each of the four calls succeeds with a valid code and is refused without one, with a wrong or replayed one, and past the rate limit; refused calls change nothing (owners, members, trusted publishers and `required` are read back unchanged); a call refused for role or scope or a `400` validation burns no code. - [ ] A passkey-only account (no TOTP) is refused the guarded mint and the owners calls with a message that names the missing proof; no attempt is counted. - [ ] Unbound tokens: the first token of `POST /api/1/accounts` has all actions, 30 days, and the event detail `unbound`; with it, creating an organisation and setting trusted publishing work with no code while the account has no factor; once a factor is enrolled the same token's owners calls need the code; `POST /api/1/tokens` by the operator for an account without a factor with more than 30 days is a `400`, with 30 or fewer it succeeds, and for an account with a factor 365 days still works. - [ ] A token of `factor` = 1 whose account lost its factor is refused for owners calls (the use-time rule of #228 extended to `owners`); a token made before this (`factor` = 0, any actions, account without a factor) works as before. - [ ] Trusted publishing exchange and publishing with a token are unaffected (existing tests unchanged and green); `torb test tools/registry/tests` green natively and in the VM. - [ ] Tests live in `tests/factor-before-tokens.test.trb` (or a new `tests/step-up.test.trb`), each rule once as it works and once as it is refused, with a fixed clock for the TOTP codes as in `totp-account.test.trb`; existing tests that mint `publish` or call owners with factor accounts are adjusted. - [ ] Docs: `tools/registry/README.md` (the call table rows for tokens and owners, the header, the design bullet that lists the known limits of #228, the unbound-token rule), RELEASE.md 7.6 "A stolen token" row and the write-service Tokens row, `docs/contributing/releasing.md` steps 6 and 7 (30-day first token, `Torb-Code` once a factor exists), and the "Not built yet" paragraph; `skills/` regenerated, pdisc restamped. ## Seed impact None, checked: TorbScript in `tools/registry` (`registry.trb`, `api.trb`) on `acceptedCode` that exists; no syntax change, no native, no driver flag, no std name the compiler looks up, no new std surface; no schema change (no new column or table). No seed refresh, so no two-commit rule. ## Size and dependencies One PR, one or two Conventional Commits (`feat(registry): ...`, and `docs(registry): ...` if separate). Depends on #272 and #273 (both landed). Follow-ups filed with label `loop`: #275 (passkey step-up), #276 (claim operator-made accounts). Parent: #16. Security-relevant: architect review of the diff (implementer opus/high).
Author
Collaborator

[loop:torbscript] intake: in scope (author orion, label loop; follow-up of the PR #273 review). Ready buffer is below 4 (#191 closed), so refine runs. claim refine by session:loop-torbscript-20261009 until 2026-10-09T19:00Z

[loop:torbscript] intake: in scope (author orion, label loop; follow-up of the PR #273 review). Ready buffer is below 4 (#191 closed), so refine runs. claim refine by session:loop-torbscript-20261009 until 2026-10-09T19:00Z
Author
Collaborator

[loop:torbscript] refine: decided by the loop: (1) first token and operator tokens for an account without a factor stay unbound (operator-made accounts cannot sign in or enrol), compensated by 30-day cap, code required on owners calls once the account has a factor, no guarded mint; (2) owners calls need a current TOTP/recovery code in Torb-Code when the account has a factor; (3) minting publish/owners needs the same code (step-up); passkey-only proof split to #275, claiming operator-made accounts to #276; no seed impact, no schema change, one PR. next: implement

[loop:torbscript] refine: decided by the loop: (1) first token and operator tokens for an account without a factor stay unbound (operator-made accounts cannot sign in or enrol), compensated by 30-day cap, code required on owners calls once the account has a factor, no guarded mint; (2) owners calls need a current TOTP/recovery code in `Torb-Code` when the account has a factor; (3) minting `publish`/`owners` needs the same code (step-up); passkey-only proof split to #275, claiming operator-made accounts to #276; no seed impact, no schema change, one PR. next: implement
Author
Collaborator

[loop:torbscript] claim implement by session:loop-torbscript-20261009 until 2026-10-09T23:03Z

[loop:torbscript] claim implement by session:loop-torbscript-20261009 until 2026-10-09T23:03Z
Author
Collaborator

[loop:torbscript] implement: PR #277 opened, CI green, owners calls and publish/owners token minting now need a fresh Torb-Code once the account has a second factor; unbound first/operator tokens capped at 30 days. next: review (waiting on the merge gate argus). Claim released.

[loop:torbscript] implement: PR https://git.torb.dev/torbscript/language/pulls/277 opened, CI green, owners calls and publish/owners token minting now need a fresh Torb-Code once the account has a second factor; unbound first/operator tokens capped at 30 days. next: review (waiting on the merge gate argus). Claim released.
argus closed this issue 2026-10-09 17:56:13 +00:00
Author
Collaborator

[loop:torbscript] close: PR #277 approved and merged by argus as 584d266fe2; main CI green on that commit. Claim released.

[loop:torbscript] close: PR https://git.torb.dev/torbscript/language/pulls/277 approved and merged by argus as 584d266fe27794469a02f35c837cfc431961e38f; main CI green on that commit. Claim released.
Sign in to join this conversation.
No labels
loop
ready
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
torbscript/language#274
No description provided.