Skip to content

feat(server): make the context scope effective - #730

Merged
decrypto21 merged 29 commits into
hrc-660-signing-manifestfrom
hrc-655-context-scope
Sep 16, 2026
Merged

decrypto21 merged 29 commits into
hrc-660-signing-manifestfrom
hrc-655-context-scope

Conversation

@decrypto21

@decrypto21 decrypto21 commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Closes #655. Stacked on #729, which the signing role needs to resolve a manifest at all.

A manifest granting context behaved identically to one granting nothing: the runtime admitted the caller and the authority refused it again with the error a non-granting product would produce. #454 introduced the scope, could not make it effective, and removed it in 3f6ec081.

Both seams change. 3f6ec081 reverted create_account_proof and ring_vrf_sign to comparing the key handle against the calling product, so the frontend consult is restored too. Reverting either one alone leaves a granted call refused at the other.

The authority resolves the grant itself, from both methods, rather than being handed a verdict. On a paired host the request arrives over the wire and calling_product_id is a SCALE field decoded from the peer, so a relayed boolean would take the manifest out of the decision and let a peer reach every handle on the device by setting one field.

The gate returns the owner it authorized, and both authorities derive the key from that rather than the spelling they were handed, so authorization and derivation cannot diverge. A handle that does not normalize takes the uniform refusal.

A prior denial still wins. The stored AccountAccess decision is read only, and read after the manifest: reading it first let a denied pair refuse without the chain lookup every other refusal pays, which times out stored denials. It is keyed by the bare product label on both sides, matching the grant it overrides, so a refusal for peopl.dot also covers app.peopl.dot. Decisions written by earlier releases under the full product id are still honoured, because reading only the new shape would let a publisher's grant through on the granted path without even re-asking.

The session is consulted before the grant on create_account_proof, matching ring_vrf_sign. The other order makes the pair of refusals a probe for who granted whom.

The identity read is covered by the grant, and bound like the proof. The contextual alias and the proof come out of one VRF evaluation, so get_account_alias accepts the same grant rather than prompting for what the grant has already authorized. It is held to the caller's own context for the same reason create_account_proof is: guarding only the proof would leave the same bytes reachable through the read, letting a grantee take the alias the owner presents to a third product that granted nothing. A product with no grant still takes the prompt, and a stored refusal still overrides.

Bounded, and the same refusal either way. Resolving a grant can reach dotNS, so the lookup runs under the caller's deadline and cancellation, and under a ceiling of its own for the wire door, where the peer sets no deadline. It is bounded before the authority call rather than inside it: two timers on one budget race, and which fires first would decide whether the caller sees the uniform refusal or a transport error naming a reason, so a lookup that runs out of time answers NotAllowlisted like every other refusal on this path. The manifest cache is keyed by the label it resolves by, and a future-stamped entry is stale. Identifiers carrying no name are rejected: empty labels, control characters, invisible bidi and zero-width formats, whitespace, path separators.

End to end

make e2e-cross-product-ringvrf is the first run anywhere in which a cross-product ring-VRF call is granted rather than refused. Every other run of this path asserts the refusal, which is how the scope shipped inert.

register       peopl.paseo   registered and signed  0xdc044fe6…d8460b
sign-granted   dim2.paseo    owner's own signature, 0xdc044fe6…d8460b
sign-untrusted stash.paseo   NotAllowlisted
sign-again     dim2.paseo    granted again

Real CLI, real wire frames, real chain. The byte comparison against the owner's own signature is the assertion that matters. Re-runnable after #752: the state directory is kept, so a second run reuses the registered account rather than stranding its username.

Verification

1341 tests, clippy -D warnings clean, cargo +nightly fmt --check clean. The battery shows no regression against the base: the cases it does not pass are the same ones on both branches, and are the known unsupported set rather than anything this changes.

Mutation pairs, each against a production function and each checked for which assertion goes red:

