Repository navigation
Conversation
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.
Contributor
Author
|
CHANGES REQUESTED: see TC-1452 (head 1e9171b) |
added 16 commits
October 21, 2026 04:38
…ing, credential type
…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.
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.
Contributor
Author
|
APPROVE: see TC-1452 (reviewed exact head 922f02f) |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Head
922f02f. Sincecf100fc:5752aa1(333-case differential),c93a3ac,f7936e0,76c55cd(real-time held mint fixture and mint-path tests),414b048(S≥L test),93100bf(disclosedvcttests, held route and signed-credential path),922f02f(expires_atindex onheld_credential_noncewith up/down tests on both backends; held route test usesregister_held; SIWE 2048 and sd_jwt 16384 cap pins; real-key S+L test). Containsorigin/mainat966fa5e. 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
requirement_claims_satisfiedruns afterverifyinvalidate_held_email_admission(policy_v3.rs). Disclosed names outsiderequirement.claimsare refused withcredential-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.session_expiryis capped bycredential_expires_at. Test:session_expiry_never_outlives_the_held_credential.HELD_EMAIL_MAX_AGE_SECONDS = 31_536_000, checked byheld_email_max_age_pinned. Test:held_email_max_age_is_pinned_to_the_row_value.held_email.rs, enforced inprecheckbefore nonce consume. Test:oversized_presentation_is_refused_before_decoding. Plus the grammar and encoding tests listed under F-items below.tinycloud-node-server/Cargo.tomladdsurl = "2"(with existingk256,sha3).Cargo.lock,test/m1-realdata-e2e/Cargo.lock,test/w5-policy-runtime-node-e2e/Cargo.lockupdated.cargo metadata --lockedexits 0 for root, m1, and w5.Findings F1–F8 (test names)
okin TS and Rust (aligned).uri_matches_audiencenow usesurl::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:///hostnow parses and matches; not pinned by a test. High-S SIWE: divergence, Rust refuses (see Deviations). DEL 0x7f inemail: TS accepts, Rust refuses (kept); recorded on TC-1540.kidas bare DID (Rustbad-header; TSdid-method-not-allowed/issuer-not-accepted).iatafter year 9999 (Rustbad-payload; TSnot-yet-valid). Empty SIWE resource (Rust refuses before consume; TS after). Left as deviations.m20261105_000000_held_credential_nonceaddsExpiresAtand indexidx_held_credential_nonce_expires_at(serves the per-consume pruneDELETE WHERE expires_at <= now). Tests:expired_nonce_is_refused_and_spends_nothing,expired_rows_are_pruned_on_consume,nonce_expiry_boundary_is_exclusive, andexpires_at_index_follows_up_and_down(SQLite and Postgres,m20261105_000000_held_credential_nonce::tests).require_typ:typ_other_than_vc_sd_jwt_is_refused.ACCOUNT_OWNER_INVALID→ 403account-owner-invalid. Test:malformed_account_owner_maps_to_forbidden_not_internal_error.did:web:localhost(TS requires a dot). Left as deviations.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;_sdmembership →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).held_presentation_is_refused_while_the_flag_is_off(policy_v3/held_route_tests.rs,on_both_backends!) asserts 403held-credentials-disabled. Gate mutation made it fail on both backends.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.vct.held_presentation_with_the_required_vct_is_admitted(200) andheld_presentation_with_a_mismatched_vct_is_refused(403profile-mismatch, from theheld_email.rsprofile check) inheld_route_tests.rs,on_both_backends!. Both useoutstanding_presentation_with_vctinheld_fixtures.rs. The held route never reachespolicy_v3.rsline 6391 (see Mutation pass).consume=false, different-account nonce burn) run through the mint harness inheld_route_tests.rs(76c55cd), on both backends.TC-1541 items
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.verify_strict.small_order_r_signature_is_refused_on_the_held_path(policy_v3.rsverify_issuer_signature). Alsov3_issuer_signature_refuses_small_order_key_and_r(M27).Issuer and differential
did:web:issuer.credentials.org(TEST_ISSUER/TEST_KIDinheld_email.rs), per spec §3.3/§6.@tinycloud/sdk-core@3.3.0-beta.37(registrygitHeadc9bb5550de7cce9330d41995dd1157062c75916b, Node v22.23.2). Verifierdist/held-credentials/index.jssha25604db65be…. Generatorgen-s1-differential.mjs(sha256633b2a62…). The branch table is now 333 cases (5752aa1), attinycloud-node-server/tests/fixtures/held-email-v1/s1-differential.json. The 145-case provenance text previously here (casesSha25639942c9f…) is superseded by5752aa1; per-case provenance was not re-derived in this pass.held_email::tests::s1_differential_table_matches_ts_verdictsis in the lib run at93100bf(701 passed, below).Merges
origin/main(966fa5e): merged into this branch. Held lib tests pass.origin/skgbafa/tc-1530-issuer-registration,f89ebc3): temp-merged into a throwaway branch on1e9171b(mergea18904d, no conflicts; not pushed, branch deleted). Held lib tests: 65 pass, 0 fail. Not merged into this PR.Verification (head
922f02f)922f02f,TINYCLOUD_TEST_BLACKHOLE=1, PG atpostgres://postgres@127.0.0.1:54413/postgres:cargo test -p tinycloud-node --lib704 passed, 0 failed (701 + S+L test + 2 cap tests);cargo test -p tinycloud-core --lib586 passed, 0 failed (includes the Postgres and SQLite index up/down tests). The 3startup_identityfailures seen at93100bfdid not reproduce in this run; they were attributed to the host's/var/run/dstack.sock.cargo fmt --all -- --check: pass at922f02f.cargo clippy -p tinycloud-core -p tinycloud-node --all-targets -- -D warnings: pass at922f02f.did_resolution_single_entryguard: verified atcf100fc(throwaway copy of TC-1500: fixed DID method allow-list for UCAN resolution #392 test file, not pushed). Not re-run at922f02f.TINYCLOUD_TEST_POSTGRES_URL): not re-run at93100bf. Last run at414b048: lib 684 passed, 12 failed, all::postgresvariants that require the URL.Deviations and unverified claims
held_email.rschanged to{"claims": {"email": "alice@example.com", "emailDomain": "example.com"}}to match B1 minimisation.urlorigin behaviour change noted above.@tinycloud/sdk-core@3.3.0-beta.37(dist/held-credentials/index.js~196–209, nobleSignature.fromCompact().addRecoveryBit().recoverPublicKey(), no low-S check) accepts high-S signatures. Rustrecover_eip191_address(held_email.rs~812–823,recover_from_prehash;ecdsa-0.16.8verify_prehashenforces 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.email: TS accepts, Rust refuses; Rust kept; TC-1540 commenta44222d3.Mutation pass at head
93100bfRows M27 through M52 are from
cf100fc. Rows M26, M30 and M29 were re-run at414b048. M48, M22, M24, M50 and the line 6391 mutants were run on a tree whose production code matches414b048plus the test-only commit93100bf. Each mutant was applied to one line, the named test run, then reverted;git statuswas then clean.policy_v3.rsverify_issuer_signature, non-strictverifyv3_issuer_signature_refuses_small_order_key_and_rpolicy_v3.rsv3 capabilities clause →|| falsepolicy_v2_admits_v3_account_and_v4_accountless_receiverspolicy_v3.rs:5847credential type mismatch&& falseheld_email::tests::credential_type_other_than_the_held_vct_is_a_type_mismatch(filterheld_email: 1 failed)policy_v3.rs:5903requirement-claims call removedheld_email::tests::requirement_for_another_email_is_not_satisfied(filterheld_email: 1 failed)policy_v3.rs:5851max-age pin call removedheld_email::tests::max_age_other_than_the_pinned_year_is_refused_before_the_nonce_is_spent(filterheld_email: 1 failed)policy_v3.rs:5860issuerKid pin.and_then(Value::as_str)→.and_then(|_| None::<&str>)(kid alwaysNone)held_email::tests::issuer_kid_other_than_the_pinned_kid_is_not_accepted(filterheld_email: 1 failed)held_email.rsvalidate_disclosedcall removedheld_email::upper_case_email_disclosure_is_refusedpolicy_v3.rsrequest-shape gate, credential clause|| false(line 2148)held_request_with_a_credential_field_is_refused_by_the_shape_gate(sqlite and postgres)policy_v3.rssession credential-expiry clamp (line 2460,and_thenclosure replaced withNone)held_session_expiry_is_clamped_to_the_credential_expiry(sqlite and postgres)policy_v3.rsfirst-pass consumetrue→false(2271)held_nonce_cannot_be_replayed_with_a_fresh_challenge(sqlite and postgres)policy_v3.rslocked re-evaluation consumefalse→true(2661)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)policy_v3.rs:6391disclosedvctcheck|| falsepolicy_v3::tests::signed_vct_other_than_the_requirement_credential_type_is_refused. Survived the full lib suite at414b048(700 passed) before this test existedNonereturned wherecredential-holder-binding-invalidexpectedheld_email.rs:40MAX_SD_JWT_CHARS16384 → 16385held_email::sd_jwt_cap_admits_16384_chars_and_refuses_16385: fails with16385 chars exceeds the cap, leftNone, rightbad-presentationheld_email.rs:41MAX_SIWE_MESSAGE_CHARS2048 → 2049held_email::siwe_message_cap_admits_2048_bytes_and_refuses_2049: fails, 2049 bytes returnsbad-siwewherebad-presentationis expectedpolicy_v3.rs:6609verify_strict→ non-strictverifyv3_issuer_signature_refuses_small_order_key_and_r; not by the new S+L testv3_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) drivesPOST /policy/v3/delegationswith a valid held presentation. Each mutant was restored;git statuswas empty afterward.Coverage: M26, M30, M29 are killed through
held_route_tests.rson both backends. M48, M22, M24, M50 are killed by theheld_email::testscases above, which do not depend on the held route tests.Former spot-check survivor (now killed).
policy_v3.rs:6391(disclosedvctvscredential_type_id) is pre-existing onmain, not in this PR's diff. The held route never reaches it:held_email::verifyrefuses a mismatchedvctfirst (held_email.rs,profile-mismatch), so the held tests above do not kill it. Killed bypolicy_v3::tests::signed_vct_other_than_the_requirement_credential_type_is_refused(non-heldverify_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):verify_stricttoSIGNATURE_PRIMITIVES.held_email.rs :: verify :: verify_strict(call at line 325).held_email.rs :: recover_eip191_address :: recover_from_prehash(call at line 823).policy_v3.rs :: verify_issuer_signature :: verify_byteswithpolicy_v3.rs :: verify_issuer_signature :: verify_strict(line 6609).policy_v3.rs :: ingest_status_checkpoint_unmounted :: verify_detached_ed25519entries (lines 133–134). They are two call sites in one function (policy_v3.rs3288 and 3409, inside 3139–3473), so the guard expects two entries. The earlier "remove one" advice was wrong.link/payload.rsandtunnel/auth.rshaveverify_strictcalls but do not appear in the scan output, so they need no entries.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)
76c55cd.held_credential_nonce: TC-1689. Theexpires_atindex is in place (922f02f). With the flag on, there is no production writer (record_nonceis#[cfg(test)]).5752aa1). The earlier "188 not located" note is obsolete.