Skip to content

Admit held email credential presentations behind a default-off flag - #393

Draft
samgbafa wants to merge 29 commits into
mainfrom
skgbafa/tc-1452-held-admission
Draft

samgbafa wants to merge 29 commits into
mainfrom
skgbafa/tc-1452-held-admission

Conversation

@samgbafa

@samgbafa samgbafa commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Head

922f02f. Since cf100fc: 5752aa1 (333-case differential), c93a3ac, f7936e0, 76c55cd (real-time held mint fixture and mint-path tests), 414b048 (S≥L test), 93100bf (disclosed vct tests, held route and signed-credential path), 922f02f (expires_at index on held_credential_nonce with up/down tests on both backends; held route test uses register_held; SIWE 2048 and sd_jwt 16384 cap pins; real-key S+L test). Contains origin/main at 966fa5e. Not merged. Held flag stays OFF.

Scope: verifier/admission slice of TC-1452 (B1–B5, F1–F8, TC-1541 items 1–2, differential). The rest of S1 (grant, origin allowlist, v4 guard, mint throttle, salted digests, nonce mint route) is out of scope.

Sam's B1–B5

  • B1 claims match and minimisation. requirement_claims_satisfied runs after verify in validate_held_email_admission (policy_v3.rs). Disclosed names outside requirement.claims are refused with credential-requirement-not-satisfied (policy-layer rule; verifier stays spec-aligned §3.4). Tests: held_requirement_claims_must_match_the_disclosed_values, held_disclosures_beyond_the_requirement_are_refused.
  • B2 expiry. session_expiry is capped by credential_expires_at. Test: session_expiry_never_outlives_the_held_credential.
  • B3 pin. HELD_EMAIL_MAX_AGE_SECONDS = 31_536_000, checked by held_email_max_age_pinned. Test: held_email_max_age_is_pinned_to_the_row_value.
  • B4 size limits. Constants in held_email.rs, enforced in precheck before nonce consume. Test: oversized_presentation_is_refused_before_decoding. Plus the grammar and encoding tests listed under F-items below.
  • B5 lockfiles. tinycloud-node-server/Cargo.toml adds url = "2" (with existing k256, sha3). Cargo.lock, test/m1-realdata-e2e/Cargo.lock, test/w5-policy-runtime-node-e2e/Cargo.lock updated. cargo metadata --locked exits 0 for root, m1, and w5.