mutation test that fails
authority returns Ok(()) without consulting the manifest a_request_cannot_substitute_for_the_owners_manifest, first assertion
grants_scope never grants same test, second assertion, plus both granted arms
drop the prior-denial read a_stored_denial_survives_a_context_grant_at_the_authority
all stops satisfying a narrower scope a_grant_of_all_satisfies_context_at_the_authority
frontend reverts to the bare comparison a_context_grant_lets_a_foreign_product_prove_with_the_owners_key
grant check moved back before the session with_no_session_a_proof_refusal_never_discloses_whether_a_grant_exists
derive from the caller's spelling, not the gate's owner the_gate_and_the_derivation_act_on_the_same_identity
key the prior denial by full product id a_refusal_covers_every_executable_of_the_refused_product
pairing authority's gate returns Ok(()) the_pairing_authority_refuses_a_foreign_ring_vrf_key_without_a_grant
drop the context binding a_grantee_cannot_mint_the_owners_alias_in_a_third_partys_context
stored decision read fails open a_grant_is_refused_when_the_stored_decision_cannot_be_read
move the gate outside the timeout scope a_grant_lookup_obeys_the_callers_deadline
key the manifest cache by full target subnames_of_one_product_share_one_cached_manifest
drop the future-stamp guard a_cache_entry_stamped_in_the_future_is_not_honoured
role has no Asset Hub configured a_grant_lookup_with_a_cold_cache_dials_the_configured_asset_hub
account_alias ignores the grant and prompts a_context_grant_covers_the_identity_read
account_alias drops the context binding a_grantee_cannot_read_the_owners_alias_in_a_third_partys_context
the guard re-derives the caller from the request an_owner_spelled_differently_still_proves_with_its_own_key
the legacy refusal key is no longer read a_refusal_recorded_before_this_release_still_overrides_a_grant
the refusal is keyed by the full target a_refusal_covers_every_executable_of_the_refused_target
list_ring_vrf_keys compares the raw caller an_owner_listing_its_own_keys_is_not_prompted_for_its_own_account
the lookup ignores the caller's deadline a_grant_lookup_obeys_the_callers_deadline
drop context from the e2e fixture make e2e-cross-product-ringvrf, granted phase

Recorded

Every door records its decision. A granted cross-product access is logged, where before only a refusal was, and a refusal carries why it was refused: not granted, the user denied it, the stored decision could not be read, the manifest did not parse, or no manifest resolved. The wire still answers one refusal for every reason; this is the operator's copy.

Not recorded: whether the caller id was bound by this Host or asserted by a peer. That distinction is known where the request is built, not where the gate runs, so carrying it needs either a field on the wire request or a parameter through both roles' authority APIs. Worth having before anything is built on these records.

@github-actions github-actions Bot added documentation Improvements or additions to documentation rfc labels Sep 10, 2026
@decrypto21
decrypto21 marked this pull request as draft September 11, 2026 06:24
@decrypto21
decrypto21 force-pushed the hrc-660-signing-manifest branch from ad85ad5 to d3b63f4 Compare September 11, 2026 06:46
@decrypto21
decrypto21 force-pushed the hrc-655-context-scope branch from f412df1 to 0e8bf60 Compare September 11, 2026 07:18

@filvecchiato filvecchiato left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Draft, so treat this as early input rather than a merge gate. The central design call is right: the authority resolves the grant from the owner's manifest itself rather than accepting a verdict relayed by the caller. On the pairing path calling_product_id is a SCALE field decoded from the peer, so a relayed boolean would let a peer reach every handle on the device by setting one field. Changing both seams, with the reasoning for why reverting either one alone still leaves a granted call refused, is the correct shape.

Two things I'd want settled before this leaves draft.

1. Normalization stops at the gate; key derivation still sees the raw handle

ring_vrf_key_access_granted normalizes handle.dot_ns_identifier to decide access. Both authorities then pass the unmodified &request.key_handle on to resolve_ring_vrf_key_for_ring / resolve_registered_ring_vrf_key. So authorization is decided about peopl.dot while entropy would be derived from PEOPL.DOT.

"Not covered" acknowledges this and pins it with a canary asserting KeyNotRegistered, and I agree it isn't exploitable today. My concern is structural: safety now rests on a registry lookup miss rather than on a check. A later "make the registry lookup case-insensitive" change — which would look like a reasonable fix in isolation — silently converts this into key confusion, and the canary goes red with a message about spelling rather than about key derivation.

