Skip to content

Commit a9f208a

Browse files
authored
Feat/ucan chain validation (#19)
* feat(ucan): validate proof chains and attenuation * feat(auth): validate UCAN chain in middleware * feat(server): enforce UCAN chain on write routes * docs(ucan): clarify wildcard attenuation semantics in doc comment * test(node): add test-only constructors for Db and RepoStore * fix(auth): harden require_ucan_chain middleware, lower log level, and add tests * refactor(server): centralize and deduplicate auth middleware layering
1 parent 491978a commit a9f208a

5 files changed

Lines changed: 696 additions & 119 deletions

File tree

crates/gitlawb-core/src/ucan.rs

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,20 @@ impl Capability {
4141
self.constraints = Some(constraints);
4242
self
4343
}
44+
45+
/// Returns `true` if `self` is a valid attenuation of `parent`.
46+
///
47+
/// A delegated capability is only valid if it is at most as permissive as
48+
/// the parent capability backing it. `"*"` on the **parent**'s resource or
49+
/// action field and `repo/admin` in the parent's action position act as
50+
/// wildcards that cover any delegated value; wildcards on `self` carry no
51+
/// special meaning.
52+
pub fn is_attenuated_by(&self, parent: &Capability) -> bool {
53+
let resource_ok = parent.with == self.with || parent.with == "*";
54+
let action_ok =
55+
parent.can == self.can || parent.can == "*" || parent.can == caps::REPO_ADMIN;
56+
resource_ok && action_ok
57+
}
4458
}
4559

4660
/// Well-known gitlawb capability strings.
@@ -132,6 +146,26 @@ impl Ucan {
132146
}
133147
}
134148

149+
/// Check if this UCAN's not-before time is in the future (token not yet valid).
150+
pub fn is_before_valid(&self) -> bool {
151+
if let Some(nbf) = self.payload.nbf {
152+
Utc::now().timestamp() < nbf
153+
} else {
154+
false
155+
}
156+
}
157+
158+
/// Verify this UCAN's audience matches `expected`.
159+
pub fn verify_audience(&self, expected: &Did) -> Result<()> {
160+
if &self.payload.aud != expected {
161+
return Err(Error::Ucan(format!(
162+
"audience mismatch: expected {expected}, got {}",
163+
self.payload.aud
164+
)));
165+
}
166+
Ok(())
167+
}
168+
135169
/// Verify the signature on this UCAN.
136170
pub fn verify_signature(&self) -> Result<()> {
137171
use crate::identity::verify;
@@ -217,6 +251,10 @@ impl Ucan {
217251
return Err(Error::Ucan("token is expired".to_string()));
218252
}
219253

254+
if self.is_before_valid() {
255+
return Err(Error::Ucan("token is not yet valid".to_string()));
256+
}
257+
220258
for proof_token in &self.payload.prf {
221259
let proof = Self::decode(proof_token)
222260
.map_err(|e| Error::Ucan(format!("failed to decode proof: {e}")))?;
@@ -229,6 +267,17 @@ impl Ucan {
229267
)));
230268
}
231269