Findings F1–F8 (test names)

  • F1 Rust vs TS. Zero-disclosure presentations: ok in TS and Rust (aligned). uri_matches_audience now uses url::Url::parse (https, empty username, no password, no port, exact host). Test: siwe_uri_must_be_https_with_no_userinfo_and_the_exact_audience_host. Behaviour change: https:///host now parses and matches; not pinned by a test. High-S SIWE: divergence, Rust refuses (see Deviations). DEL 0x7f in email: TS accepts, Rust refuses (kept); recorded on TC-1540.
  • F2 not reconciled. kid as bare DID (Rust bad-header; TS did-method-not-allowed/issuer-not-accepted). iat after year 9999 (Rust bad-payload; TS not-yet-valid). Empty SIWE resource (Rust refuses before consume; TS after). Left as deviations.
  • F3 out of scope (state neutrally).
  • F4 nonce expiry. Migration m20261105_000000_held_credential_nonce adds ExpiresAt and index idx_held_credential_nonce_expires_at (serves the per-consume prune DELETE WHERE expires_at <= now). Tests: expired_nonce_is_refused_and_spends_nothing, expired_rows_are_pruned_on_consume, nonce_expiry_boundary_is_exclusive, and expires_at_index_follows_up_and_down (SQLite and Postgres, m20261105_000000_held_credential_nonce::tests).
  • F5 require_typ: typ_other_than_vc_sd_jwt_is_refused.
  • F6 malformed owner. ACCOUNT_OWNER_INVALID → 403 account-owner-invalid. Test: malformed_account_owner_maps_to_forbidden_not_internal_error.
  • F7 not reconciled. Rust refuses one pubkey under two kids (TS allows). Rust accepts single-label did:web:localhost (TS requires a dot). Left as deviations.
  • F8 mutation pass. 13 verifier/handler gates. Each applied on one line; named test failed; reverted. Mutant → test: issuer-outside-policy → issuer_outside_the_policy_accepted_list_is_refused; check_now → issuer_stopped_at_now_is_refused; iss → payload_iss_must_name_the_kid_issuer; check_issued_at → credential_issued_after_issuer_retirement_is_refused; type gate → credential_type_outside_the_issuer_entry_is_refused; holder-chain filter → subject_on_another_chain_is_refused; holder binding → holder_binding_must_name_the_subject; _sd membership → disclosure_must_be_listed_in_sd; SIWE resource → siwe_resource_must_hash_the_presented_sd_jwt; SIWE expiration → siwe_expiration_time_in_the_past_is_refused; SIWE signer → siwe_signed_by_another_key_is_refused; SIWE domain → siwe_domain_other_than_audience_is_refused; B1 disclosure-name refusal → held_disclosures_beyond_the_requirement_are_refused. Plus earlier: verify_strict→verify (G1), verify-before-consume (refused_presentation_still_spends_the_nonce), audience gate (held_credentials_are_off_unless_enabled_with_a_valid_audience).
  • Route gate. held_presentation_is_refused_while_the_flag_is_off (policy_v3/held_route_tests.rs, on_both_backends!) asserts 403 held-credentials-disabled. Gate mutation made it fail on both backends.
  • Credential-field shape gate (flag on). held_request_with_a_credential_field_is_refused_by_the_shape_gate (held_route_tests.rs, on_both_backends!) pins the credential clause of the request-shape gate through HTTP (policy_v3.rs ~2146–2161). Account and space fields are placeholders so only the credential clause is exercised.
  • Disclosed vct. held_presentation_with_the_required_vct_is_admitted (200) and held_presentation_with_a_mismatched_vct_is_refused (403 profile-mismatch, from the held_email.rs profile check) in held_route_tests.rs, on_both_backends!. Both use outstanding_presentation_with_vct in held_fixtures.rs. The held route never reaches policy_v3.rs line 6391 (see Mutation pass).
  • Scope note. Post-gate route cases (consume-first, locked re-evaluation consume=false, different-account nonce burn) run through the mint harness in held_route_tests.rs (76c55cd), on both backends.

TC-1541 items

  1. No kid. kidless_header_is_refused_with_the_combined_issuer_set, missing_kid_header_is_refused, kidless_lookup_never_resolves_a_pinned_key, kidless_header_is_refused_for_a_configured_entry_in_the_combined_set.
  2. verify_strict. small_order_r_signature_is_refused_on_the_held_path (policy_v3.rs verify_issuer_signature). Also v3_issuer_signature_refuses_small_order_key_and_r (M27).

Issuer and differential

  • Test issuer is did:web:issuer.credentials.org (TEST_ISSUER/TEST_KID in held_email.rs), per spec §3.3/§6.
  • Differential regenerated from published @tinycloud/sdk-core@3.3.0-beta.37 (registry gitHead c9bb5550de7cce9330d41995dd1157062c75916b, Node v22.23.2). Verifier dist/held-credentials/index.js sha256 04db65be…. Generator gen-s1-differential.mjs (sha256 633b2a62…). The branch table is now 333 cases (5752aa1), at tinycloud-node-server/tests/fixtures/held-email-v1/s1-differential.json. The 145-case provenance text previously here (casesSha256 39942c9f…) is superseded by 5752aa1; per-case provenance was not re-derived in this pass.
  • Test. held_email::tests::s1_differential_table_matches_ts_verdicts is in the lib run at 93100bf (701 passed, below).

Merges

  • origin/main (966fa5e): merged into this branch. Held lib tests pass.
  • Check configured issuer set at policy registration #396 (origin/skgbafa/tc-1530-issuer-registration, f89ebc3): temp-merged into a throwaway branch on 1e9171b (merge a18904d, no conflicts; not pushed, branch deleted). Held lib tests: 65 pass, 0 fail. Not merged into this PR.

