Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
72fff6c
security: close #374 by removal — the KEL travels in the committed bu…
bordumb Jul 18, 2026
1132c4a
security: close #262 — signed KELs at every remaining untrusted boundary
bordumb Jul 18, 2026
5b88273
fix: formatting
bordumb Jul 18, 2026
d0949e6
fix: format
bordumb Jul 18, 2026
c3ee75b
sdk: presentation evidence export — the agent hands its verifier-read…
bordumb Jul 18, 2026
2638b64
sdk: regenerate Node type declarations for the KelUnauthenticated status
bordumb Jul 18, 2026
b18dcee
fix: the two failures the mirror removal left behind — and the bundle…
bordumb Jul 18, 2026
7d5cb52
verifier: the Valid presentation verdict names the proven subject root
bordumb Jul 18, 2026
3b51db8
merge main — keep the bundle_kels (root + device) resolver through ma…
bordumb Jul 18, 2026
521d980
fix: delegated attachments authenticate everywhere — and the schema c…
bordumb Jul 18, 2026
3af6408
fix: downstream replays use the rehydrated events the boundary authen…
bordumb Jul 18, 2026
83d4637
sdk(node): delegateAgent mints a KERI-delegated agent, not an attesta…
bordumb Jul 18, 2026
12b4097
sdk(node): the relying party lives here — one call authenticates a pr…
bordumb Jul 18, 2026
bac2bbb
docs: the report is the only API — relying parties never inspect evid…
bordumb Jul 18, 2026
e5c2400
lint: bindings are a presentation layer — check-binding-boundary
bordumb Jul 18, 2026
dd0e09c
sdk(node): the contract ships with the package — conformance vectors …
bordumb Jul 18, 2026
beeed3f
sdk(node): mintChallengeNonce — the nonce shape is the contract's, no…
bordumb Jul 18, 2026
e12bcb1
style: rustfmt on the binding-boundary check
bordumb Jul 18, 2026
1a40b5d
fix: formatting
bordumb Jul 18, 2026
fafa2eb
ci: the four reds — fmt-proof boundary annotations, OsRng, regenerate…
bordumb Jul 18, 2026
0ec6ce9
style: rustfmt pass on the boundary check
bordumb Jul 18, 2026
3762b7f
sdk(node): the hand-maintained native shadow catches up with the KERI…
bordumb Jul 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ jobs:
cargo run -p xtask -- check-admission-policy
cargo run -p xtask -- check-command-drift
cargo run -p xtask -- check-verify-path-completeness
cargo run -p xtask -- check-binding-boundary
cargo run -p xtask -- gen-error-docs --check
- name: Architecture boundary checks
run: |
Expand Down
19 changes: 19 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,25 @@ graph TD

Violations are caught by `cargo deny check bans` and `cargo clippy` (`disallowed-methods`).

## The Report Is the Only API (relying parties)

A relying party — the market, any server authenticating `Auths-Presentation`
requests, any consumer of a verify entrypoint — **never inspects evidence**.
Anything an RP needs to act on (subject, the proven root, capabilities, expiry,
freshness) must be a field on the verdict/report, set by the verifier only after
it proved the fact. If an RP finds itself parsing a KEL, an attachment, or an
ACDC out of the evidence it was handed, that is a missing verdict field — fix it
in `auths-verifier`, never in the consumer. Precedent: `subjectRoot` (the
delegator a seal-checked replay proved) was added to the Valid presentation
verdict for exactly this reason.

The companion rule: **RPs re-implement no wire.** Header parsing, base64
conventions, verify-request assembly, and the verdict→denial doctrine live once,
behind the SDK's relying-party surface (`packages/auths-node`
`presentationNonce` / `authenticatePresentation`). An RP keeps only what is
irreducibly its own: storage (the single-use challenge store, principal
records) and grant policy (which capabilities admit which action).

## Port Inventory

