Skip to content

Commit 705b69e

Browse files
committed
feat: harden authz layer with cycle detection, SRL, and issuance bounds
Security hardening based on deep review of the capability-token design space: - chain: reject duplicate warrant ids in a delegation chain (cycle detection); distinct-id re-entry (A->B->A) remains allowed. - warrant: enforce unknown-extension-key rejection on decode (was dead code); add 128-bit UUIDv7 warrant-id generation with full entropy. - pop: bind tool-call arguments into the PoP tuple (tool_args_digest) for confused-deputy defense; order-independent canonical digest. - constraint: add validate_attenuation (decidable subset check) and issuance-time static attenuation in DelegatedWarrantBuilder via with_merchant/with_resource/with_payment/with_tool narrowers. - issue_bounds: control-plane defense-in-depth constraining what a holder may delegate (merchant/asset/rail/scheme/payee/cap ceilings), carried in the reserved ledgerflow.issue_bounds extension. - srl: add Signed Revocation List (versioned, anti-rollback, additive) in core, and SrlSync bridging it onto the persistent FileRevocationStore for multi-node SaaS revocation propagation. - Add security_hardening (19 tests) and srl_sync_behaviors (3 tests); all new code paths covered by mutation testing (0 surviving mutants).
1 parent 13ab0fe commit 705b69e

19 files changed

Lines changed: 1468 additions & 27 deletions

File tree

bin/ledgerflow-cli/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ fn render_sample_payment_fixture() -> Result<String> {
9090
created_at_ms: 2_000,
9191
nonce: "nonce-1".to_string(),
9292
payment_identifier: Some("payment-1".to_string()),
93+
tool_args: std::collections::BTreeMap::new(),
9394
approvals: Vec::new(),
9495
},
9596
)?;