Verification (head 922f02f)

  • At 922f02f, TINYCLOUD_TEST_BLACKHOLE=1, PG at postgres://postgres@127.0.0.1:54413/postgres: cargo test -p tinycloud-node --lib 704 passed, 0 failed (701 + S+L test + 2 cap tests); cargo test -p tinycloud-core --lib 586 passed, 0 failed (includes the Postgres and SQLite index up/down tests). The 3 startup_identity failures seen at 93100bf did not reproduce in this run; they were attributed to the host's /var/run/dstack.sock.
  • cargo fmt --all -- --check: pass at 922f02f.
  • cargo clippy -p tinycloud-core -p tinycloud-node --all-targets -- -D warnings: pass at 922f02f.
  • did_resolution_single_entry guard: verified at cf100fc (throwaway copy of TC-1500: fixed DID method allow-list for UCAN resolution #392 test file, not pushed). Not re-run at 922f02f.
  • SQLite-only run (no TINYCLOUD_TEST_POSTGRES_URL): not re-run at 93100bf. Last run at 414b048: lib 684 passed, 12 failed, all ::postgres variants that require the URL.

Deviations and unverified claims

  • B1 refusal is policy-layer, not a verifier verdict (§3.4).
  • Requirement fixture in held_email.rs changed to {"claims": {"email": "alice@example.com", "emailDomain": "example.com"}} to match B1 minimisation.
  • url origin behaviour change noted above.
  • High-S SIWE secp256k1: divergence. TS @tinycloud/sdk-core@3.3.0-beta.37 (dist/held-credentials/index.js ~196–209, noble Signature.fromCompact().addRecoveryBit().recoverPublicKey(), no low-S check) accepts high-S signatures. Rust recover_eip191_address (held_email.rs ~812–823, recover_from_prehash; ecdsa-0.16.8 verify_prehash enforces low-S) refuses them. Rust refusal kept; no Rust behaviour change. Test: siwe_high_s_signature_is_refused_and_low_s_still_recovers. Recorded on TC-1540 (415cf179), with js-sdk follow-up options.
  • DEL 0x7f in email: TS accepts, Rust refuses; Rust kept; TC-1540 comment a44222d3.
  • F2 and F7: unreconciled, see above.
  • Differential: from published beta.37 (see above).

Mutation pass at head 93100bf

Rows M27 through M52 are from cf100fc. Rows M26, M30 and M29 were re-run at 414b048. M48, M22, M24, M50 and the line 6391 mutants were run on a tree whose production code matches 414b048 plus the test-only commit 93100bf. Each mutant was applied to one line, the named test run, then reverted; git status was then clean.

ID Site Result Killer / note
M27 policy_v3.rs verify_issuer_signature, non-strict verify killed v3_issuer_signature_refuses_small_order_key_and_r
M51 policy_v3.rs v3 capabilities clause → || false killed policy_v2_admits_v3_account_and_v4_accountless_receivers
M48 policy_v3.rs:5847 credential type mismatch && false killed held_email::tests::credential_type_other_than_the_held_vct_is_a_type_mismatch (filter held_email: 1 failed)
M22 policy_v3.rs:5903 requirement-claims call removed killed held_email::tests::requirement_for_another_email_is_not_satisfied (filter held_email: 1 failed)
M24 policy_v3.rs:5851 max-age pin call removed killed held_email::tests::max_age_other_than_the_pinned_year_is_refused_before_the_nonce_is_spent (filter held_email: 1 failed)
M50 policy_v3.rs:5860 issuerKid pin .and_then(Value::as_str) → .and_then(|_| None::<&str>) (kid always None) killed held_email::tests::issuer_kid_other_than_the_pinned_kid_is_not_accepted (filter held_email: 1 failed)
M43 held_email.rs validate_disclosed call removed killed held_email::upper_case_email_disclosure_is_refused
M52 policy_v3.rs request-shape gate, credential clause || false (line 2148) killed held_request_with_a_credential_field_is_refused_by_the_shape_gate (sqlite and postgres)
M26 policy_v3.rs session credential-expiry clamp (line 2460, and_then closure replaced with None) killed held_session_expiry_is_clamped_to_the_credential_expiry (sqlite and postgres)
M30 policy_v3.rs first-pass consume true → false (2271) killed held_nonce_cannot_be_replayed_with_a_fresh_challenge (sqlite and postgres)
M29 policy_v3.rs locked re-evaluation consume false → true (2661) killed held_mint_admits_once_and_returns_an_authorization, held_nonce_cannot_be_replayed_with_a_fresh_challenge, held_session_expiry_is_clamped_to_the_credential_expiry (6 failed across both backends)
line 6391 (non-held path) policy_v3.rs:6391 disclosed vct check || false killed policy_v3::tests::signed_vct_other_than_the_requirement_credential_type_is_refused. Survived the full lib suite at 414b048 (700 passed) before this test existed
line 6391 (non-held path) line 6391 deleted killed same test: None returned where credential-holder-binding-invalid expected
held_email.rs:40 MAX_SD_JWT_CHARS 16384 → 16385 killed held_email::sd_jwt_cap_admits_16384_chars_and_refuses_16385: fails with 16385 chars exceeds the cap, left None, right bad-presentation
held_email.rs:41 MAX_SIWE_MESSAGE_CHARS 2048 → 2049 killed held_email::siwe_message_cap_admits_2048_bytes_and_refuses_2049: fails, 2049 bytes returns bad-siwe where bad-presentation is expected
policy_v3.rs:6609 verify_strict → non-strict verify killed by v3_issuer_signature_refuses_small_order_key_and_r; not by the new S+L test v3_issuer_signature_refuses_real_key_with_s_plus_group_order (922f02f) passes under the mutant, so it does not separate strict from non-strict. The small-order test fails under the mutant (policy_v3.rs:6765).

Not covered: MAX_SIWE_SIGNATURE_CHARS (132). The cap-pin tests above cover only the SD-JWT and SIWE message caps.

Resolved. The mint-path fixture (76c55cd) drives POST /policy/v3/delegations with a valid held presentation. Each mutant was restored; git status was empty afterward.

Coverage: M26, M30, M29 are killed through held_route_tests.rs on both backends. M48, M22, M24, M50 are killed by the held_email::tests cases above, which do not depend on the held route tests.

Former spot-check survivor (now killed). policy_v3.rs:6391 (disclosed vct vs credential_type_id) is pre-existing on main, not in this PR's diff. The held route never reaches it: held_email::verify refuses a mismatched vct first (held_email.rs, profile-mismatch), so the held tests above do not kill it. Killed by policy_v3::tests::signed_vct_other_than_the_requirement_credential_type_is_refused (non-held verify_opencredentials_credential).

#392 guard list (tests/did_resolution_single_entry.rs; not merged here)

Checked by running the #392 test file against this tree (throwaway copy, not pushed). Actual sites on this tree, in the existing format (file relative to src/, enclosing function, primitive):

  • Add verify_strict to SIGNATURE_PRIMITIVES.
  • Add held_email.rs :: verify :: verify_strict (call at line 325).
  • Add held_email.rs :: recover_eip191_address :: recover_from_prehash (call at line 823).
  • Replace policy_v3.rs :: verify_issuer_signature :: verify_bytes with policy_v3.rs :: verify_issuer_signature :: verify_strict (line 6609).
  • Keep both policy_v3.rs :: ingest_status_checkpoint_unmounted :: verify_detached_ed25519 entries (lines 133–134). They are two call sites in one function (policy_v3.rs 3288 and 3409, inside 3139–3473), so the guard expects two entries. The earlier "remove one" advice was wrong.
  • link/payload.rs and tunnel/auth.rs have verify_strict calls but do not appear in the scan output, so they need no entries.
  • Three entries in the TC-1500: fixed DID method allow-list for UCAN resolution #392 list (encryption_network/service.rs :: did_key_public_key, share_email/data_plane.rs :: did_key_public_key, share_email.rs :: verify_did_key_signature) are absent on this tree because their source files are TC-1500: fixed DID method allow-list for UCAN resolution #392-only. They are not a Admit held email credential presentations behind a default-off flag #393 issue.

Open items (not in this PR)

  • None from the mint-path blocker: resolved in 76c55cd.
  • Issuer-row check (M3): merge Check configured issuer set at policy registration #396 before enabling the flag.
  • Production writer for held_credential_nonce: TC-1689. The expires_at index is in place (922f02f). With the flag on, there is no production writer (record_nonce is #[cfg(test)]).
  • Differential: 333 cases in the branch fixture (5752aa1). The earlier "188 not located" note is obsolete.

Naomi and others added 12 commits October 20, 2026 22:48
Add a verifier for held email SD-JWT presentations (profile
tinycloud.email-held/v1, vct opencredentials.email/v1) and wire it into
the policy requirement admission path. The path is inert unless
held_credentials.enabled is set.

- Strict JSON parsing refuses duplicate keys; exact header and claim sets.
- EdDSA verification with verify_strict against the pinned issuer key.
- Holder binding and SIWE checks, EIP-191 recovery against the subject.
- Nonce is consumed before verification, scoped to audience and account.
- Lifetime and not-before rules; audience and profile checks.
- Issuer set lookups refuse a missing kid for configured and legacy sets.
- Fixture copied unmodified from the upstream vector, with provenance.
- Held admission compares every requirement claim with the value the
  holder disclosed and refuses a mismatch as credential-requirement-not-satisfied.
  Minimisation choice: only the required claims are checked. Extra
  disclosures are not refused, because the reviewed fixtures disclose
  both email and emailDomain while requiring one of them.
- A held session never outlives the credential exp: session_expiry takes
  credential_expires_at and caps the expiry at it. v3 and v4 pass None.
- The held email row's maxAgeSeconds must be exactly 31536000 (spec
  held-credential-profile-v1 section 4). Any other value, including an
  absent one, is refused. It never shortens a credential's life.
- held_credential_nonce gains expires_at; a nonce is spendable only while
  now < expires_at. Expired rows are pruned on each consume.
- A malformed caller account returns account-owner-invalid (403) instead of
  a 500 BAD_CONFIG; BAD_CONFIG stays for true server misconfiguration.
- Tests: nonce expiry boundary, prune, account binding, held startup
  audience and enable gate, account-owner-invalid status mapping.
Align held_email verify with verifyHeldPresentation (sdk-core 3.3.0-beta.31):
- accept zero disclosures (spec 4.4)
- refuse a kid without a did: prefix as did-method-not-allowed
- clamp iat to the time range so year 10000 is not-yet-valid
- normalise SIWE URI origin (drop default :443, lowercase host)

Commit the 145-case TS verdict table and replay it through the production
order (precheck, consume nonce, verify). A bad issuer set is refused at
startup in Rust (lib.rs credential_issuer_set()?) and replayed as bad-issuer-set.
…ntial generator

Enforce seconds <= 59 explicitly in rfc3339_shape, matching TS. Add
seconds_must_be_at_most_59 test. Commit gen-s1-differential.mjs with env-
configurable verifier and output paths; provenance now points at it. SIWE
nonces are random per run, so reproduction is checked by TS verdict equality.
…2, TC-1541)