Ports are trait interfaces that decouple domain logic from infrastructure. Production adapters live in the appropriate infra crate under `src/adapters/`. Test doubles live in `auths-test-utils/src/fakes/` or `auths-test-utils/src/mocks/`.
Expand Down
1 change: 1 addition & 0 deletions crates/auths-api/tests/cases/rp_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ fn principal(caps: &[&str]) -> VerifiedPrincipal {
let verdict = PresentationVerdict::Valid {
issuer: IdentityDID::parse("did:keri:Eissuer").expect("issuer"),
subject: CanonicalDid::parse("did:keri:Eagent").expect("subject"),
subject_root: CanonicalDid::parse("did:keri:Eagent").expect("subject"),
caps: caps
.iter()
.map(|c| Capability::parse(c).expect("cap"))
Expand Down
2 changes: 1 addition & 1 deletion crates/auths-cli/src/commands/artifact/verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -700,7 +700,7 @@ fn resolve_identity_key(

// The identity_did is now PROVEN equal to the authenticated KEL's
// inception (RT-005), so it is safe to return as the certified signer.
let (root_did, _kel) = trust.into_parts();
let (root_did, _kel, _device_kels) = trust.into_parts();
Ok((pk, CanonicalDid::new_unchecked(root_did)))
} else {
// Resolve public key from the issuer DID
Expand Down
44 changes: 38 additions & 6 deletions crates/auths-cli/src/commands/credential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,14 @@ pub enum CredentialSubcommand {
help = "The base64url challenge nonce from /v1/auth/challenge."
)]
nonce: String,

/// Also emit the wire-ready evidence (credential + attachment-carrying KELs + TEL)
/// a relying party without a registry replica verifies the presentation against.
#[arg(
long = "with-evidence",
help = "Emit JSON with the header AND the verifiable evidence bundle (for relying parties that hold no replica of your KEL)."
)]
with_evidence: bool,
},
}