crates/ledgerflow-core/src/approval.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,7 @@ mod tests {
452452
request_hash: request_hash.to_string(),
453453
accepted_hash: "accepted-hash".to_string(),
454454
payment_payload_digest: "payment-digest".to_string(),
455+
tool_args_digest: None,
455456
approvals_digest: Some(PopTuple::approvals_digest(approvals)),
456457
nonce: "nonce-1".to_string(),
457458
created_at_ms: 10_000,

crates/ledgerflow-core/src/chain.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99
//! Capability attenuation is enforced with **runtime conjunction** (I4): a
1010
//! request must satisfy the constraints of *every* node in the chain. This
1111
//! avoids undecidable static subset checking of URL patterns.
12+
//!
13+
//! A presented chain must not contain the same warrant id twice: duplicate
14+
//! ids would allow re-arranging nodes (or a same-node-cycle) to confuse the
15+
//! chain's depth accounting. This is checked before any linkage work.
16+
17+
use std::collections::HashSet;
1218

1319
use crate::{
1420
constraint::{AuthorizationContext, Verify},
@@ -105,6 +111,18 @@ pub fn verify_chain(
105111
// Trust anchor: the root issuer must be trusted.
106112
trusted.verify_root(root)?;
107113

114+
// Cycle detection: the same warrant id must not appear twice. Duplicate
115+
// ids would permit re-ordering nodes (or a self-referencing cycle) that
116+
// bypasses the depth accounting below.
117+
let mut seen = HashSet::with_capacity(chain.len());
118+
for node in &chain.warrants {
119+
if !seen.insert(node.id.as_slice()) {
120+
return Err(AuthorizationError::DuplicateWarrantInChain {
121+
warrant_id: node.id_hex(),
122+
});
123+
}
124+
}
125+
108126
// Per-node checks: envelope signature, time bounds, runtime-conjunction
109127
// constraints, and delegation capability on non-leaf nodes.
110128
for (index, node) in chain.warrants.iter().enumerate() {

crates/ledgerflow-core/src/constraint.rs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,151 @@ impl PaymentConstraint {
234234
}
235235
}
236236

237+
/// Validates that `child` is a valid static attenuation of `parent`.
238+
///
239+
/// This performs a **conservative, decidable** subset check: every value that
240+
/// `parent` allows must also be allowed by `child`. It is used at issuance
241+
/// time (in [`crate::typestate::DelegatedWarrantBuilder`]) to reject a child
242+
/// that would expand capabilities *before* the warrant is signed.
243+
///
244+
/// The check is conservative in two ways:
245+
///
246+
/// - Empty allowlists mean "any", so a child that adds restrictions to a
247+
/// parent with an empty list is valid (narrowing), but a child that empties
248+
/// a parent's non-empty list is rejected (widening).
249+
/// - Unknown/unbounded dimensions (e.g. arbitrary host names under a suffix)
250+
/// are judged by the same allow-list semantics, never by pattern-language
251+
/// containment (which can be undecidable).
252+
pub fn validate_attenuation(parent: &Constraint, child: &Constraint) -> Result<()> {
253+
match (parent, child) {
254+
(Constraint::Merchant(p), Constraint::Merchant(c)) => {
255+
// Child's allowed merchant ids must be a subset of parent's
256+
// (when the parent restricts them).
257+
if !p.merchant_ids.is_empty() {
258+
for id in &c.merchant_ids {
259+
if !p.merchant_ids.contains(id) {
260+
return Err(AuthorizationError::AttenuationViolation {
261+
dimension: "merchant_ids".to_string(),
262+
detail: format!("merchant `{id}` not allowed by parent"),
263+
});
264+
}
265+
}
266+
}
267+
// Child's host suffixes must be a subset of parent's.
268+
if !p.host_suffixes.is_empty() {
269+
for suffix in &c.host_suffixes {
270+
if !p.host_suffixes.contains(suffix) {
271+
return Err(AuthorizationError::AttenuationViolation {
272+
dimension: "host_suffixes".to_string(),
273+
detail: format!("host suffix `{suffix}` not allowed by parent"),
274+
});
275+
}
276+
}
277+
}
278+
Ok(())
279+
}
280+
(Constraint::Resource(p), Constraint::Resource(c)) => {
281+
if !p.http_methods.is_empty() {
282+
for method in &c.http_methods {
283+
if !p.http_methods.iter().any(|m| m.eq_ignore_ascii_case(method)) {
284+
return Err(AuthorizationError::AttenuationViolation {
285+
dimension: "http_methods".to_string(),
286+
detail: format!("method `{method}` not allowed by parent"),
287+
});
288+
}
289+
}
290+
}
291+
if !p.path_prefixes.is_empty() {
292+
for prefix in &c.path_prefixes {
293+
if !p.path_prefixes.iter().any(|pp| prefix.starts_with(pp)) {
294+
return Err(AuthorizationError::AttenuationViolation {
295+
dimension: "path_prefixes".to_string(),
296+
detail: format!("path prefix `{prefix}` not under a parent prefix"),
297+
});
298+
}
299+
}
300+
}
301+
Ok(())
302+
}
303+
(Constraint::Tool(p), Constraint::Tool(c)) => {
304+
for (dimension, parent_list, child_list) in [
305+
("tool_names", &p.tool_names, &c.tool_names),
306+
("model_providers", &p.model_providers, &c.model_providers),
307+
("action_labels", &p.action_labels, &c.action_labels),
308+
] {
309+
if parent_list.is_empty() {
310+
continue;
311+
}
312+
for value in child_list {
313+
if !parent_list.contains(value) {
314+
return Err(AuthorizationError::AttenuationViolation {
315+
dimension: dimension.to_string(),
316+
detail: format!("`{value}` not allowed by parent"),
317+
});
318+
}
319+
}
320+
}
321+
Ok(())
322+
}
323+
(Constraint::Payment(p), Constraint::Payment(c)) => {
324+
// Amount cap can only shrink.
325+
if c.max_per_charge > p.max_per_charge {
326+
return Err(AuthorizationError::AttenuationViolation {
327+
dimension: "max_per_charge".to_string(),
328+
detail: format!(
329+
"child cap {} exceeds parent cap {}",
330+
c.max_per_charge, p.max_per_charge
331+
),
332+
});
333+
}
334+
// Child assets must be a subset of parent assets.
335+
if !p.allowed_assets.is_empty() {
336+
for asset in &c.allowed_assets {
337+
if !p.allowed_assets.iter().any(|pa| pa == asset) {
338+
return Err(AuthorizationError::AttenuationViolation {
339+
dimension: "allowed_assets".to_string(),
340+
detail: format!("asset `{}` not allowed by parent", asset.asset),
341+
});
342+
}
343+
}
344+
}
345+
// Rails are a distinct enum type; check them separately.
346+
if !p.allowed_rails.is_empty() {
347+
for rail in &c.allowed_rails {
348+
if !p.allowed_rails.contains(rail) {
349+
return Err(AuthorizationError::AttenuationViolation {
350+
dimension: "allowed_rails".to_string(),
351+
detail: format!("rail `{rail:?}` not allowed by parent"),
352+
});
353+
}
354+
}
355+
}
356+
for (dimension, parent_list, child_list) in [
357+
("allowed_schemes", &p.allowed_schemes, &c.allowed_schemes),
358+
("payee_ids", &p.payee_ids, &c.payee_ids),
359+
] {
360+
if parent_list.is_empty() {
361+
continue;
362+
}
363+
for value in child_list {
364+
if !parent_list.contains(value) {
365+
return Err(AuthorizationError::AttenuationViolation {
366+
dimension: dimension.to_string(),
367+
detail: format!("`{value}` not allowed by parent"),
368+
});
369+
}
370+
}
371+
}
372+
Ok(())
373+
}
374+
// Different constraint kinds are never comparable; treat as invalid.
375+
_ => Err(AuthorizationError::AttenuationViolation {
376+
dimension: "constraint_kind".to_string(),
377+
detail: "parent and child constraint kinds differ".to_string(),
378+
}),
379+
}
380+
}
381+
237382
/// Unified `Verify` trait for constraint evaluation.
238383
pub trait Verify {
239384
/// Checks this constraint against the given context.

crates/ledgerflow-core/src/error.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ pub enum AuthorizationError {
5252
DelegationDepthExceeded { presented: u8, allowed: u8 },
5353
#[error("the warrant chain is empty")]
5454
EmptyChain,
55+
#[error("warrant `{warrant_id}` appears more than once in the chain (cycle detected)")]
56+
DuplicateWarrantInChain { warrant_id: String },
5557
#[error("child issuer does not match parent holder (I1)")]
5658
DelegationAuthorityMismatch,
5759
#[error("child depth {actual} does not equal parent depth + 1 (expected {expected}) (I2)")]
@@ -66,6 +68,8 @@ pub enum AuthorizationError {
6668
ParentHashMismatch,
6769
#[error("the root issuer is not trusted")]
6870
UntrustedIssuer { key_id: String },
71+
#[error("child constraint violates monotonic attenuation on `{dimension}`: {detail}")]
72+
AttenuationViolation { dimension: String, detail: String },
6973
#[error("the warrant has been revoked")]
7074
WarrantRevoked,
7175
#[error("the holder key has been revoked")]
@@ -86,6 +90,10 @@ pub enum AuthorizationError {
8690
ApprovalsDigestMismatch,
8791
#[error("unknown warrant extension key `{key}` (extensions are frozen in v1)")]
8892
UnknownExtension { key: String },
93+
#[error("SRL version {presented} does not advance the applied version {applied} (anti-rollback)")]
94+
SrlVersionRegression { presented: u64, applied: u64 },
95+
#[error("the SRL signature is invalid")]
96+
InvalidSrlSignature,
8997
}
9098

9199
/// Errors returned while encoding or decoding LedgerFlow wire payloads.
@@ -97,4 +105,6 @@ pub enum WireError {
97105
Serialization(String),
98106
#[error("failed to decode the payload from CBOR: {0}")]
99107
Deserialization(String),
108+
#[error("unknown warrant extension key `{key}` (extensions are frozen in v1)")]
109+
UnknownExtension { key: String },
100110
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
//! Issuance bounds: what a warrant's holder may delegate to descendants.
2+
//!
3+
//! A root warrant can carry an optional `IssueBounds` that constrains the
4+
//! warrants its holder is allowed to issue (delegate). This provides
5+
//! defense-in-depth for the control plane: even if a root signing key is
6+
//! compromised, the attacker can only delegate within the bounds, never
7+
//! arbitrarily.
8+
//!
9+
//! Bounds are carried in the warrant's `extensions` map under the reserved
10+
//! key [`ISSUE_BOUNDS_EXTENSION`], encoded as CBOR (see [`IssueBounds`]).
11+
//! Unknown extension keys are rejected by the decoder, so the reserved key is
12+
//! the only way to express bounds on the wire.
13+
14+
use serde::{Deserialize, Serialize};
15+
16+
use crate::{
17+
error::WireResult,
18+
warrant::{AssetRef, PaymentRail, CborCodec, Warrant},
19+
};
20+
21+
/// Reserved extension key carrying the issuance bounds.
22+
pub const ISSUE_BOUNDS_EXTENSION: &str = "ledgerflow.issue_bounds";
23+
24+
/// Limits on the warrants this warrant's holder may issue (delegate).
25+
///
26+
/// Every dimension is a *ceiling*: a delegated warrant's corresponding
27+
/// constraint must be no wider than the bound. Empty lists mean "no
28+
/// restriction" (any value allowed), mirroring the warrant constraint
29+
/// semantics.
30+
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
31+
pub struct IssueBounds {
32+
/// Merchant ids the issuer may delegate to (empty = any).
33+
pub merchant_ids: Vec<String>,
34+
/// Host suffixes the issuer may delegate to (empty = any).
35+
pub host_suffixes: Vec<String>,
36+
/// HTTP methods the issuer may delegate (empty = any).
37+
pub http_methods: Vec<String>,
38+
/// Path prefixes the issuer may delegate (empty = any).
39+
pub path_prefixes: Vec<String>,
40+
/// Assets the issuer may delegate (empty = any).
41+
pub assets: Vec<AssetRef>,
42+
/// Rails the issuer may delegate (empty = any).
43+
pub rails: Vec<PaymentRail>,
44+
/// Schemes the issuer may delegate (empty = any).
45+
pub schemes: Vec<String>,
46+
/// Payees the issuer may delegate to (empty = any).
47+
pub payee_ids: Vec<String>,
48+
/// Maximum per-charge amount the issuer may delegate (`None` = inherit
49+
/// the parent's cap, which is already monotonic).
50+
pub max_per_charge: Option<u128>,
51+
/// Maximum delegation depth for issued warrants.
52+
pub max_issue_depth: Option<u8>,
53+
}
54+
55+
impl IssueBounds {
56+
/// Creates unrestricted bounds (no limits beyond what the parent itself
57+
/// already constrains).
58+
#[must_use]
59+
pub const fn unrestricted() -> Self {
60+
Self {
61+
merchant_ids: Vec::new(),
62+
host_suffixes: Vec::new(),
63+
http_methods: Vec::new(),
64+
path_prefixes: Vec::new(),
65+
assets: Vec::new(),
66+
rails: Vec::new(),
67+
schemes: Vec::new(),
68+
payee_ids: Vec::new(),
69+
max_per_charge: None,
70+
max_issue_depth: None,
71+
}
72+
}
73+
74+
/// Encodes the bounds as CBOR bytes (for embedding in `extensions`).
75+
pub fn encode_cbor(&self) -> WireResult<Vec<u8>> {
76+
<Self as CborCodec>::encode_cbor(self)
77+
}
78+
79+
/// Decodes bounds from CBOR bytes.
80+
pub fn decode_cbor(bytes: &[u8]) -> WireResult<Self> {
81+
<Self as CborCodec>::decode_cbor(bytes)
82+
}
83+
}
84+
85+
impl CborCodec for IssueBounds {}
86+
87+
impl Warrant {
88+
/// Returns the issuance bounds carried by this warrant, if any.
89+
pub fn issue_bounds(&self) -> Option<IssueBounds> {
90+
let bytes = self.extensions.get(ISSUE_BOUNDS_EXTENSION)?;
91+
IssueBounds::decode_cbor(bytes).ok()
92+
}
93+
}

crates/ledgerflow-core/src/lib.rs

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@ pub mod approval;
2323
pub mod chain;
2424
pub mod constraint;
2525
pub mod error;
26+
pub mod issue_bounds;
2627
pub mod pop;
2728
pub mod proof_builder;
2829
pub mod revocation;
30+
pub mod srl;
2931
pub mod trust;
3032
pub mod typestate;
3133
pub mod verification;
@@ -38,25 +40,28 @@ pub use crate::{
3840
},
3941
chain::{VerifiedChainAuthorization, WarrantChain, verify_chain, verify_link},
4042
constraint::{
41-
AuthorizationContext, Constraint, MerchantConstraint, PaymentConstraint,
42-
ResourceConstraint, ToolConstraint, Verify, verify_all as verify_all_constraints,
43+
validate_attenuation, AuthorizationContext, Constraint, MerchantConstraint,
44+
PaymentConstraint, ResourceConstraint, ToolConstraint, Verify,
45+
verify_all as verify_all_constraints,
4346
},
47+
issue_bounds::{ISSUE_BOUNDS_EXTENSION, IssueBounds},
4448
error::{AuthorizationError, Result, WireError, WireResult},
4549
pop::{POP_SIGN_DOMAIN, PopProof, PopTuple, verify_freshness},
4650
proof_builder::ProofBuilder,
4751
revocation::{InMemoryRevocationCheck, RevocationCheck, RevocationDecision},
52+
srl::{SRL_SIGN_DOMAIN, SignedRevocationList, SrlEntry, SrlState},
4853
trust::{TrustedIssuer, TrustedIssuers},
4954
typestate::{DelegatedWarrantBuilder, WarrantBuilder},
5055
verification::{
5156
AuthorizationInput, ToolArguments, VerifiedAuthorization, WarrantExt, verify_authorization,
5257
},
5358
warrant::{
5459
AssetRef, CborCodec, DEFAULT_CHALLENGE_TTL_MS, DEFAULT_CLOCK_SKEW_MS, DEFAULT_MAX_DEPTH,
55-
DEFAULT_PROOF_FRESHNESS_MS, DEFAULT_WARRANT_TTL_SECS, MAX_DELEGATION_DEPTH,
56-
MAX_WARRANT_CBOR_BYTES, MAX_WARRANT_TTL_SECS, PaymentRail, PaymentSubjectKind,
57-
PaymentSubjectRef, SignatureEnvelope, SignerRef, SigningAlgorithm, SigningKeyPair,
58-
WARRANT_SIGN_DOMAIN, WARRANT_VERSION_V1, Warrant, WarrantMetadata, generate_warrant_id,
59-
sha256_prefixed,
60+
DEFAULT_PROOF_FRESHNESS_MS, DEFAULT_WARRANT_TTL_SECS, KNOWN_EXTENSION_KEYS,
61+
MAX_DELEGATION_DEPTH, MAX_WARRANT_CBOR_BYTES, MAX_WARRANT_TTL_SECS, PaymentRail,
62+
PaymentSubjectKind, PaymentSubjectRef, SignatureEnvelope, SignerRef, SigningAlgorithm,
63+
SigningKeyPair, WARRANT_SIGN_DOMAIN, WARRANT_VERSION_V1, Warrant, WarrantMetadata,
64+
generate_warrant_id, generate_warrant_id_128, hex_encode_bytes, sha256_prefixed,
6065
},
6166
};
6267

0 commit comments

Comments
 (0)