- Add validate-level handler tests for the flag gate, nonce admission and
  replay, spend-on-refusal, subject-only nonce spend, and locked
  re-evaluation that does not respend the nonce.
- Add a no-kid regression test on the held path and a small-order R
  signature test that fails if verify_strict is replaced by verify.
- Pin the combined issuer-set kid-less refusal in policy_v3.
- Expose the minimal policy_v3 items the handler tests need (pub(crate)).
- Verify issuer signatures with verify_strict as well.
…TC-1452, TC-1541)

- Refuse disclosed names outside the requirement claims (policy_v3).
- Route-level test: held presentation refused while the flag is off.
- Origin check for SIWE URI parses with url::Url (https, no userinfo, exact host).
- Test issuer renamed to did:web:issuer.credentials.org.
- Differential provenance records spec-alignment gap and TC-1677 blocker.
Source: js-sdk PR #645 at 4ac7592951ae644418077f567a1fe176e0842b2c
(pre-publication; sdk-core 3.3.0-beta.36 built from source).
Email test issuer is now did:web:issuer.credentials.org (spec 3.3, 6).
All 145 TS verdict codes unchanged vs prior table; only the issuer string
inside the 11 ok cases changed.
@samgbafa

Copy link
Copy Markdown
Contributor Author

CHANGES REQUESTED: see TC-1452 (head 1e9171b)

