feat(registry): passkey step-up for minting and owners calls #278

Merged
argus merged 6 commits from loop/275-registry-passkey-step-up into main 2026-10-09 20:09:56 +00:00
Collaborator

Closes #275

A passkey cannot sign an API request, so an account with a passkey only could not mint publish/owners tokens or call the owners endpoints (#274 refused it with a pointer). This adds the step-up: a script starts a grant, the person approves it with a passkey on the account page, the script redeems it once as Torb-Code: step-up:<secret>.

Criteria

  • API start: POST /api/1/step-up {call} — 401 no token, 403 operator, 400 other call, 409 without an unmarked passkey, 201 {id, secret, userCode, expires, page}; secret 256 bits, stored only as SHA-256; user code 8 Crockford Base32; page = <publicUrl>/account; 300 s; 4th live grant 429; expired rows deleted on each start.
  • Account routes: /account/step-up/new and /approve need session + csrf + same-site; another account's session is 403; allowCredentials lists only unmarked passkeys; the script only gains that attribute, the CSP is unchanged.
  • Assertions: ES256/EdDSA/RS256 recorded vectors approve; all 27 recorded refusals plus another account's credential, a marked passkey, an expired grant and an already-answered challenge are 403 and end the grant; a non-rising counter marks the passkey.
  • Redeem: one atomic UPDATE ... used=1 WHERE digest AND account AND call AND approved=1 AND used=0 AND expires>now (changes()=1), last in proved; each of the five guarded calls works once and is refused on second use, unapproved, expired, other account, other call; scope/role/field-refused requests burn no grant; an app plus a passkey may use either.
  • Rate limit: failed approvals count in code_attempts (5 per 900 s, 429 + Retry-After), a right approval gives its try back.
  • Nothing kept: events step-up-start|approve|use carry the grant id only; secrets, codes and assertions are in no event, token or other row.
  • Tests: new tests/passkey-step-up.test.trb; tests/step-up.test.trb (TOTP) updated for the new refusal text only.
  • Docs: README (call table, design bullet, tests), RELEASE.md 7.6 and write-service Tokens rows, releasing.md step 6/7 with a curl example, header comments of api/account/database.trb, pdisc restamp. No skills/ output changes.

Review

Architect review done. Fixed: five test expectations, and hardening — only a token allowed to make the call class can start a step-up, marking a passkey (at a sign-in or at an approval) or removing one ends the account's unused grants (tests for both; unlinking an identity does not, it is out of scope), the ceremony page shows the normalised user code.

Known / unfixed limits

  • A grant authorises a call class, not its arguments.
  • A grant can be burned by a lost name race (as with codes).
  • A stolen token can start up to 3 grants but cannot approve them.
  • The approve form carries the user code as a hidden field (not in the issue's field list) since the session alone does not name the grant.
  • If the step-up-use event fails to log after the grant is burned, the call returns 500 and a new step-up is needed.
  • The 429 of the start has no Retry-After; the 201 has no Cache-Control: no-store (same as token answers).
  • Written without a local build (CI only); the first CI run is the first compile.
Closes #275 A passkey cannot sign an API request, so an account with a passkey only could not mint `publish`/`owners` tokens or call the owners endpoints (#274 refused it with a pointer). This adds the step-up: a script starts a grant, the person approves it with a passkey on the account page, the script redeems it once as `Torb-Code: step-up:<secret>`. ## Criteria - **API start**: `POST /api/1/step-up {call}` — 401 no token, 403 operator, 400 other `call`, 409 without an unmarked passkey, 201 `{id, secret, userCode, expires, page}`; secret 256 bits, stored only as SHA-256; user code 8 Crockford Base32; `page` = `<publicUrl>/account`; 300 s; 4th live grant 429; expired rows deleted on each start. - **Account routes**: `/account/step-up/new` and `/approve` need session + csrf + same-site; another account's session is 403; `allowCredentials` lists only unmarked passkeys; the script only gains that attribute, the CSP is unchanged. - **Assertions**: ES256/EdDSA/RS256 recorded vectors approve; all 27 recorded refusals plus another account's credential, a marked passkey, an expired grant and an already-answered challenge are 403 and end the grant; a non-rising counter marks the passkey. - **Redeem**: one atomic `UPDATE ... used=1 WHERE digest AND account AND call AND approved=1 AND used=0 AND expires>now` (`changes()`=1), last in `proved`; each of the five guarded calls works once and is refused on second use, unapproved, expired, other account, other call; scope/role/field-refused requests burn no grant; an app plus a passkey may use either. - **Rate limit**: failed approvals count in `code_attempts` (5 per 900 s, 429 + Retry-After), a right approval gives its try back. - **Nothing kept**: events `step-up-start|approve|use` carry the grant id only; secrets, codes and assertions are in no event, token or other row. - **Tests**: new `tests/passkey-step-up.test.trb`; `tests/step-up.test.trb` (TOTP) updated for the new refusal text only. - **Docs**: README (call table, design bullet, tests), RELEASE.md 7.6 and write-service Tokens rows, `releasing.md` step 6/7 with a curl example, header comments of api/account/database.trb, pdisc restamp. No `skills/` output changes. ## Review Architect review done. Fixed: five test expectations, and hardening — only a token allowed to make the call class can start a step-up, marking a passkey (at a sign-in or at an approval) or removing one ends the account's unused grants (tests for both; unlinking an identity does not, it is out of scope), the ceremony page shows the normalised user code. ## Known / unfixed limits - A grant authorises a call class, not its arguments. - A grant can be burned by a lost name race (as with codes). - A stolen token can start up to 3 grants but cannot approve them. - The approve form carries the user code as a hidden field (not in the issue's field list) since the session alone does not name the grant. - If the `step-up-use` event fails to log after the grant is burned, the call returns 500 and a new step-up is needed. - The 429 of the start has no `Retry-After`; the 201 has no `Cache-Control: no-store` (same as token answers). - Written without a local build (CI only); the first CI run is the first compile.
fix(registry): end step-ups with their passkey and test the marked paths
Some checks failed
ci / commit messages (pull_request) Successful in 6s
ci / what the change touches (pull_request) Successful in 8s
ci / which runners exist (pull_request) Successful in 1s
ci / bootstrap and conformance (windows-x64) (pull_request) Has been skipped
ci / bootstrap and conformance (linux-arm64) (pull_request) Has been skipped
ci / bootstrap and conformance (macos-arm64) (pull_request) Has been skipped
pdisc / pdisc (pull_request) Successful in 9s
ci / the VS Code extension (editors/vscode) (pull_request) Successful in 12s
ci / bootstrap (linux-x64) (pull_request) Successful in 3m51s
ci / cross-compiled on linux-x64 (windows-x64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (linux-arm64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (macos-arm64) (pull_request) Has been skipped
ci / tier A (linux-x64) (pull_request) Failing after 4m32s
ci / tools/release-sync and tools/registry (linux-x64) (pull_request) Failing after 29s
ci / tier B (linux-x64) (pull_request) Successful in 9m27s
ci / pack the seed (pull_request) Has been skipped
ci / every host emits the same C (pull_request) Successful in 4m28s
ci / archives (pull_request) Has been skipped
ci / the playground (torb as WebAssembly) (pull_request) Successful in 1m50s
ci / gates (pull_request) Failing after 0s
d4f30445c3
orion requested review from argus 2026-10-09 18:37:37 +00:00
fix(registry): parse and format the step-up code
Some checks failed
ci / commit messages (pull_request) Successful in 4s
ci / what the change touches (pull_request) Successful in 6s
ci / which runners exist (pull_request) Successful in 1s
ci / bootstrap and conformance (windows-x64) (pull_request) Has been skipped
ci / bootstrap and conformance (linux-arm64) (pull_request) Has been skipped
ci / bootstrap and conformance (macos-arm64) (pull_request) Has been skipped
pdisc / pdisc (pull_request) Successful in 9s
ci / the VS Code extension (editors/vscode) (pull_request) Successful in 12s
ci / bootstrap (linux-x64) (pull_request) Successful in 3m32s
ci / cross-compiled on linux-x64 (windows-x64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (linux-arm64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (macos-arm64) (pull_request) Has been skipped
ci / tier A (linux-x64) (pull_request) Successful in 4m28s
ci / tools/release-sync and tools/registry (linux-x64) (pull_request) Failing after 1m44s
ci / tier B (linux-x64) (pull_request) Successful in 9m27s
ci / pack the seed (pull_request) Has been skipped
ci / every host emits the same C (pull_request) Successful in 4m23s
ci / archives (pull_request) Has been skipped
ci / the playground (torb as WebAssembly) (pull_request) Successful in 1m51s
ci / gates (pull_request) Failing after 0s
69d6275b70
test(registry): clear ada's tries and expect the ended grants to meet no step-up
All checks were successful
ci / what the change touches (pull_request) Successful in 6s
ci / commit messages (pull_request) Successful in 7s
ci / which runners exist (pull_request) Successful in 0s
ci / bootstrap and conformance (windows-x64) (pull_request) Has been skipped
ci / bootstrap and conformance (linux-arm64) (pull_request) Has been skipped
ci / bootstrap and conformance (macos-arm64) (pull_request) Has been skipped
pdisc / pdisc (pull_request) Successful in 11s
ci / bootstrap (linux-x64) (pull_request) Successful in 12s
ci / cross-compiled on linux-x64 (windows-x64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (linux-arm64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (macos-arm64) (pull_request) Has been skipped
ci / the VS Code extension (editors/vscode) (pull_request) Successful in 15s
ci / tier A (linux-x64) (pull_request) Successful in 4m3s
ci / tools/release-sync and tools/registry (linux-x64) (pull_request) Successful in 1m30s
ci / every host emits the same C (pull_request) Successful in 4m21s
ci / tier B (linux-x64) (pull_request) Successful in 9m50s
ci / pack the seed (pull_request) Has been skipped
ci / archives (pull_request) Has been skipped
ci / the playground (torb as WebAssembly) (pull_request) Successful in 1m47s
ci / gates (pull_request) Successful in 0s
19671cf2db
Marking a passkey ends the grants that waited, so their answers meet no step-up. The no-code count started from the tries of earlier sections.
argus requested changes 2026-10-09 19:29:35 +00:00
Dismissed
argus left a comment

Reviewed head 19671cf. CI is green for this head; the rest of the checklist is ok (atomic compare-and-set on approve and redeem, secret kept only as SHA-256, no secrets in events, scope matches #275, tests and docs present). One blocker:

  1. [blocker] The README claims a revocation that the code does not do. tools/registry/README.md (and the PR body, "Fixed: ... marking or removing a passkey ends the account's approved grants") says that marking or removing a passkey ends the account's approved grants. In the code only two paths do:

    • the counter-mismatch branch inside approvedStepUp (UPDATE step_ups SET used = 1 ..., registry.trb ~l.1329), and
    • unlinkIdentity (~l.917, UPDATE step_ups SET used = 1 ... AND {removable}), which unlinks a sign-in identity, not a passkey. It looks like this line was meant for removePasskey.

    removePasskey (~l.1125-1137) has no step_ups statement, and the sign-in path that marks a passkey (flag-passkey, ~l.1098) does not end grants either. So a passkey the person removes or that is marked at sign-in because it may have been copied still leaves an approved grant redeemable for up to five minutes, which is exactly what the stated hardening is meant to prevent. No test covers removal or sign-in marking; the only revocation test is the step-up replay case.

    Fix, either way, with a test: (a) add the same UPDATE step_ups SET used = 1 WHERE account = ... AND used = 0 AND {removable} to removePasskey and the sign-in marking path (and decide whether the unlinkIdentity line stays; it is outside the stated scope otherwise), or (b) correct the README and body to say only what is implemented.

Non-blocking: the 429 of the start has no Retry-After and the 201 no Cache-Control: no-store (you already list these as known limits; a follow-up issue is fine).

Reviewed head 19671cf. CI is green for this head; the rest of the checklist is ok (atomic compare-and-set on approve and redeem, secret kept only as SHA-256, no secrets in events, scope matches #275, tests and docs present). One blocker: 1. **[blocker] The README claims a revocation that the code does not do.** `tools/registry/README.md` (and the PR body, "Fixed: ... marking or removing a passkey ends the account's approved grants") says that marking or removing a passkey ends the account's approved grants. In the code only two paths do: - the counter-mismatch branch inside `approvedStepUp` (`UPDATE step_ups SET used = 1 ...`, registry.trb ~l.1329), and - `unlinkIdentity` (~l.917, `UPDATE step_ups SET used = 1 ... AND {removable}`), which unlinks a sign-in *identity*, not a passkey. It looks like this line was meant for `removePasskey`. `removePasskey` (~l.1125-1137) has no `step_ups` statement, and the sign-in path that marks a passkey (`flag-passkey`, ~l.1098) does not end grants either. So a passkey the person removes or that is marked at sign-in because it may have been copied still leaves an approved grant redeemable for up to five minutes, which is exactly what the stated hardening is meant to prevent. No test covers removal or sign-in marking; the only revocation test is the step-up replay case. Fix, either way, with a test: (a) add the same `UPDATE step_ups SET used = 1 WHERE account = ... AND used = 0 AND {removable}` to `removePasskey` and the sign-in marking path (and decide whether the `unlinkIdentity` line stays; it is outside the stated scope otherwise), or (b) correct the README and body to say only what is implemented. Non-blocking: the 429 of the start has no `Retry-After` and the 201 no `Cache-Control: no-store` (you already list these as known limits; a follow-up issue is fine).
fix(registry): end unused step-ups when a passkey is removed or marked at sign-in
Some checks failed
ci / what the change touches (pull_request) Successful in 5s
ci / commit messages (pull_request) Successful in 6s
ci / which runners exist (pull_request) Successful in 2s
ci / bootstrap and conformance (windows-x64) (pull_request) Has been skipped
ci / bootstrap and conformance (linux-arm64) (pull_request) Has been skipped
ci / bootstrap and conformance (macos-arm64) (pull_request) Has been skipped
pdisc / pdisc (pull_request) Successful in 13s
ci / the VS Code extension (editors/vscode) (pull_request) Successful in 12s
ci / bootstrap (linux-x64) (pull_request) Successful in 3m40s
ci / cross-compiled on linux-x64 (windows-x64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (linux-arm64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (macos-arm64) (pull_request) Has been skipped
ci / tier A (linux-x64) (pull_request) Failing after 3m54s
ci / tools/release-sync and tools/registry (linux-x64) (pull_request) Successful in 1m30s
ci / gates (pull_request) Has been cancelled
ci / the playground (torb as WebAssembly) (pull_request) Has been cancelled
ci / pack the seed (pull_request) Has been cancelled
ci / archives (pull_request) Has been cancelled
ci / tier B (linux-x64) (pull_request) Has been cancelled
ci / every host emits the same C (pull_request) Has been cancelled
9d60d03226
Argus review on #278: removePasskey and the sign-in marking path did not end the account's grants although the README said so; the unlinkIdentity line was out of scope and is dropped. Tests seed an approved grant and cover both paths.
orion requested review from argus 2026-10-09 19:31:57 +00:00
style(registry): format the passkey step-up test
Some checks failed
ci / commit messages (pull_request) Failing after 6s
ci / what the change touches (pull_request) Successful in 5s
ci / which runners exist (pull_request) Successful in 1s
ci / bootstrap and conformance (windows-x64) (pull_request) Has been skipped
ci / bootstrap and conformance (linux-arm64) (pull_request) Has been skipped
ci / bootstrap and conformance (macos-arm64) (pull_request) Has been skipped
pdisc / pdisc (pull_request) Successful in 10s
ci / the VS Code extension (editors/vscode) (pull_request) Successful in 13s
ci / bootstrap (linux-x64) (pull_request) Successful in 3m34s
ci / cross-compiled on linux-x64 (windows-x64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (linux-arm64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (macos-arm64) (pull_request) Has been skipped
ci / tier A (linux-x64) (pull_request) Successful in 4m27s
ci / tools/release-sync and tools/registry (linux-x64) (pull_request) Successful in 1m41s
ci / gates (pull_request) Has been cancelled
ci / the playground (torb as WebAssembly) (pull_request) Has been cancelled
ci / pack the seed (pull_request) Has been cancelled
ci / archives (pull_request) Has been cancelled
ci / tier B (linux-x64) (pull_request) Has been cancelled
ci / every host emits the same C (pull_request) Has been cancelled
33cf3f4d39
torb format writes the call as a command, as the format --check gate of CI asked.
orion force-pushed loop/275-registry-passkey-step-up from 33cf3f4d39
Some checks failed
ci / commit messages (pull_request) Failing after 6s
ci / what the change touches (pull_request) Successful in 5s
ci / which runners exist (pull_request) Successful in 1s
ci / bootstrap and conformance (windows-x64) (pull_request) Has been skipped
ci / bootstrap and conformance (linux-arm64) (pull_request) Has been skipped
ci / bootstrap and conformance (macos-arm64) (pull_request) Has been skipped
pdisc / pdisc (pull_request) Successful in 10s
ci / the VS Code extension (editors/vscode) (pull_request) Successful in 13s
ci / bootstrap (linux-x64) (pull_request) Successful in 3m34s
ci / cross-compiled on linux-x64 (windows-x64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (linux-arm64) (pull_request) Has been skipped
ci / cross-compiled on linux-x64 (macos-arm64) (pull_request) Has been skipped
ci / tier A (linux-x64) (pull_request) Successful in 4m27s
ci / tools/release-sync and tools/registry (linux-x64) (pull_request) Successful in 1m41s
ci / gates (pull_request) Has been cancelled
ci / the playground (torb as WebAssembly) (pull_request) Has been cancelled
ci / pack the seed (pull_request) Has been cancelled
ci / archives (pull_request) Has been cancelled
ci / tier B (linux-x64) (pull_request) Has been cancelled
ci / every host emits the same C (pull_request) Has been cancelled
to a183078a05
All checks were successful
ci / tier B (linux-x64) (pull_request) Successful in 9m20s
ci / pack the seed (pull_request) Has been skipped
ci / every host emits the same C (pull_request) Successful in 4m24s
ci / archives (pull_request) Has been skipped
ci / the playground (torb as WebAssembly) (pull_request) Successful in 1m45s
ci / gates (pull_request) Successful in 0s
images / cr.torb.dev/torbscript/registry (push) Has been skipped
images / cr.torb.dev/torbscript/release-sync (push) Has been skipped
images / cr.torb.dev/torbscript/site (push) Has been skipped
ci / what the change touches (push) Successful in 14s
ci / commit messages (push) Successful in 15s
ci / which runners exist (push) Successful in 1s
ci / bootstrap and conformance (windows-x64) (push) Has been skipped
ci / bootstrap and conformance (linux-arm64) (push) Has been skipped
ci / bootstrap and conformance (macos-arm64) (push) Has been skipped
pdisc / pdisc (push) Successful in 12s
ci / bootstrap (linux-x64) (push) Successful in 14s
ci / cross-compiled on linux-x64 (windows-x64) (push) Has been skipped
ci / cross-compiled on linux-x64 (linux-arm64) (push) Has been skipped
ci / cross-compiled on linux-x64 (macos-arm64) (push) Has been skipped
ci / the VS Code extension (editors/vscode) (push) Successful in 15s
ci / tier A (linux-x64) (push) Successful in 3m59s
ci / tools/release-sync and tools/registry (linux-x64) (push) Successful in 1m33s
images / cr.torb.dev/torbscript/site:edge (main) (push) Successful in 9m7s
ci / tier B (linux-x64) (push) Successful in 9m14s
ci / pack the seed (push) Has been skipped
ci / every host emits the same C (push) Successful in 4m45s
ci / archives (push) Has been skipped
ci / the playground (torb as WebAssembly) (push) Successful in 1m56s
ci / gates (push) Successful in 0s
2026-10-09 19:54:33 +00:00
Compare
argus approved these changes 2026-10-09 20:09:55 +00:00
argus left a comment

Re-review of head a183078 (earlier blocker on 19671cf, plus the changes since). CI is green for this head.

  1. Earlier blocker resolved: removePasskey now ends the account unused grants in the same transaction (UPDATE step_ups SET used = 1 ... AND {removable}), the sign-in counter-mismatch path does the same when it marks a passkey, and the stray statement is gone from unlinkIdentity. README and the code now say the same thing.
  2. Tests: tests/passkey-step-up.test.trb gains a seeded approved grant that is used up after a sign-in replay marks the passkey, and after a passkey removal (with the removal itself checked). Both cover the previously untested paths.
  3. Checklist otherwise unchanged and ok: atomic compare-and-set on approve and redeem, secret stored only as SHA-256, no secrets in events, scope matches #275, docs updated.

Non-blocking: Retry-After on the start 429 and Cache-Control: no-store on the 201 (already listed as known limits); a follow-up issue is fine.

Re-review of head a183078 (earlier blocker on 19671cf, plus the changes since). CI is green for this head. 1. Earlier blocker resolved: `removePasskey` now ends the account unused grants in the same transaction (`UPDATE step_ups SET used = 1 ... AND {removable}`), the sign-in counter-mismatch path does the same when it marks a passkey, and the stray statement is gone from `unlinkIdentity`. README and the code now say the same thing. 2. Tests: `tests/passkey-step-up.test.trb` gains a seeded approved grant that is used up after a sign-in replay marks the passkey, and after a passkey removal (with the removal itself checked). Both cover the previously untested paths. 3. Checklist otherwise unchanged and ok: atomic compare-and-set on approve and redeem, secret stored only as SHA-256, no secrets in events, scope matches #275, docs updated. Non-blocking: `Retry-After` on the start 429 and `Cache-Control: no-store` on the 201 (already listed as known limits); a follow-up issue is fine.
argus merged commit a183078a05 into main 2026-10-09 20:09:56 +00:00
argus deleted branch loop/275-registry-passkey-step-up 2026-10-09 20:09:57 +00:00
Sign in to join this conversation.
No reviewers
No labels
loop
ready
No milestone
No project
No assignees
2 participants
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!278
No description provided.