Skip to content

Commit bf23248

Browse files
fix(tcb): close AT-5.1 delegation impersonation + AT-3.1 intermediate epoch gaps
AT-5.1 fix (dag.rs): validate_chain now enforces SHA-256(child.issuer_pubkey) == parent.subject_id. An attacker who knows a parent proof but does not hold the parent subject's private key is rejected with "issuer pubkey does not correspond to parent subject identity". AT-3.1 fix (dag.rs): validate_chain now receives min_epoch and checks every chain node (not just the leaf). A delegator whose key was revoked by epoch advancement cannot serve as a valid chain link even if the leaf was reissued in a later epoch. Error: "delegation chain node epoch predates minimum required epoch". engine.rs: propagate action.min_epoch to validate_chain call. tests.rs: all delegation chain tests updated to use subject_id = SHA-256(delegator.pubkey) (identity model contract); two known_gap_* tests converted to enforcement tests; AT-3.2 test corrected (stale cap must have same rights as fresh cap so the epoch check fires before the rights check). attack_tree_coverage.py: verify_action updated with both fixes; AT-5.1 and AT-3.1 tests now assert Deny; new test_at3_1_intermediate_node_stale_epoch_rejected added. Python suite: 42 tests, 1 KNOWN-GAP (AT-7.5 shadow execution only). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 51842c8 commit bf23248

5 files changed

Lines changed: 232 additions & 120 deletions

File tree

.github/workflows/tcb-tests.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ env:
1111

1212
jobs:
1313
rust-tcb:
14-
name: Rust TCB tests (45 tests)
14+
name: Rust TCB tests (56 in tests.rs + dag/engine/sequence modules)
1515
runs-on: windows-latest
1616
steps:
1717
- uses: actions/checkout@v4
@@ -40,7 +40,7 @@ jobs:
4040
continue-on-error: true
4141

4242
python-attack-harness:
43-
name: Python attack harness (41 tests)
43+
name: Python attack harness (42 tests — AT-5.1 and AT-3.1 fixed)
4444
runs-on: ubuntu-latest
4545
steps:
4646
- uses: actions/checkout@v4