dryrun added 16 commits October 21, 2026 04:38
…cond times

- SIWE URI origin must equal the audience including a non-default port,
  as TS does. Previously an explicit port was refused and the host alone
  was compared.
- did:pkh body: accept all-lowercase; otherwise require valid EIP-55.
  The subject is reported checksummed, as TS canonicalPkhDid.
- SIWE Expiration/Not Before compare at millisecond precision, as JS Date.
  Tests pin sub-millisecond expiry and not-before against TS verdicts.
- Verifier is the published @tinycloud/sdk-core 3.3.0-beta.37 (gitHead
  c9bb5550); dist/held-credentials/index.js sha256 is unchanged from the
  js-sdk PR #645 build, so verdicts match the prior table except one.
- disc-empty-salt: bad-disclosure -> ok. The row now carries a matching _sd
  digest, so its verdict reflects the empty-salt rule rather than a digest
  mismatch. Rust accepts an empty salt string too.
- Rebuild 14 disclosure-value rows with basePayload(disclosures) so each
  reaches its target rule.
- Provenance: packageVersion, verifierSource, generator, and casesSha256
  updated.
recover_eip191_address refuses high-S via verify_prehash (low-S).
The comment claimed high-S was not refused; corrected. TS beta.37
recovers high-S signatures, so the divergence is recorded on TC-1540.
The reviewer's 188-case differential file was not saved. Rebuild the
added cases from the category list in review f41449f6 (126 generic,
62 disclosure-value) and regenerate all 333 from the published
@tinycloud/sdk-core 3.3.0-beta.37 verifier. The 145 base verdicts are
unchanged.

