feat(server): make the context scope effective - #730
Conversation
ad85ad5 to
d3b63f4
Compare
f412df1 to
0e8bf60
Compare
There was a problem hiding this comment.
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 onscope == Granted::Context. I assume that's deliberate — one place covers both the frontend and the authority — but it means a reader ofgrants_scopeon 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 intoring_vrf_key_access_grantedand accept the duplication, or say ingrants_scopethat 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 bareNotAllowlisted. 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-ringvrfis 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.
d3b63f4 to
33e6dbe
Compare
0e8bf60 to
7030444
Compare
|
CI Status: 17 required jobs green, 15 passed and 2 skipped by path filter. All job results
Commit |
|
The
|
2c17686 to
9087799
Compare
a29b47b to
336c51d
Compare
|
All five addressed. Rebased onto T13's new tip; 1343 tests, clippy and fmt clean, mutation set re-run green on the new base.
|
336c51d to
aa0a2a5
Compare
…ty resolve the grant itself
…ing back to the served chain set
…ub feeds the grant path
… test as documentation
…rized, and test the grant path against a configured Asset Hub
…ot be read, and cover the pairing authority's gate
… a level a host reports
…hing the grant it overrides
…expire a future-stamped manifest
…and the constructed Asset Hub
… 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.
… and key a refusal where the grant reads it
… and record every door's decision
aa0a2a5 to
dff4c93
Compare
|
@lore-app-bot review |
There was a problem hiding this comment.
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 insideremote_authority_call, not before it". It runs before, andruntime.rs:455argues at length for exactly that. Looks like pre-rework rationale left behind;ring_vrf_signinherits it.signing_host.rs:2353— comment says the call stops atKeyNotRegistered, "asserted exactly", and "should becomeis_ok()" later. It already isis_ok(). Also the same call and assertion asthe_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 isUnknown { reason: "Invalid key handle" }, notNotAllowlisted. 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 door —
scope_grantnotes 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 role —
the_pairing_authority_refuses_a_foreign_ring_vrf_key_without_a_grantis refusal-only, on the door the PR argues is higher-risk. A seeded-cache granted assertion looks cheap. - Orphaned cache entries —
manifest_cache_keyas sole constructor is the right fix, but old full-id entries are now unreachable with nothing evicting them. Worth a changeset line. - Nits —
docs/rfcs/product-manifest.md:121continuation comment over-indented;docs/design/product-manifest.md:106drops the design doc's only illustration of the superset rule;lite_username_baseand theDuration::edits inwire_result_shape.rs:634,647are unrelated to #655. - Semver — sessionless
create_account_proofflipsNotAllowlisted→Rejectedasminor. 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.
…xt, and put each doc block back on its own item
… what the comments point at
|
Round two is addressed. Rebased on T13's tip (9861c17), 1355 tests, clippy and fmt clean, the three guards re-mutated red. Blockers:
Your point about comments contradicting the code sent me through the rest of that class, and three more had rotted:
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; Follow-ups, taking your read: storage's deadline at
One local failure, unrelated: |
Closes #655. Stacked on #729, which the signing role needs to resolve a manifest at all.
A manifest granting
contextbehaved 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 in3f6ec081.Both seams change.
3f6ec081revertedcreate_account_proofandring_vrf_signto 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_idis 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
AccountAccessdecision 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 forpeopl.dotalso coversapp.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, matchingring_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_aliasaccepts 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 reasoncreate_account_proofis: 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
NotAllowlistedlike 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-ringvrfis 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.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 warningsclean,cargo +nightly fmt --checkclean. 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:
Ok(())without consulting the manifesta_request_cannot_substitute_for_the_owners_manifest, first assertiongrants_scopenever grantsa_stored_denial_survives_a_context_grant_at_the_authorityallstops satisfying a narrower scopea_grant_of_all_satisfies_context_at_the_authoritya_context_grant_lets_a_foreign_product_prove_with_the_owners_keywith_no_session_a_proof_refusal_never_discloses_whether_a_grant_existsthe_gate_and_the_derivation_act_on_the_same_identitya_refusal_covers_every_executable_of_the_refused_productOk(())the_pairing_authority_refuses_a_foreign_ring_vrf_key_without_a_granta_grantee_cannot_mint_the_owners_alias_in_a_third_partys_contexta_grant_is_refused_when_the_stored_decision_cannot_be_reada_grant_lookup_obeys_the_callers_deadlinesubnames_of_one_product_share_one_cached_manifesta_cache_entry_stamped_in_the_future_is_not_honoureda_grant_lookup_with_a_cold_cache_dials_the_configured_asset_hubaccount_aliasignores the grant and promptsa_context_grant_covers_the_identity_readaccount_aliasdrops the context bindinga_grantee_cannot_read_the_owners_alias_in_a_third_partys_contextan_owner_spelled_differently_still_proves_with_its_own_keya_refusal_recorded_before_this_release_still_overrides_a_granta_refusal_covers_every_executable_of_the_refused_targetlist_ring_vrf_keyscompares the raw calleran_owner_listing_its_own_keys_is_not_prompted_for_its_own_accounta_grant_lookup_obeys_the_callers_deadlinecontextfrom the e2e fixturemake e2e-cross-product-ringvrf, granted phaseRecorded
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.