attack_harness/attack_tree_coverage.py

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
AT-6: Crypto boundary (cross-context reuse, nonce uniqueness)
1515
AT-7: Integration boundary (adapter mutation, shadow execution — documented)
1616
17+
AT-5.1 (delegation impersonation) and AT-3.1 (intermediate epoch) are now fixed.
18+
AT-7.5 (shadow execution) remains a KNOWN-GAP — requires architectural enforcement.
19+
1720
Format: each test prints PASS / KNOWN-GAP / FAIL.
1821
"""
1922

@@ -139,6 +142,14 @@ def verify_action(actor_id, resource_hash, required_rights, min_epoch,
139142
return Decision(False, "capability epoch predates minimum required epoch")
140143
if not cap.get("sig_valid", True):
141144
return Decision(False, "root signature verification failed")
145+
# AT-3.1 fix: intermediate (parent) node epoch must also meet min_epoch.
146+
# parent_epoch defaults to the leaf's epoch — stale chains must set it explicitly.
147+
parent_epoch = cap.get("parent_epoch", cap.get("epoch", 0))
148+
if parent_epoch < min_epoch:
149+
return Decision(False, "delegation chain node epoch predates minimum required epoch")
150+
# AT-5.1 fix: issuer pubkey must bind to parent subject_id via SHA-256.
151+
if not cap.get("issuer_binding_valid", True):
152+
return Decision(False, "issuer pubkey does not correspond to parent subject identity")
142153
if cap.get("parent_rights") is not None:
143154
if (cap["rights"] & ~cap["parent_rights"]) != 0:
144155
return Decision(False, "attenuation violation: child rights exceed parent")
@@ -173,7 +184,17 @@ def verify_action(actor_id, resource_hash, required_rights, min_epoch,
173184

174185

175186
def base_cap(actor=None, resource=None, rights=RIGHT_READ, expiry=EXPIRY,
176-
epoch=EPOCH, sig_valid=True, parent_rights=None):
187+
epoch=EPOCH, sig_valid=True, parent_rights=None,
188+
issuer_binding_valid=True, parent_epoch=None):
189+
"""Build a capability proof descriptor.
190+
191+
issuer_binding_valid — simulates AT-5.1 check:
192+
SHA-256(child.issuer_pubkey) == parent.subject_id.
193+
False means the issuer's key does not correspond to the parent subject (impersonation).
194+
parent_epoch — simulates AT-3.1 check: epoch of the parent (intermediate) node.
195+
If parent_epoch < min_epoch, the chain is rejected even if the leaf is fresh.
196+
Defaults to the leaf epoch (assumes parent is at least as fresh as the leaf).
197+
"""
177198
a = actor or ACTOR
178199
r = resource or RESOURCE
179200
ph = hashlib.sha256(a + r + struct.pack(">Q", rights)).digest()
@@ -185,6 +206,8 @@ def base_cap(actor=None, resource=None, rights=RIGHT_READ, expiry=EXPIRY,
185206
"epoch": epoch,
186207
"sig_valid": sig_valid,
187208
"parent_rights": parent_rights,
209+
"issuer_binding_valid": issuer_binding_valid,
210+
"parent_epoch": parent_epoch if parent_epoch is not None else epoch,
188211
"proof_hash": ph,
189212
"canonical_bytes": ph + a + r,
190213
}
@@ -428,23 +451,30 @@ def test_at4_stepwise_accumulation_detection():
428451
# AT-5: Identity binding
429452
# ---------------------------------------------------------------------------
430453

431-
def test_at5_delegation_impersonation_gap_documented():
432-
"""AT-5.1 KNOWN GAP: validate_chain does not check that the delegation chain
433-
issuer key corresponds to the parent's subject_id. An attacker who knows the
434-
parent proof can forge a child without the parent subject's private key.
435-
Fix: SHA-256(child.issuer_pubkey) == parent.subject_id.
436-
This test documents the current (exploitable) behavior."""
437-
# Simulate: attacker creates a child claiming delegation from [0xAA;32]
438-
# but signs it with their own key, not [0xAA;32]'s key.
439-
# In the Python model, parent_rights represents the linked parent's rights.
440-
# The Python model doesn't check issuer_pubkey -> parent.subject_id, mirroring the gap.
441-
attacker_cap = base_cap(ACTOR, RESOURCE, rights=RIGHT_READ, parent_rights=RIGHT_READ)
442-
# The attacker's cap passes attenuation (READ <= READ) and sig_valid=True.
454+
def test_at5_delegation_impersonation_blocked():
455+
"""AT-5.1: Delegation impersonation is now blocked (formerly a known gap, now fixed).
456+
Attacker forges a child proof claiming delegation from a parent they don't control.
457+
issuer_binding_valid=False simulates SHA-256(attacker.pubkey) != parent.subject_id."""
458+
# Attacker's cap: valid sig, rights within parent, but issuer key doesn't match
459+
# the parent's subject_id — the binding check (AT-5.1 fix) rejects it.
460+
attacker_cap = base_cap(ACTOR, RESOURCE, rights=RIGHT_READ, parent_rights=RIGHT_READ,
461+
issuer_binding_valid=False)
443462
action = sealed_action(caps=[attacker_cap])
444463
d = check_action(action)
445-
# KNOWN GAP: currently Permits (impersonation succeeds in Python model too)
446-
assert d.permit, f"KNOWN GAP AT-5.1: expected Permit to document gap, got: {d}"
447-
print("AT-5.1 KNOWN-GAP: delegation impersonation not blocked (fix: bind issuer_pubkey to parent.subject_id)")
464+
assert not d.permit and "issuer" in d.reason, f"AT-5.1 FAILED: expected Deny(issuer...), got: {d}"
465+
print("AT-5.1 PASS: delegation impersonation blocked (issuer_pubkey must bind to parent.subject_id)")
466+
467+
468+
def test_at3_1_intermediate_node_stale_epoch_rejected():
469+
"""AT-3.1: Stale epoch on intermediate (parent) delegation node is now rejected.
470+
Leaf has epoch=EPOCH (fresh), parent has epoch=1 (stale, < min_epoch=EPOCH).
471+
validate_chain must check every node, not just the leaf."""
472+
# Cap with stale parent_epoch — simulates a chain whose delegator is from a revoked epoch.
473+
cap = base_cap(ACTOR, RESOURCE, rights=RIGHT_READ, epoch=EPOCH, parent_epoch=1)
474+
action = sealed_action(caps=[cap], min_epoch=EPOCH)
475+
d = check_action(action)
476+
assert not d.permit and "epoch" in d.reason, f"AT-3.1 FAILED: {d}"
477+
print("AT-3.1 PASS: stale intermediate node epoch rejected (chain-wide epoch enforcement)")
448478

449479

450480
def test_at5_zero_actor_vs_nonzero_subject():
@@ -567,7 +597,8 @@ def test_at7_post_verification_mutation_blocked():
567597
test_at4_stepwise_accumulation_detection()
568598

569599
print("\n--- AT-5: Identity Binding ---")
570-
test_at5_delegation_impersonation_gap_documented()
600+
test_at5_delegation_impersonation_blocked()
601+
test_at3_1_intermediate_node_stale_epoch_rejected()
571602
test_at5_zero_actor_vs_nonzero_subject()
572603

573604
print("\n--- AT-6: Crypto Boundary ---")

freedom-kernel/src/tcb/dag.rs

Lines changed: 124 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,22 @@
22
///
33
/// `validate_chain` walks from a leaf capability proof back to the root, verifying:
44
/// 1. Every node's ed25519 signature is cryptographically valid.
5-
/// 2. Every intermediate node's signature is by the key it claims as issuer.
6-
/// 3. Child rights ⊆ parent rights (attenuation invariant, Bug 5 fix).
5+
/// 2. Every node's epoch >= min_epoch (AT-3.1 fix: intermediate delegation nodes
6+
/// issued in a compromised epoch cannot serve as valid chain links).
7+
/// 3. Every intermediate node's issuer_pubkey binds to the parent's subject_id
8+
/// via SHA-256 (AT-5.1 fix: closes delegation impersonation gap).
9+
/// 4. Child rights ⊆ parent rights (attenuation invariant).
710
///
811
/// The root is identified by `IssuerRef::Root` — its signature is verified
912
/// against the root key passed into verify(). No other node is implicitly trusted.
13+
///
14+
/// Identity model: subject_id = SHA-256(pubkey). This is the invariant that
15+
/// AT-5.1 relies on. All capability issuers must have their pubkey enrolled
16+
/// as SHA-256(pubkey) = subject_id in the granting proof.
17+
#![forbid(unsafe_code)]
18+
1019
use ed25519_dalek::{Signature, VerifyingKey, Verifier};
20+
use sha2::{Digest, Sha256};
1121
use crate::tcb::types::{CapabilityProof, IssuerRef};
1222

1323
const MAX_CHAIN_DEPTH: usize = 16;
@@ -17,11 +27,15 @@ const MAX_CHAIN_DEPTH: usize = 16;
1727
/// `all_proofs` is the full bundle from `CanonicalAction` — intermediate nodes
1828
/// must be present here. Proofs absent from the bundle cannot be trusted.
1929
///
30+
/// `min_epoch` is the caller's minimum required epoch. Every node in the chain
31+
/// must have `epoch >= min_epoch` (AT-3.1 fix).
32+
///
2033
/// Returns `Ok(())` if the chain is valid; `Err(reason)` otherwise.
2134
pub fn validate_chain(
2235
leaf: &CapabilityProof,
2336
all_proofs: &[CapabilityProof],
2437
root_key: &VerifyingKey,
38+
min_epoch: u64,
2539
) -> Result<(), &'static str> {
2640
let mut current = leaf;
2741
let mut depth = 0usize;
@@ -32,50 +46,50 @@ pub fn validate_chain(
3246
}
3347
depth += 1;
3448

35-
let msg = current.signing_message();
49+
// AT-3.1 fix: epoch check on every chain node, not just the leaf.
50+
// A delegator whose key was compromised in epoch N cannot serve as a
51+
// valid chain link even if the leaf was reissued in a later epoch.
52+
if current.epoch < min_epoch {
53+
return Err("delegation chain node epoch predates minimum required epoch");
54+
}
3655

56+
let msg = current.signing_message();
3757
let sig = Signature::from_bytes(&current.signature)
3858
.map_err(|_| "malformed signature encoding")?;
3959

4060
match &current.issuer {
4161
IssuerRef::Root => {
42-
// This node claims to be root-signed — verify against root_key.
4362
root_key
4463
.verify(&msg, &sig)
4564
.map_err(|_| "root signature verification failed")?;
46-
// Reached root without violation — chain is valid.
4765
return Ok(());
4866
}
4967

5068
IssuerRef::Delegated { parent_hash } => {
51-
// Verify this node's signature with the issuer's own key.
52-
// We cannot trust self-reported issuer_pubkey blindly —
53-
// it will be validated when we traverse up to the parent.
5469
let issuer_key = VerifyingKey::from_bytes(&current.issuer_pubkey)
5570
.map_err(|_| "malformed issuer pubkey in proof")?;
5671
issuer_key
5772
.verify(&msg, &sig)
5873
.map_err(|_| "intermediate signature verification failed")?;
5974

60-
// Locate parent in the bundle — reject if absent.
6175
let parent = all_proofs
6276
.iter()
6377
.find(|p| p.proof_hash == *parent_hash)
6478
.ok_or("parent proof not found in bundle")?;
6579

66-
// Verify that `issuer_pubkey` in this node matches what the parent
67-
// was issued to. This closes the impersonation gap:
68-
// a proof claiming a different issuer's key would fail here.
69-
if current.issuer_pubkey != parent.issuer_pubkey
70-
&& matches!(parent.issuer, IssuerRef::Root)
71-
{
72-
// At root level, the "issuer pubkey" of children must come from
73-
// what root actually granted — checked via signature above.
74-
// No additional check needed here; root_key verify in next iteration.
80+
// AT-5.1 fix: the principal this node claims as issuer must be
81+
// the same principal the parent capability was issued to.
82+
// Identity model: subject_id = SHA-256(pubkey).
83+
// Without this, an attacker who knows the parent proof can sign a
84+
// child with their own key and set parent_hash to the real parent —
85+
// the chain would traverse correctly despite no key from the parent's
86+
// subject ever being used.
87+
let claimed_issuer_id: [u8; 32] = Sha256::digest(&current.issuer_pubkey).into();
88+
if claimed_issuer_id != parent.subject_id {
89+
return Err("issuer pubkey does not correspond to parent subject identity");
7590
}
7691

77-
// Bug 5: Attenuation enforcement.
78-
// A delegator cannot grant rights they don't possess.
92+
// Attenuation: a delegator cannot grant rights they don't possess.
7993
if (current.rights & !parent.rights) != 0 {
8094
return Err("attenuation violation: child rights exceed parent");
8195
}
@@ -94,6 +108,10 @@ mod tests {
94108
use rand_core::OsRng;
95109
use sha2::{Digest, Sha256};
96110

111+
fn subject_id_of(sk: &SigningKey) -> [u8; 32] {
112+
Sha256::digest(sk.verifying_key().to_bytes()).into()
113+
}
114+
97115
fn make_root_proof(
98116
root_sk: &SigningKey,
99117
subject_id: [u8; 32],
@@ -116,60 +134,114 @@ mod tests {
116134
};
117135
let msg = p.signing_message();
118136
p.signature = root_sk.sign(&msg).to_bytes();
119-
let hash: [u8; 32] = Sha256::digest(p.to_canonical_bytes()).into();
120-
p.proof_hash = hash;
137+
p.proof_hash = Sha256::digest(p.to_canonical_bytes()).into();
138+
p
139+
}
140+
141+
fn make_delegated_proof(
142+
delegator_sk: &SigningKey,
143+
parent: &CapabilityProof,
144+
subject_id: [u8; 32],
145+
resource_hash: [u8; 32],
146+
rights: u64,
147+
expiry: u64,
148+
epoch: u64,
149+
) -> CapabilityProof {
150+
let issuer_pubkey = delegator_sk.verifying_key().to_bytes();
151+
let mut p = CapabilityProof {
152+
proof_hash: [0u8; 32],
153+
subject_id,
154+
resource_hash,
155+
rights,
156+
expiry,
157+
epoch,
158+
issuer: IssuerRef::Delegated { parent_hash: parent.proof_hash },
159+
signature: [0u8; 64],
160+
issuer_pubkey,
161+
};
162+
let msg = p.signing_message();
163+
p.signature = delegator_sk.sign(&msg).to_bytes();
164+
p.proof_hash = Sha256::digest(p.to_canonical_bytes()).into();
121165
p
122166
}
123167

168+
const RESOURCE: [u8; 32] = [0x02; 32];
169+
const MIN_EPOCH: u64 = 1;
170+
124171
#[test]
125172
fn valid_root_proof_passes() {
126173
let root_sk = SigningKey::generate(&mut OsRng);
127174
let root_vk = root_sk.verifying_key();
128-
let proof = make_root_proof(
129-
&root_sk,
130-
[1u8; 32],
131-
[2u8; 32],
132-
RIGHT_READ,
133-
u64::MAX,
134-
1,
135-
);
136-
assert!(validate_chain(&proof, &[proof.clone()], &root_vk).is_ok());
175+
let proof = make_root_proof(&root_sk, [1u8; 32], RESOURCE, RIGHT_READ, u64::MAX, 1);
176+
assert!(validate_chain(&proof, &[proof.clone()], &root_vk, MIN_EPOCH).is_ok());
137177
}
138178

139179
#[test]
140180
fn wrong_root_key_fails() {
141181
let root_sk = SigningKey::generate(&mut OsRng);
142182
let other_sk = SigningKey::generate(&mut OsRng);
143183
let other_vk = other_sk.verifying_key();
144-
let proof = make_root_proof(&root_sk, [1u8; 32], [2u8; 32], RIGHT_READ, u64::MAX, 1);
145-
assert!(validate_chain(&proof, &[proof.clone()], &other_vk).is_err());
184+
let proof = make_root_proof(&root_sk, [1u8; 32], RESOURCE, RIGHT_READ, u64::MAX, 1);
185+
assert!(validate_chain(&proof, &[proof.clone()], &other_vk, MIN_EPOCH).is_err());
146186
}
147187

148188
#[test]
149189
fn attenuation_violation_rejected() {
150190
let root_sk = SigningKey::generate(&mut OsRng);
151191
let root_vk = root_sk.verifying_key();
152-
// Parent has READ only; child claims READ|WRITE — must be rejected.
153-
let parent = make_root_proof(&root_sk, [1u8; 32], [2u8; 32], RIGHT_READ, u64::MAX, 1);
154192
let child_sk = SigningKey::generate(&mut OsRng);
155-
let parent_hash = parent.proof_hash;
156-
let issuer_pubkey = child_sk.verifying_key().to_bytes();
157-
let mut child = CapabilityProof {
158-
proof_hash: [0u8; 32],
159-
subject_id: [3u8; 32],
160-
resource_hash: [2u8; 32],
161-
rights: RIGHT_READ | RIGHT_WRITE, // violation
162-
expiry: u64::MAX,
163-
epoch: 1,
164-
issuer: IssuerRef::Delegated { parent_hash },
165-
signature: [0u8; 64],
166-
issuer_pubkey,
167-
};
168-
let msg = child.signing_message();
169-
child.signature = child_sk.sign(&msg).to_bytes();
170-
child.proof_hash = Sha256::digest(child.to_canonical_bytes()).into();
171-
let result = validate_chain(&child, &[parent.clone(), child.clone()], &root_vk);
193+
// Parent grants READ to child_sk's identity.
194+
let parent = make_root_proof(&root_sk, subject_id_of(&child_sk), RESOURCE, RIGHT_READ, u64::MAX, 1);
195+
// Child claims READ|WRITE — attenuation violation.
196+
let child = make_delegated_proof(&child_sk, &parent, [3u8; 32], RESOURCE, RIGHT_READ | RIGHT_WRITE, u64::MAX, 1);
197+
let result = validate_chain(&child, &[parent.clone(), child.clone()], &root_vk, MIN_EPOCH);
172198
assert!(result.is_err());
173199
assert!(result.unwrap_err().contains("attenuation"));
174200
}
201+
202+
// AT-5.1: attacker with a different key cannot forge delegation from [0xAA;32]
203+
#[test]
204+
fn at5_delegation_impersonation_rejected() {
205+
let root_sk = SigningKey::generate(&mut OsRng);
206+
let root_vk = root_sk.verifying_key();
207+
let attacker_sk = SigningKey::generate(&mut OsRng);
208+
// Parent issued to [0xAA;32] — NOT to attacker_sk's identity.
209+
let parent = make_root_proof(&root_sk, [0xAA; 32], RESOURCE, RIGHT_READ, u64::MAX, 1);
210+
// Attacker signs a child claiming delegation from that parent, using their own key.
211+
let fake_child = make_delegated_proof(&attacker_sk, &parent, [0x01; 32], RESOURCE, RIGHT_READ, u64::MAX, 1);
212+
let result = validate_chain(&fake_child, &[parent.clone(), fake_child.clone()], &root_vk, MIN_EPOCH);
213+
assert!(result.is_err());
214+
assert!(result.unwrap_err().contains("issuer pubkey"));
215+
}
216+
217+
// AT-3.1: intermediate node with stale epoch fails even if leaf epoch is fresh
218+
#[test]
219+
fn at3_1_intermediate_stale_epoch_rejected() {
220+
let root_sk = SigningKey::generate(&mut OsRng);
221+
let root_vk = root_sk.verifying_key();
222+
let delegator_sk = SigningKey::generate(&mut OsRng);
223+
let next_sk = SigningKey::generate(&mut OsRng);
224+
// Parent issued at epoch 0 (stale).
225+
let parent = make_root_proof(&root_sk, subject_id_of(&delegator_sk), RESOURCE, RIGHT_READ, u64::MAX, 0);
226+
// Child at fresh epoch — but its parent node is stale.
227+
let child = make_delegated_proof(&delegator_sk, &parent, subject_id_of(&next_sk), RESOURCE, RIGHT_READ, u64::MAX, 5);
228+
let result = validate_chain(&child, &[parent.clone(), child.clone()], &root_vk, 5);
229+
assert!(result.is_err());
230+
assert!(result.unwrap_err().contains("epoch"));
231+
}
232+
233+
// Valid two-level chain with proper identity binding
234+
#[test]
235+
fn valid_two_level_chain() {
236+
let root_sk = SigningKey::generate(&mut OsRng);
237+
let root_vk = root_sk.verifying_key();
238+
let delegator_sk = SigningKey::generate(&mut OsRng);
239+
let actor = [0x01; 32];
240+
// Parent granted to delegator_sk's identity.
241+
let parent = make_root_proof(&root_sk, subject_id_of(&delegator_sk), RESOURCE, RIGHT_READ, u64::MAX, 1);
242+
// delegator_sk issues to actor.
243+
let child = make_delegated_proof(&delegator_sk, &parent, actor, RESOURCE, RIGHT_READ, u64::MAX, 1);
244+
let result = validate_chain(&child, &[parent.clone(), child.clone()], &root_vk, MIN_EPOCH);
245+
assert!(result.is_ok());
246+
}
175247
}

0 commit comments

Comments
 (0)