The fix is already in hand. cross_product_scope_target returns the normalized target as Option<String>, and both call sites in account.rs discard it with .is_none(). normalize_product_account_id sits ~20 lines below it in runtime.rs. Rebuilding the handle from the value the gate already computed — and doing the same at sso_responder's wire entry point — makes the gate and the derivation agree by construction instead of by coincidence.

Since this PR is what widens the owner check from a raw comparison to a normalized one, and so is what starts admitting alternate spellings, I'd close it here rather than defer it.

2. The new grant tests run on services with no Asset Hub installed

signing_runtime_with_ring_resolver calls SigningHostRole::new_with_ring_resolver, which reaches new_with_ring_resolver_on (signing_host.rs:171). That fixture builds RuntimeServices::new(...) and never calls install_asset_hub_genesis_hash — verified on #729's branch, where the other three direct-construction fixtures were each given the install and this one was not.

So a_context_grant_lets_a_foreign_product_prove_with_the_owners_key, a_request_cannot_substitute_for_the_owners_manifest, a_stored_denial_survives_a_context_grant_at_the_authority and a_grant_of_all_satisfies_context_at_the_authority all execute against a role with no Asset Hub. They pass because cache_grant() pre-seeds the manifest cache and root_manifest serves the cache before consulting the genesis hash.

That is exactly the blind spot #729's description identifies as the reason #660 survived: "every one pre-seeded the manifest cache, which root_manifest serves before consulting the genesis hash. The hash could have been removed from the pairing role too and the suite would have stayed green." The same is true here — deleting #729's install would leave every one of these green.

the_signing_role_adjudicates_grants_against_the_asset_hub_it_installed does assert the install, but it runs on signing_runtime(), the other fixture. The assertion and the grant tests never touch the same services, so the mutation row "remove #729's Asset Hub install" is carried entirely by that one test rather than by the suite it is meant to protect.

Two fixes, and I'd take both: install in new_with_ring_resolver_on so the ring-VRF tests resolve manifests the way production does, and add one granted case that reaches the chain rather than the cache, so the seeded-cache shortcut is not the only path under test. Threading the hash through RuntimeServices::new (Lore's #5 on #729) would make the first unnecessary.

Smaller

  • The stored-denial read lives in the generic helper. It's in grants_scope, gated on scope == Granted::Context. I assume that's deliberate — one place covers both the frontend and the authority — but it means a reader of grants_scope on the storage path has to know the check silently doesn't apply to them, and a future scope implying account access won't inherit it. Either hoist it into ring_vrf_key_access_granted and accept the duplication, or say in grants_scope that the denial read is scope-specific by design.
  • One non-uniform refusal, on the peer-supplied field. A caller id that fails normalization returns RingVrfError::Unknown { reason } with the error string, where everything else returns bare NotAllowlisted. Carried over from the old code, and it only tells a peer about the string it just sent — but it's the single distinguishable outcome on a path whose whole design is one refusal for every reason.
  • No e2e on the pairing wire path, which you flag. make e2e-cross-product-ringvrf is a real addition — first run anywhere where this path is granted rather than refused, which is how the scope shipped inert — but it drives the local signing role.

I read the diffs and surrounding code; I did not run the suite.

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown

CI Status: 17 required jobs green, 15 passed and 2 skipped by path filter.

All job results
job result
android-bindings success
changes success
changeset-guard success
codegen success
e2e skipped
explorer success
ios-bindings success
ios-swift success
licenses success
playground success
release-guard success
rust success
ts-client success
ts-debugger success
ts-host success
wasm-provider success
workflow-lint skipped

Commit 90b04198 · run log

@decrypto21

Copy link
Copy Markdown
Contributor Author

