Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
63 changes: 63 additions & 0 deletions crates/auths-sdk/tests/cases/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:"));
}
33 changes: 33 additions & 0 deletions crates/auths-sdk/tests/cases/authenticate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:?}"
);
}
62 changes: 61 additions & 1 deletion crates/auths-sdk/tests/cases/kill_switch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:?}"
);
}
60 changes: 60 additions & 0 deletions crates/auths-verifier/src/verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading