Summary
role_grant_timestamps (rust-executor/src/perspectives/flow_evaluator.rs:477-491) computes the start of a role-grant eligibility window as .min() over the timestamps of every link on the role instance that names the DID. That filter chain is:
.filter(|l| names_did(&l.data.target))
.filter_map(|l| parse_link_timestamp(&l.timestamp).map(|dt| (dt, l.timestamp)))
.min()
No signature check. No author check. Compare the revocation branch twelve lines below (:503), which does check both — proof.valid == Some(true) plus the authority filter applied by revocation_authorised:
.filter(|l| l.proof.valid == Some(true) && names_did(&l.data.target))
The asymmetry runs in the permissive direction. An unsigned or forged revocation is correctly ignored; an unsigned or forged grant link silently back-dates the window.
Attack
Role SDNA says author: did:admin. roles.rs enforces that author condition on the role instance itself and, via revocation_authorised, on tombstones — but never on the grant timestamp.
- Admin grants Bob the role at 10:00.
- Bob voted at 09:00 — correctly ineligible, the vote does not count.
- Anyone at all (not the admin, no signature needed) writes a second
didProperty link on that instance naming Bob, stamped 08:00.
.min() moves the left edge of the window to 08:00. Bob's 09:00 vote is now eligible.
The roles.rs module doc accepts a back-dated revocation by an admin as in-authority. A back-dated grant by a non-granter is not in that accepted set.
Why the obvious fix is wrong
Adding proof.valid == Some(true) to mirror the revocation branch is not sufficient and can land more permissive than the bug:
Scope for the fix
All three together, or not at all:
- Author filter on grant links, consistent with how
roles.rs gates the instance and tombstones.
- Signature predicate, symmetric with revocations.
- Fallback semantics — decide explicitly what
granted_at: None means now that it can be produced by a rejected link rather than only by an absent one. Falling back to the instance timestamp is not defensible once filtering is real; this should fail closed.
Needs a regression test proving a broken grant signature collapses the window rather than widening it, and one proving a non-granter cannot move the left edge.
Sequencing
Blocked on PR 1 of the #1046 split (proof.valid: None must round-trip as None, not Some(false)). Deliberately kept out of #1062: fixing it there would give that PR a dependency on #1046 landing first, which it does not have today.
Provenance
Found by @lucksus during review of #1062; independently confirmed and correctly re-located (it is flow_evaluator.rs, not roles.rs — that line in roles.rs is test code) by Marvin in #1062 (review), who also identified the fallback trap above.
Summary
role_grant_timestamps(rust-executor/src/perspectives/flow_evaluator.rs:477-491) computes the start of a role-grant eligibility window as.min()over the timestamps of every link on the role instance that names the DID. That filter chain is:No signature check. No author check. Compare the revocation branch twelve lines below (
:503), which does check both —proof.valid == Some(true)plus the authority filter applied byrevocation_authorised:The asymmetry runs in the permissive direction. An unsigned or forged revocation is correctly ignored; an unsigned or forged grant link silently back-dates the window.
Attack
Role SDNA says
author: did:admin.roles.rsenforces that author condition on the role instance itself and, viarevocation_authorised, on tombstones — but never on the grant timestamp.didPropertylink on that instance naming Bob, stamped 08:00..min()moves the left edge of the window to 08:00. Bob's 09:00 vote is now eligible.The
roles.rsmodule doc accepts a back-dated revocation by an admin as in-authority. A back-dated grant by a non-granter is not in that accepted set.Why the obvious fix is wrong
Adding
proof.valid == Some(true)to mirror the revocation branch is not sufficient and can land more permissive than the bug:granted_atis anOption, and the comment at:473-475states the consequence: if nothing survives the filters it staysNone, "so the caller falls back to the instance's own timestamp or fails closed." The instance timestamp is normally earlier than the assignment link. Tightening the filter can therefore pushgranted_attoNone, fall back to an earlier bound, and widen the window.proof.validis exactly the field model_query: provenance and link-level gaps the flow engine has to work around (umbrella) — and making invalid-proof filtering the default #1046 identifies as conflating "never evaluated" with "evaluated and failed". Until the storage fix from the model_query: provenance and link-level gaps the flow engine has to work around (umbrella) — and making invalid-proof filtering the default #1046 split lands,Some(true)cannot carry this weight.Scope for the fix
All three together, or not at all:
roles.rsgates the instance and tombstones.granted_at: Nonemeans now that it can be produced by a rejected link rather than only by an absent one. Falling back to the instance timestamp is not defensible once filtering is real; this should fail closed.Needs a regression test proving a broken grant signature collapses the window rather than widening it, and one proving a non-granter cannot move the left edge.
Sequencing
Blocked on PR 1 of the #1046 split (
proof.valid: Nonemust round-trip asNone, notSome(false)). Deliberately kept out of #1062: fixing it there would give that PR a dependency on #1046 landing first, which it does not have today.Provenance
Found by @lucksus during review of #1062; independently confirmed and correctly re-located (it is
flow_evaluator.rs, notroles.rs— that line inroles.rsis test code) by Marvin in #1062 (review), who also identified the fallback trap above.