The context scope is effective; the rest hardens the path that now reaches the network and the keys.

  • Grant resolution: each authority resolves the owner's manifest itself, from both methods. A relayed verdict would take the manifest out of the decision on the wire, where calling_product_id is peer-asserted.
  • One identity: the gate returns the normalized owner it authorized and both authorities derive from that, not the caller's spelling. Previously safe only because a registry lookup missed.
  • Context binding: a granted proof is held to the caller's own context, so a grantee cannot mint the alias the owner presents to a third product that granted nothing.
  • Denial: fails closed on a storage error, keyed by bare label to match the grant, read after the manifest so a denied pair costs what other refusals cost. Decisions under a full product id re-prompt once after upgrading.
  • Bounded: the gate runs inside remote_authority_call, so a dotNS stall cannot outlive the caller's timeout. Cache keyed by resolving label; a future-stamped entry is stale.
  • Identifiers: empty labels, control characters, invisible bidi and zero-width formats, whitespace and path separators are rejected. Each reached the grant key, the cache key, the prompt and the logs.
  • Coverage: pairing_host.rs had no tests and blanking its gate left all 871 green; it is now driven at the authority. Grant tests run against a configured Asset Hub, one taking the chain branch not the seeded cache.
  • Identity read: get_account_alias accepts a context grant. The alias and the proof come out of one VRF evaluation, so a grantee that may create_proof already holds it; prompting asked the user to approve what the grant authorized. No grant still prompts, a stored refusal still overrides.
  • Audit: a granted cross-product access is now logged, where before only the refusal was. It is not revocable: no prompt, no stored decision, and no surface for a user to record one about a pair they were never asked about.

@decrypto21

Copy link
Copy Markdown
Contributor Author

All five addressed. Rebased onto T13's new tip; 1343 tests, clippy and fmt clean, mutation set re-run green on the new base.

  • Normalization stops at the gate: the gate now returns the normalized owner it authorized and both authorities derive the key from that, so authorization and derivation cannot diverge at any entry point. the_gate_and_the_derivation_act_on_the_same_identity; the old canary now asserts success rather than KeyNotRegistered.
  • Grant tests with no Asset Hub: fixed upstream by T13 taking the hash through RuntimeServices::new, so no fixture can omit it. Added a_grant_lookup_with_a_cold_cache_dials_the_configured_asset_hub, which takes the chain branch instead of the seeded cache, so the blind spot is covered by a test rather than by inspection.
  • Stored-denial read in the generic helper: kept there, now documented as scope-specific by design, including that a later scope implying account access must name itself rather than inherit it.
  • Non-uniform refusal on the peer-supplied field: a caller id that fails normalization now takes the same bare NotAllowlisted as everything else on that path.
  • No e2e on the pairing wire: it works, but not in this PR. It needs --product-config on pairing-host — the app side consults the grant before delegating, so without a local config its only source is dotNS. truapi-host: honour manifest-declared cross-product trust for local development #524 scopes the flag as "the CLI reads trustedProducts" and feat(cli): honour trustedProducts from the local product config #702 describes it unscoped, so it reads as a gap in feat(cli): honour trustedProducts from the local product config #702 rather than a new feature. Held on a local branch; happy to raise it against your area.

@decrypto21
decrypto21 force-pushed the hrc-655-context-scope branch from 336c51d to aa0a2a5 Compare September 15, 2026 04:33
@decrypto21
decrypto21 marked this pull request as draft September 15, 2026 06:00
decrypto21 and others added 15 commits September 15, 2026 13:32
…ot be read, and cover the pairing authority's gate
… is refused

The script discarded its --base-path on exit while registering a lite username
on a real chain. That directory held the only copy of the account owning the
name, so run 1 took "headless" and every later run asked for it with a fresh
mnemonic and was told it was taken. Keep the state under target/, overridable
via E2E_STATE_DIR.

lite_username_base refused a short prefix and a non-lowercase one with the same
"at least 6 lowercase ASCII letters" message. Making a prefix unique by adding
digits or a hyphen therefore reported as too short. Split the two.
@decrypto21
decrypto21 force-pushed the hrc-655-context-scope branch from aa0a2a5 to dff4c93 Compare September 15, 2026 08:11
@decrypto21
decrypto21 marked this pull request as ready for review September 15, 2026 09:50
@filvecchiato

Copy link
Copy Markdown
Collaborator

@lore-app-bot review

@filvecchiato filvecchiato left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 46c1048. The earlier round looks settled: the gate returns the normalized owner and both authorities derive from it, new_with_ring_resolver_on installs the Asset Hub, the denial read is documented as scope-specific, and a non-normalizing caller id takes the uniform refusal.

