From b2f5a1d8f8612711d8673a55e1b5e303a3e883bd Mon Sep 17 00:00:00 2001 From: bordumb Date: Wed, 24 Jun 2026 23:49:23 +0100 Subject: [PATCH] test(sdk): adversarial coverage for the multi-surface crown-jewel capabilities (matrix R1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capability matrix flagged five capabilities as reached from multiple front doors (or, for the agent-issuance ones, the highest-risk remote API cells). Per the architecture (thin front doors, logic in the SDK, tested once), their adversarial coverage belongs at the SDK/verifier layer, not per-surface. The audit found verify_chain (9), verify_with_keys (14), and authenticate_presentation (8) already well covered; the thin ones were add_scoped (1) and revoke_batch (2). Fills the genuine gaps (no padding of already-covered paths): - revoke_batch: empty-list no-op; mixed live/already-revoked seals only the live member but reports the whole set; an unparseable did fails closed (AgentNotFound). - add_scoped: a strict narrowing of the delegator scope is allowed; an empty scope is a valid capability-less delegation. - authenticate_presentation: a mis-sized (31-byte) nonce is a 400 client error, rejected at the wire boundary before any challenge/signature is trusted. - verify_chain: a revoked+expired attestation is rejected with a terminal status. - verify_with_keys: a valid signature verified under the wrong key is rejected (distinct from a tampered signature). Redundancy audit: no per-surface logic tests to remove — auths-api/rp_auth.rs fakes the crypto and tests only the real ChallengeStore + middleware translation, and no CLI/MCP test calls these SDK functions directly. Auths-Id: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Device: did:keri:EB5cPHY0t-ejNC_rUzPS1dclTvd6kG-R9mQzjozCuGgd Auths-Anchor-Seq: 1 --- crates/auths-sdk/tests/cases/agents.rs | 63 ++++++++++++++++++++ crates/auths-sdk/tests/cases/authenticate.rs | 33 ++++++++++ crates/auths-sdk/tests/cases/kill_switch.rs | 62 ++++++++++++++++++- crates/auths-verifier/src/verify.rs | 60 +++++++++++++++++++ 4 files changed, 217 insertions(+), 1 deletion(-) diff --git a/crates/auths-sdk/tests/cases/agents.rs b/crates/auths-sdk/tests/cases/agents.rs index d9ecdc5c..1bbdbbe9 100644 --- a/crates/auths-sdk/tests/cases/agents.rs +++ b/crates/auths-sdk/tests/cases/agents.rs @@ -343,3 +343,66 @@ fn scope_cannot_exceed_delegator() { "got {err:?}" ); } + +#[test] +fn scoped_agent_may_narrow_the_delegator_scope() { + let (ctx, root_alias, root_prefix, _tmp) = setup(); + + // Give the delegator [read, write], so the subset check actually runs (not the + // unconstrained-root path). + let (_pk, root_curve) = extract_public_key_bytes( + ctx.key_storage.as_ref(), + &root_alias, + ctx.passphrase_provider.as_ref(), + ) + .expect("root curve"); + mark_agent_scope( + ctx.registry.as_ref(), + &root_prefix, + &root_alias, + root_curve, + &root_prefix, + &AgentScope { + capabilities: vec![ + Capability::parse("read").unwrap(), + Capability::parse("write").unwrap(), + ], + expires_at: None, + }, + ctx.passphrase_provider.as_ref(), + ctx.key_storage.as_ref(), + ) + .expect("anchor delegator scope"); + + // Delegating [read] — a strict subset of [read, write] — must be allowed. The subset + // rule narrows; it must not reject a legitimate narrowing (a false-reject would be a + // usability regression and push callers toward over-broad grants). + let agent = add_scoped( + &ctx, + &root_alias, + &KeyAlias::new_unchecked("narrowed-bot"), + CurveType::Ed25519, + &[Capability::parse("read").unwrap()], + None, + ) + .expect("narrowing the delegator's scope to a subset must be allowed"); + assert!(agent.agent_did.starts_with("did:keri:")); +} + +#[test] +fn scoped_agent_with_empty_scope_is_allowed() { + let (ctx, root_alias, _root_prefix, _tmp) = setup(); + + // An empty scope is a subset of any delegator scope — a capability-less, anchor-only + // agent (e.g. an identity placeholder) is valid, not an error. + let agent = add_scoped( + &ctx, + &root_alias, + &KeyAlias::new_unchecked("no-scope-bot"), + CurveType::Ed25519, + &[], + None, + ) + .expect("an empty scope is a valid (capability-less) delegation"); + assert!(agent.agent_did.starts_with("did:keri:")); +} diff --git a/crates/auths-sdk/tests/cases/authenticate.rs b/crates/auths-sdk/tests/cases/authenticate.rs index ae18af8d..fefb2173 100644 --- a/crates/auths-sdk/tests/cases/authenticate.rs +++ b/crates/auths-sdk/tests/cases/authenticate.rs @@ -375,3 +375,36 @@ async fn valid_then_revoked_presentation_transition() { "after revocation, a fresh presentation of the same credential must be rejected" ); } + +#[tokio::test] +async fn malformed_nonce_length_is_rejected_as_a_client_error() { + let h = setup(); + let (subject_alias, _subject, cred) = issue_to_subject(&h, "agent", CurveType::P256); + let audience = Audience::parse(AUDIENCE).unwrap(); + let store = InMemoryChallengeStore::new(16); + let now = chrono::Utc::now(); + + // Present over a 31-byte nonce (a real challenge is always 32). The nonce length is + // checked at the wire boundary, before the challenge is consumed or any signature is + // trusted — so a mis-sized nonce is a 400 client error, never a path to a bypass. + let envelope = present_credential( + &h.ctx, + &subject_alias, + &cred, + AUDIENCE, + PresentationChallenge::Challenge { + nonce: vec![0u8; 31], + }, + ) + .expect("present"); + let wire = WirePresentation::from_envelope(&envelope); + + let err = authenticate_presentation(&h.ctx, &h.issuer_alias, &store, &audience, wire, now) + .await + .expect_err("a 31-byte nonce must be rejected"); + assert_eq!( + err.http_status(), + 400, + "a mis-sized nonce is a client error, got {err:?}" + ); +} diff --git a/crates/auths-sdk/tests/cases/kill_switch.rs b/crates/auths-sdk/tests/cases/kill_switch.rs index 31d79132..f08c58c0 100644 --- a/crates/auths-sdk/tests/cases/kill_switch.rs +++ b/crates/auths-sdk/tests/cases/kill_switch.rs @@ -12,7 +12,7 @@ use auths_crypto::CurveType; use auths_id::keri::Event; use auths_id::keri::types::Prefix; use auths_sdk::context::AuthsContext; -use auths_sdk::domains::agents::{add_scoped, list, revoke_batch}; +use auths_sdk::domains::agents::{AgentError, add_scoped, list, revoke_batch}; use auths_sdk::domains::identity::service::initialize; use auths_sdk::domains::identity::types::{ CreateDeveloperIdentityConfig, IdentityConfig, InitializeResult, @@ -138,3 +138,63 @@ fn batch_revoke_is_idempotent() { "already-revoked batch is a no-op (no new event)" ); } + +#[test] +fn batch_revoke_empty_list_is_a_clean_noop() { + let (ctx, org_alias, _org_prefix, _tmp) = setup(); + + // The kill switch fired with nothing to kill must be a clean no-op, never an error + // or a spurious KEL event. + let receipt = revoke_batch(&ctx, &org_alias, &[]).expect("an empty batch is Ok"); + assert!( + receipt.anchored_at_seq.is_none(), + "an empty kill-switch writes no KEL event" + ); + assert!(receipt.revoked.is_empty(), "nothing is reported revoked"); +} + +#[test] +fn batch_revoke_seals_only_live_agents_but_reports_the_whole_set() { + let (ctx, org_alias, _org_prefix, _tmp) = setup(); + let a1 = add_agent(&ctx, &org_alias, "agent-1"); + let a2 = add_agent(&ctx, &org_alias, "agent-2"); + + // Kill a1 alone, then fire a batch over BOTH. Only a2 is still live, so a new event + // is anchored — but the receipt reports the full requested set as revoked, and both + // end up revoked. (A kill switch must not silently drop an already-dead member.) + revoke_batch(&ctx, &org_alias, std::slice::from_ref(&a1)).expect("kill a1"); + let receipt = revoke_batch(&ctx, &org_alias, &[a1.clone(), a2.clone()]).expect("kill both"); + + assert!( + receipt.anchored_at_seq.is_some(), + "a still-live member in the set anchors a new event" + ); + assert_eq!( + receipt.revoked, + vec![a1, a2], + "the full requested set is reported revoked, not just the live one" + ); + assert_eq!( + list(&ctx) + .expect("list") + .into_iter() + .filter(|a| a.revoked) + .count(), + 2, + "both agents end revoked" + ); +} + +#[test] +fn batch_revoke_rejects_an_unparseable_did() { + let (ctx, org_alias, _org_prefix, _tmp) = setup(); + + // A malformed agent did in the set must abort the batch (fail-closed), not be silently + // skipped — otherwise a typo could leave a targeted agent alive. + let err = revoke_batch(&ctx, &org_alias, &["not-a-did:keri".to_string()]) + .expect_err("a malformed agent did must be rejected"); + assert!( + matches!(err, AgentError::AgentNotFound { ref did } if did == "not-a-did:keri"), + "expected AgentNotFound, got {err:?}" + ); +} diff --git a/crates/auths-verifier/src/verify.rs b/crates/auths-verifier/src/verify.rs index 3950cb25..1487af87 100644 --- a/crates/auths-verifier/src/verify.rs +++ b/crates/auths-verifier/src/verify.rs @@ -1005,6 +1005,66 @@ mod tests { assert!(result.is_ok()); } + #[tokio::test] + async fn verify_chain_revoked_and_expired_attestation_is_rejected() { + let (root_kp, root_pk) = create_test_keypair(&[1u8; 32]); + let root_did = ed25519_did(&root_pk); + let (device_kp, device_pk) = create_test_keypair(&[2u8; 32]); + let device_did = ed25519_did(&device_pk); + + // Both revoked AND expired: the two negative signals must not cancel — the result + // is a terminal rejection, never Valid. + let att = create_signed_attestation( + &root_kp, + &device_kp, + &root_did, + &device_did, + Some(fixed_now()), + Some(fixed_now() - Duration::days(1)), + ); + + let result = test_verifier() + .verify_chain(&[att], &ed(&root_pk)) + .await + .unwrap(); + assert!(!result.is_valid(), "revoked+expired must not be valid"); + assert!( + matches!( + result.status, + VerificationStatus::Revoked { .. } | VerificationStatus::Expired { .. } + ), + "expected a terminal revoked/expired status, got {:?}", + result.status + ); + } + + #[tokio::test] + async fn verify_with_keys_rejects_a_correct_signature_under_the_wrong_key() { + let (root_kp, root_pk) = create_test_keypair(&[1u8; 32]); + let root_did = ed25519_did(&root_pk); + let (device_kp, device_pk) = create_test_keypair(&[2u8; 32]); + let device_did = ed25519_did(&device_pk); + + // Validly signed by root_kp, but verified against a DIFFERENT key. A perfectly + // valid signature under the wrong key is still a forgery from the verifier's view — + // distinct from a tampered signature (this one verifies, just not under this key). + let att = create_signed_attestation( + &root_kp, + &device_kp, + &root_did, + &device_did, + None, + Some(fixed_now() + Duration::days(365)), + ); + let (_other_kp, other_pk) = create_test_keypair(&[9u8; 32]); + + let result = test_verifier().verify_with_keys(&att, &ed(&other_pk)).await; + assert!( + result.is_err(), + "a signature that does not verify under the supplied issuer key must be rejected" + ); + } + /// Helper to wrap an attestation as verified (for tests where we created it ourselves). fn verified(att: Attestation) -> VerifiedAttestation { VerifiedAttestation::dangerous_from_unchecked(att)