270+
// Every delegated capability must be covered by the proof (attenuation).
271+
for cap in &self.payload.att {
272+
let covered = proof.payload.att.iter().any(|p| cap.is_attenuated_by(p));
273+
if !covered {
274+
return Err(Error::Ucan(format!(
275+
"capability attenuation violated: '{}' on '{}' not covered by proof",
276+
cap.can, cap.with
277+
)));
278+
}
279+
}
280+
232281
// Verify the proof's signature and chain recursively
233282
proof.verify_chain()?;
234283
}
@@ -399,4 +448,157 @@ mod tests {
399448
let err = delegated.verify_chain().unwrap_err();
400449
assert!(err.to_string().contains("expired"));
401450
}
451+
452+
#[test]
453+
fn is_before_valid_future_nbf() {
454+
let issuer = Keypair::generate();
455+
let audience = Keypair::generate().did();
456+
let nbf_future = chrono::Utc::now() + chrono::Duration::hours(1);
457+
458+
let payload = UcanPayload {
459+
ucan: "1.0.0".to_string(),
460+
iss: issuer.did(),
461+
aud: audience,
462+
att: vec![],
463+
exp: None,
464+
nbf: Some(nbf_future.timestamp()),
465+
prf: vec![],
466+
};
467+
let signing_bytes = serde_json::to_vec(&payload).unwrap();
468+
let sig = issuer.sign_b64(&signing_bytes);
469+
let ucan = Ucan { payload, s: sig };
470+
471+
assert!(ucan.is_before_valid());
472+
let err = ucan.verify_chain().unwrap_err();
473+
assert!(err.to_string().contains("not yet valid"));
474+
}
475+
476+
#[test]
477+
fn is_before_valid_past_nbf() {
478+
let issuer = Keypair::generate();
479+
let audience = Keypair::generate().did();
480+
let nbf_past = chrono::Utc::now() - chrono::Duration::hours(1);
481+
482+
let payload = UcanPayload {
483+
ucan: "1.0.0".to_string(),
484+
iss: issuer.did(),
485+
aud: audience,
486+
att: vec![Capability::new("gitlawb://repos/test", caps::GIT_PUSH)],
487+
exp: None,
488+
nbf: Some(nbf_past.timestamp()),
489+
prf: vec![],
490+
};
491+
let signing_bytes = serde_json::to_vec(&payload).unwrap();
492+
let sig = issuer.sign_b64(&signing_bytes);
493+
let ucan = Ucan { payload, s: sig };
494+
495+
assert!(!ucan.is_before_valid());
496+
ucan.verify_chain().unwrap();
497+
}
498+
499+
#[test]
500+
fn verify_audience_matches() {
501+
let issuer = Keypair::generate();
502+
let audience = Keypair::generate().did();
503+
let ucan = Ucan::issue(&issuer, audience.clone(), vec![], None).unwrap();
504+
ucan.verify_audience(&audience).unwrap();
505+
}
506+
507+
#[test]
508+
fn verify_audience_mismatch() {
509+
let issuer = Keypair::generate();
510+
let audience = Keypair::generate().did();
511+
let wrong = Keypair::generate().did();
512+
let ucan = Ucan::issue(&issuer, audience, vec![], None).unwrap();
513+
let err = ucan.verify_audience(&wrong).unwrap_err();
514+
assert!(err.to_string().contains("audience mismatch"));
515+
}
516+
517+
#[test]
518+
fn attenuation_valid_subset() {
519+
let alice = Keypair::generate();
520+
let bob = Keypair::generate();
521+
let charlie = Keypair::generate();
522+
523+
// Alice grants Bob push on a specific repo
524+
let root = Ucan::issue(
525+
&alice,
526+
bob.did(),
527+
vec![Capability::new("gitlawb://repos/org/repo", caps::GIT_PUSH)],
528+
None,
529+
)
530+
.unwrap();
531+
532+
// Bob delegates the same capability (exact subset) to Charlie
533+
let delegated = Ucan::delegate(
534+
&bob,
535+
charlie.did(),
536+
vec![Capability::new("gitlawb://repos/org/repo", caps::GIT_PUSH)],
537+
None,
538+
&root,
539+
)
540+
.unwrap();
541+
542+
delegated.verify_chain().unwrap();
543+
}
544+
545+
#[test]
546+
fn attenuation_exceeds_parent_is_rejected() {
547+
let alice = Keypair::generate();
548+
let bob = Keypair::generate();
549+
let charlie = Keypair::generate();
550+
551+
// Alice grants Bob push on one repo only
552+
let root = Ucan::issue(
553+
&alice,
554+
bob.did(),
555+
vec![Capability::new("gitlawb://repos/org/repo", caps::GIT_PUSH)],
556+
None,
557+
)
558+
.unwrap();
559+
560+
// Bob tries to delegate merge (not in the original grant) to Charlie
561+
let delegated = Ucan::delegate(
562+
&bob,
563+
charlie.did(),
564+
vec![Capability::new("gitlawb://repos/org/repo", caps::PR_MERGE)],
565+
None,
566+
&root,
567+
)
568+
.unwrap();
569+
570+
let err = delegated.verify_chain().unwrap_err();
571+
assert!(err.to_string().contains("attenuation violated"));
572+
}
573+
574+
#[test]
575+
fn attenuation_repo_admin_covers_all() {
576+
let alice = Keypair::generate();
577+
let bob = Keypair::generate();
578+
let charlie = Keypair::generate();
579+
580+
// Alice grants Bob repo/admin (superpower)
581+
let root = Ucan::issue(
582+
&alice,
583+
bob.did(),
584+
vec![Capability::new(
585+
"gitlawb://repos/org/repo",
586+
caps::REPO_ADMIN,
587+
)],
588+
None,
589+
)
590+
.unwrap();
591+
592+
// Bob delegates a more specific capability — covered by repo/admin
593+
let delegated = Ucan::delegate(
594+
&bob,
595+
charlie.did(),
596+
vec![Capability::new("gitlawb://repos/org/repo", caps::GIT_PUSH)],
597+
None,
598+
&root,
599+
)
600+
.unwrap();
601+
602+
delegated.verify_chain().unwrap();
603+
}
402604
}

0 commit comments

Comments
 (0)