Design call still reads right. Each authority resolving the owner's manifest itself is the only sound choice when calling_product_id is decoded from the peer, and a_request_cannot_substitute_for_the_owners_manifest pins it: identical request, one line of manifest different, opposite outcomes. Good negative controls throughout.

Blockers below are mostly comments, not code.

Doc comments spliced onto the wrong items

Six places where a new item was inserted between an existing doc block and the item it documented. Compiles clean; clippy won't see it.

Doc now attached to Written for
bounded_cross_product_scope_target (runtime.rs:452) cross_product_scope_target (:494)
enum RefusedBecause (product_manifest.rs:236) grants_scope (:270)
enum StoredDecision (product_manifest.rs:348) stored_account_decision (:358)
const MANIFEST_RESOLUTION_CEILING (product_manifest.rs:402) ring_vrf_key_access_granted (:489)
cache_manifest_at (runtime/tests.rs:288) cache_manifest_entry
a_grant_is_refused_when_the_stored_decision_cannot_be_read (signing_host.rs:2245) the casing test the "3f6ec081's message says…" paragraph belongs to

Worst is the fourth: ring_vrf_key_access_granted — the function the authority-side gate rests on — now has no doc, while a Duration const is documented as "Whether calling_product_id may act on handle's ring-VRF key".

Comments that contradict the code

  • capabilities/account.rs:190 — "runs inside remote_authority_call, not before it". It runs before, and runtime.rs:455 argues at length for exactly that. Looks like pre-rework rationale left behind; ring_vrf_sign inherits it.
  • signing_host.rs:2353 — comment says the call stops at KeyNotRegistered, "asserted exactly", and "should become is_ok()" later. It already is is_ok(). Also the same call and assertion as the_gate_and_the_derivation_act_on_the_same_identity (:2338) — drop one.
  • runtime/tests.rs:502 — "An id that does not normalize is not the caller, so it takes the same refusal as a product that granted nothing." It's the key handle, not the caller, and the assertion is Unknown { reason: "Invalid key handle" }, not NotAllowlisted. Worth fixing rather than deleting: it's accidentally documenting a real asymmetry, since the frontend does leak a distinguishable malformed-handle error where the authority doesn't.

require_own_context and the ownership check disagree on "same product"

ring_vrf_key_access_granted short-circuits on full normalized ids (:503); require_own_context compares bare labels (:461). Caller peopl.paseo vs handle peopl.dot: the first demands a grant, the second takes the owner branch and leaves the context unconstrained. Narrow (needs a cross-TLD peer id plus a same-label grant), but two functions answering "is this the owner?" differently is what this PR otherwise collapses.

Related: context.product_id is the one identity here not normalized — straight off the request payload, while the doc says "Both identities come from the gate, already normalized". A grantee spelling its context DIM2.paseo is refused. Fails closed, but it's the hazard an_owner_spelled_differently_still_proves_with_its_own_key exists to prevent, one line below.

Deadline is now a per-stage budget

bounded_cross_product_scope_target arms cx.timeout(), then remote_authority_call(&cx, …) arms it again — a 1s request can wait ~2s. I follow the argument for not nesting them (two timers on one budget makes the refusal shape scheduling-dependent), but it isn't stated anywhere and a_grant_lookup_obeys_the_callers_deadline only asserts < 5s.

Storage didn't get the fix

capabilities/platform.rs:187 still calls the unbounded cross_product_scope_target, though storage reads now go through the same scope_grant chain lookup. The PR's own argument applies unchanged. Bound it or say why it's exempt.

Smaller

  • 30s serial head-of-line on the wire doorscope_grant notes the responder dispatches serially, then caps at 30s. Better than unbounded, still a long stall reachable from a peer-chosen field.
  • No granted case on the pairing rolethe_pairing_authority_refuses_a_foreign_ring_vrf_key_without_a_grant is refusal-only, on the door the PR argues is higher-risk. A seeded-cache granted assertion looks cheap.
  • Orphaned cache entriesmanifest_cache_key as sole constructor is the right fix, but old full-id entries are now unreachable with nothing evicting them. Worth a changeset line.
  • Nitsdocs/rfcs/product-manifest.md:121 continuation comment over-indented; docs/design/product-manifest.md:106 drops the design doc's only illustration of the superset rule; lite_username_base and the Duration:: edits in wire_result_shape.rs:634,647 are unrelated to #655.
  • Semver — sessionless create_account_proof flips NotAllowlistedRejected as minor. Called out in the changeset and the doctest accepts either, so flagging only to keep it a decision.

Security

Fails closed on every path I traced: unreachable chain, unparseable manifest, unreadable permission store, expired ceiling, non-normalizing caller or handle. The legacy-key fallback in stored_account_decision is the subtle one and it's right — reading only the new shape would turn a stored "no" into a "yes" on upgrade silently, since the granted path never prompts; a_refusal_recorded_before_this_release_still_overrides_a_grant asserts both halves.

has_well_formed_labels changes a public function's behaviour, correctly framed as a hazard list rather than an ASCII allowlist, with an_internationalized_identifier_is_still_an_identifier guarding the other direction. A few invisibles fall outside the ranges (U+00AD, U+3164, U+FFA0), consistent with the stated non-goal. Refusal logging is safe — caller and owner are both post-normalization.


Before merge: the doc splices and the three contradicted comments (in a PR that documents its reasoning this heavily, a comment asserting the opposite of the code is worse than none), plus the require_own_context mismatch and unnormalized context.product_id — this PR is what starts admitting alternate spellings, so I'd settle those here. Storage's deadline and the pairing role's positive case read as fine follow-ups.

Read the diff and surrounding code; did not run the suite or the e2e.

@decrypto21

Copy link
Copy Markdown
Contributor Author

Round two is addressed. Rebased on T13's tip (9861c17), 1355 tests, clippy and fmt clean, the three guards re-mutated red.

Blockers:

  • Sixth doc splice: the casing paragraph fused onto a_grant_is_refused_when_the_stored_decision_cannot_be_read was a leftover copy, so it is gone rather than re-homed. an_owner_spelled_differently_still_proves_with_its_own_key already carries a rewritten version.
  • an_owner_naming_its_own_key_in_another_spelling_is_admitted_over_the_wire dropped with its comment. It does duplicate the_gate_and_the_derivation_act_on_the_same_identity, which asserts strictly more: both spellings succeed and produce the same signature.
  • require_own_context now uses access.caller == access.owner, the gate's own test on the gate's own values.
  • context.product_id is normalized before the label comparison, so a grantee spelling its context DIM2.paseo is admitted.

Your point about comments contradicting the code sent me through the rest of that class, and three more had rotted:

  • product_manifest.rs said "the function two above" for ring_vrf_key_access_granted, which sits below require_own_context.
  • The dropped comment cited derive_ring_vrf_entropy (:426); no such function exists, it is ring_vrf_entropy, and the line number matched by coincidence.
  • signing_host.rs said "the gate two statements above" in a doc on a test.

All three now name what they point at, so moving code cannot rot them again.

Nits: RFC continuation realigned; design doc keeps the superset illustration with the context example as its own entry; Duration qualification reverted, the local import covers it; changeset says orphaned full-id cache entries are no longer read and nothing evicts them.

Follow-ups, taking your read: storage's deadline at capabilities/platform.rs:187, a granted case on the pairing role, and the provenance marker on the grant log, which needs either a wire-struct field or a parameter threaded through both roles' authority APIs.

mutation red
ownership back to bare labels a_cross_network_namesake_is_not_treated_as_the_owner
context left unnormalized a_grantee_may_spell_its_own_context_differently
alias guard removed at its call site a_grantee_cannot_read_the_owners_alias_in_a_third_partys_context

One local failure, unrelated: sso_handler_contracts is a trybuild fixture expecting rustc's older E0599 wording. truapi-macros is untouched here and CI passes it.

@decrypto21
decrypto21 merged commit d26f292 into hrc-660-signing-manifest Sep 16, 2026
29 checks passed
@decrypto21
decrypto21 deleted the hrc-655-context-scope branch September 16, 2026 09:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation rfc

Projects

None yet

2 participants