Expand Down Expand Up @@ -231,6 +239,7 @@ pub fn handle_credential(
said,
audience,
nonce,
with_evidence,
} => {
let ctx = build_auths_context(&repo_path, env_config, Some(passphrase_provider))?;
let subject_alias = KeyAlias::new_unchecked(subject);
Expand All @@ -250,14 +259,37 @@ pub fn handle_credential(
.map_err(anyhow::Error::new)?;
let header = format!("Auths-Presentation {token}");

if is_json_mode() {
JsonResponse::success(
"credential present",
serde_json::json!({ "authorization": header }),
let evidence = if with_evidence {
Some(
auths_sdk::domains::credentials::load_presentation_evidence(
&ctx,
&subject_alias,
&said,
)
.map_err(anyhow::Error::new)?,
)
.print()?;
} else {
println!("{header}");
None
};

match evidence {
Some(evidence) => {
// --with-evidence is machine-facing: always JSON, one object a
// relying party splices into its verify request.
JsonResponse::success(
"credential present",
serde_json::json!({ "authorization": header, "evidence": evidence }),
)
.print()?;
}
None if is_json_mode() => {
JsonResponse::success(
"credential present",
serde_json::json!({ "authorization": header }),
)
.print()?;
}
None => println!("{header}"),
}
Ok(())
}
Expand Down
48 changes: 48 additions & 0 deletions crates/auths-cli/src/commands/id/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,53 @@ pub fn handle_id(
kel_attachments.push(hex::encode(att));
}

// Delegated device KELs: commits are signed by a DEVICE AID, so a
// stateless verifier needs each device KEL the root anchored — not
// only the root's. Enumerate the registry for dip-rooted KELs whose
// delegator is this identity, exporting each with its attachments.
let root_prefix_str = prefix.as_str().to_string();
let mut device_prefixes: Vec<String> = Vec::new();
let _ = registry.visit_identities(&mut |candidate| {
if candidate != root_prefix_str {
device_prefixes.push(candidate.to_string());
}
std::ops::ControlFlow::Continue(())
});
let mut device_kels = Vec::new();
for device in device_prefixes {
let device_prefix = auths_keri::Prefix::new_unchecked(device.clone());
let mut device_events: Vec<auths_keri::Event> = Vec::new();
let _ = registry.visit_events(&device_prefix, 0, &mut |e| {
device_events.push(e.clone());
std::ops::ControlFlow::Continue(())
});
let delegated_by_this_root = device_events
.first()
.and_then(|e| e.delegator())
.is_some_and(|d| d.as_str() == root_prefix_str);
if !delegated_by_this_root {
continue;
}
let mut device_attachments = Vec::with_capacity(device_events.len());
for e in &device_events {
let seq = e.sequence().value();
let att = registry
.get_attachment(&device_prefix, seq)
.map_err(|err| anyhow::anyhow!("failed to read device KEL signature: {err}"))?
.ok_or_else(|| {
anyhow::anyhow!(
"device {device} KEL event at seq {seq} has no stored signature attachment; cannot export a verifiable bundle"
)
})?;
device_attachments.push(hex::encode(att));
}
device_kels.push(auths_verifier::BundleDeviceKel {
did: format!("did:keri:{device}"),
kel: device_events,
kel_attachments: device_attachments,
});
}

// Create the bundle. Curve flows in-band from the typed keychain
// extraction so verifiers never re-derive it from byte length.
let bundle = IdentityBundle {
Expand All @@ -866,6 +913,7 @@ pub fn handle_id(
attestation_chain: attestations,
kel,
kel_attachments,
device_kels,
bundle_timestamp: now,
max_valid_for_secs: max_age_secs,
};
Expand Down
58 changes: 36 additions & 22 deletions crates/auths-cli/src/commands/verify_commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,14 +170,14 @@ pub async fn handle_verify_commit(
{
pinned_roots.push(own_root);
}
let mut bundle_kel: Option<BundleKel> = None;
let mut bundle_kels: Vec<BundleKel> = Vec::new();
let bundle_path = cmd
.identity_bundle
.clone()
.or_else(super::verify_helpers::discover_project_bundle);
if let Some(bundle_path) = &bundle_path {
match load_bundle_trust(bundle_path, chrono::Utc::now()) {
Ok((root, kel)) => {
Ok((root, kel, device_kels)) => {
// Evidence-only (RT-005): the bundle is *evidence for* a root that
// must be pinned independently (`.auths/roots` or self-trust). It
// never becomes its own trust anchor — otherwise the anchor and the
Expand All @@ -198,11 +198,18 @@ pub async fn handle_verify_commit(
);
}
if !kel.is_empty() {
bundle_kel = Some(BundleKel {
bundle_kels.push(BundleKel {
did: root,
events: kel,
});
}
// Device KELs were seal-checked against the root by the parse;
// each resolves its own delegated signer DID statelessly.
bundle_kels.extend(
device_kels
.into_iter()
.map(|(did, events)| BundleKel { did, events }),
);
}
Err(e) => return handle_error(&cmd, 2, &e),
}
Expand All @@ -226,7 +233,7 @@ pub async fn handle_verify_commit(
&receipt_lookup,
&sdk_ctx,
&cmd,
bundle_kel.as_ref(),
&bundle_kels,
commit_ref,
)
.await,
Expand Down Expand Up @@ -256,15 +263,22 @@ struct BundleKel {
fn load_bundle_trust(
path: &std::path::Path,
now: chrono::DateTime<chrono::Utc>,
) -> std::result::Result<(String, Vec<Event>), String> {
) -> std::result::Result<
(
String,
Vec<Event>,
Vec<auths_verifier::AuthenticatedDeviceKel>,
),
String,
> {
let content = fs::read_to_string(path)
.map_err(|e| format!("could not read identity bundle {path:?}: {e}"))?;
let bundle: IdentityBundle = serde_json::from_str(&content)
.map_err(|e| format!("identity bundle {path:?} is not valid JSON: {e}"))?;
let trust = BundleTrust::parse(&bundle, now)
.map_err(|e| format!("identity bundle {path:?} is not a usable trust anchor: {e}"))?;
let (root, kel) = trust.into_parts();
Ok((root, kel))
let (root, kel, device_kels) = trust.into_parts();
Ok((root, kel, device_kels))
}

/// Resolve the commit spec to a list of commit SHAs.
Expand Down Expand Up @@ -366,19 +380,18 @@ fn extract_oidc_binding_display(attestation: &Attestation) -> Option<OidcBinding
///
/// Args:
/// * `registry`: The local registry backend (the trusted floor).
/// * `bundle_kel`: The authenticated identity-bundle KEL, when one was loaded.
/// * `bundle_kels`: The authenticated identity-bundle KELs (root + devices), when loaded.
/// * `did`: The `did:keri:` to resolve.
async fn resolve_signer_kel(
registry: &dyn RegistryBackend,
bundle_kel: Option<&BundleKel>,
bundle_kels: &[BundleKel],
did: &str,
) -> Result<Vec<Event>, String> {
// Stateless first: a bundle that carries the signer's KEL satisfies
// resolution without any identity store (CI runners). Prefix binding is
// still enforced, so a tampered bundle cannot smuggle a foreign KEL.
if let Some(bundle) = bundle_kel
&& bundle.did == did
{
// Stateless first: a bundle that carries the signer's KEL (the root's or a
// delegated device's) satisfies resolution without any identity store (CI
// runners). Prefix binding is still enforced, so a tampered bundle cannot
// smuggle a foreign KEL.
if let Some(bundle) = bundle_kels.iter().find(|b| b.did == did) {
let prefix = auths_sdk::keri::parse_did_keri(did).map_err(|e| e.to_string())?;
auths_sdk::keri::verify_prefix_binding(&prefix, &bundle.events)
.map_err(|e| e.to_string())?;
Expand All @@ -403,7 +416,7 @@ async fn verify_one_commit(
receipt_lookup: &dyn WitnessReceiptLookup,
sdk_ctx: &auths_sdk::context::AuthsContext,
cmd: &VerifyCommitCommand,
bundle_kel: Option<&BundleKel>,
bundle_kels: &[BundleKel],
commit_ref: &str,
) -> VerifyCommitResult {
let sha = match resolve_commit_sha(commit_ref) {
Expand Down Expand Up @@ -440,7 +453,7 @@ async fn verify_one_commit(
// KEL sourcing is an SDK/adapter concern: the committed bundle or the local
// registry — never a network fetch. The prefix-binding guard is applied
// regardless of source. The command stays presentation-thin.
let device_kel = match resolve_signer_kel(registry, bundle_kel, &device_did).await {
let device_kel = match resolve_signer_kel(registry, bundle_kels, &device_did).await {
Ok(events) => events,
Err(e) => {
return VerifyCommitResult::failure(
Expand All @@ -463,7 +476,7 @@ async fn verify_one_commit(
Some(delegator) => format!("did:keri:{delegator}"),
None => root_did,
};
let root_kel = match resolve_signer_kel(registry, bundle_kel, &root_did).await {
let root_kel = match resolve_signer_kel(registry, bundle_kels, &root_did).await {
Ok(events) => events,
Err(e) => {
return VerifyCommitResult::failure(
Expand Down Expand Up @@ -501,11 +514,12 @@ async fn verify_one_commit(
// no source it can trust to confirm freshness from them. Absent a verifier-supplied fresher
// tip, the strongest honest grade is Unknown — the policy decides whether to tolerate it
// (ADR 009 D5). A direct verify carries no such signal and likewise stays Unknown.
let verdict = match bundle_kel {
Some(_) => witnessed
let verdict = if bundle_kels.is_empty() {
witnessed.verdict
} else {
witnessed
.verdict
.with_freshness(&FreshnessPolicy::default(), FreshnessEvidence::Offline),
None => witnessed.verdict,
.with_freshness(&FreshnessPolicy::default(), FreshnessEvidence::Offline)
};
let mut result = verdict_to_result(sha.clone(), verdict);
match witnessed.witness {
Expand Down
2 changes: 2 additions & 0 deletions crates/auths-id/src/policy/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,8 @@ mod tests {
PresentationVerdict::Valid {
issuer: auths_verifier::IdentityDID::parse(CRED_ISSUER).expect("valid test issuer"),
subject: auths_verifier::CanonicalDid::parse(CRED_SUBJECT).expect("valid test subject"),
subject_root: auths_verifier::CanonicalDid::parse(CRED_SUBJECT)
.expect("valid test subject"),
caps: caps
.iter()
.map(|c| auths_verifier::Capability::parse(c).expect("valid test capability"))
Expand Down
31 changes: 30 additions & 1 deletion crates/auths-keri/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1615,10 +1615,39 @@ pub fn pair_kel_attachments(
events
.into_iter()
.zip(attachments)
.map(|(event, att)| Ok(SignedEvent::new(event, parse_attachment(att.as_ref())?)))
.map(|(event, att)| {
// A delegated (`dip`/`drt`) event's attachment is `-A <sig> ++ -G
// <source seal>`; a plain event's is just `-A <sig>`. The JSON body
// of a delegated event carries no source seal (it lives in the
// attachment), so restore it here — the bilateral delegation
// binding then re-verifies it against the delegator KEL; it is
// never trusted from the attachment alone.
let (signatures, seals) = parse_delegated_attachment(att.as_ref())?;
let event = rehydrate_source_seal(event, seals.into_iter().next());
Ok(SignedEvent::new(event, signatures))
})
.collect()
}

/// Re-attach a delegated event's source seal from its parsed attachment; plain
/// events and sealless attachments pass through unchanged.
fn rehydrate_source_seal(event: Event, seal: Option<SourceSeal>) -> Event {
let Some(seal) = seal else {
return event;
};
match event {
Event::Dip(mut e) => {
e.source_seal = Some(seal);
Event::Dip(e)
}
Event::Drt(mut e) => {
e.source_seal = Some(seal);
Event::Drt(e)
}
other => other,
}
}

/// Error shape for attachment encode/decode.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
Expand Down
1 change: 1 addition & 0 deletions crates/auths-mcp-server/src/keri_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ mod tests {
let verdict = PresentationVerdict::Valid {
issuer: IdentityDID::parse("did:keri:Eissuer").expect("valid test issuer"),
subject: CanonicalDid::parse("did:keri:Eagent").expect("valid test subject"),
subject_root: CanonicalDid::parse("did:keri:Eagent").expect("valid test subject"),
caps: caps
.iter()
.map(|c| Capability::parse(c).expect("valid test capability"))
Expand Down
2 changes: 2 additions & 0 deletions crates/auths-rp/src/principal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ mod tests {
PresentationVerdict::Valid {
issuer: IdentityDID::parse("did:keri:Eissuer").expect("valid test issuer"),
subject: CanonicalDid::parse(subject).expect("valid test subject"),
subject_root: CanonicalDid::parse(subject).expect("valid test subject"),
caps: caps
.iter()
.map(|c| Capability::parse(c).expect("valid test capability"))
Expand All @@ -193,6 +194,7 @@ mod tests {
let fresh = PresentationVerdict::Valid {
issuer: IdentityDID::parse("did:keri:Eissuer").expect("issuer"),
subject: CanonicalDid::parse("did:keri:Eagent").expect("subject"),
subject_root: CanonicalDid::parse("did:keri:Eagent").expect("subject"),
caps: vec![],
role: None,
expires_at: None,
Expand Down
Loading
Loading