Eleven cases diverge from the Rust replay and carry a `deviation`
annotation: eight did:pkh casing/encoding rows (Rust refuses as
account-owner-invalid before nonce consume; TS reports bad-config),
siwe-enc-high-s (Rust keeps refusing high-S), json-sub-lone-surrogate,
and cfg-notafter-before-notbefore (Rust fails closed for the whole
issuer set at startup). The replay requires each annotation to name
the exact Rust result and to sit only on a case where the two differ.
Name the function that enforces low-S (recover_from_prehash via
verify_prehash) and reference the s1-differential.json deviation in
place of the tracker ID.
Builds a held presentation signed with the harness issuer key, records its
SIWE nonce as outstanding in the app's own store, and drives the real held
mint route. Covers one admission returning an authorization, replay with a
fresh challenge refused with nonce-replayed, and session expiry clamped to
the credential expiry.
Identity key and R with S = L must be refused as credential-signature-invalid.
Boundary case beside the small-order test; non-strict verify also rejects
S >= L, so this pins the boundary rather than killing verify_strict mutants.
…tial path

Held route: a presentation whose disclosed vct matches the required type is
admitted, and one that does not is refused with profile-mismatch.

Signed-credential path: policy_v3.rs disclosed vct comparison (no held test
reaches it; held_email::verify refuses first) is now killed by
signed_vct_other_than_the_requirement_credential_type_is_refused.
@samgbafa

Copy link
Copy Markdown
Contributor Author

APPROVE: see TC-1452 (reviewed exact head 93100bf)

- m20261105 held_credential_nonce: add idx_held_credential_nonce_expires_at
  for the per-consume prune DELETE WHERE expires_at <= now; down drops the
  index before the table. Up/down tests on SQLite and Postgres.
- held_route_tests: register the held policy via register_held in the
  credential-field shape-gate test.
- held_email: pin MAX_SD_JWT_CHARS (16384) and MAX_SIWE_MESSAGE_CHARS (2048)
  at the boundary through precheck.
- policy_v3: real-key test that S+L is refused as credential-signature-invalid
  while the original signature verifies.
@samgbafa

Copy link
Copy Markdown
Contributor Author

APPROVE: see TC-1452 (reviewed exact head 922f02f)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant