auths-sdk 0.1.16

Application services layer for Auths identity operations
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
//! Relying-party presentation authentication e2e.
//!
//! Builds a real issuer + delegated subject + issued credential, then runs the full
//! `authenticate_presentation` flow through a green git-backed registry: parse the wire
//! shape, consume the single-use challenge, resolve issuer + subject + delegator KELs,
//! verify, and map to a `VerifiedPrincipal`. Covers the valid path, single-use replay,
//! audience mismatch (server A vs B), and a revoked credential.

use std::path::Path;
use std::sync::Arc;

use auths_core::PrefilledPassphraseProvider;
use auths_core::signing::{PassphraseProvider, StorageSigner};
use auths_core::storage::keychain::KeyAlias;
use auths_core::testing::IsolatedKeychainHandle;
use auths_crypto::CurveType;
use auths_id::keri::parse_did_keri;
use auths_id::keri::types::Prefix;
use auths_rp::{Audience, ChallengeStore, InMemoryChallengeStore, WirePresentation};
use auths_sdk::context::AuthsContext;
use auths_sdk::domains::credentials::{
    FreshnessDecision, PresentationAuthError, PresentationChallenge, RevocationFreshnessSource,
    authenticate_presentation, issue, present_credential, revoke,
};
use auths_sdk::domains::device::add_device;
use auths_sdk::domains::identity::service::initialize;
use auths_sdk::domains::identity::types::{
    CreateDeveloperIdentityConfig, IdentityConfig, InitializeResult,
};
use auths_sdk::domains::org::policy::{Expr, set_org_policy};
use auths_sdk::domains::signing::types::GitSigningScope;

use crate::cases::helpers::build_test_context_with_provider;

const PASS: &str = "Test-passphrase1!";
const AUDIENCE: &str = "api.example.com";

fn setup_test_identity(registry_path: &Path) -> (KeyAlias, IsolatedKeychainHandle) {
    let keychain = IsolatedKeychainHandle::new();
    let signer = StorageSigner::new(keychain.clone());
    let provider = PrefilledPassphraseProvider::new(PASS);
    let config = CreateDeveloperIdentityConfig::builder(KeyAlias::new_unchecked("issuer-key"))
        .with_git_signing_scope(GitSigningScope::Skip)
        .build();
    let ctx = build_test_context_with_provider(registry_path, Arc::new(keychain.clone()), None);
    let result = match initialize(
        IdentityConfig::Developer(config),
        &ctx,
        Arc::new(keychain.clone()),
        &signer,
        &provider,
        None,
    )
    .unwrap()
    {
        InitializeResult::Developer(r) => r,
        _ => unreachable!(),
    };
    (result.key_alias, keychain)
}

struct Harness {
    ctx: AuthsContext,
    issuer_alias: KeyAlias,
    _tmp: tempfile::TempDir,
}

fn setup() -> Harness {
    let tmp = tempfile::tempdir().unwrap();
    let (issuer_alias, keychain) = setup_test_identity(tmp.path());
    let provider: Arc<dyn PassphraseProvider + Send + Sync> =
        Arc::new(PrefilledPassphraseProvider::new(PASS));
    let ctx = build_test_context_with_provider(tmp.path(), Arc::new(keychain), Some(provider));
    Harness {
        ctx,
        issuer_alias,
        _tmp: tmp,
    }
}

/// Delegate a subject device + issue a credential to it; returns `(alias, prefix, said)`.
fn issue_to_subject(h: &Harness, label: &str, curve: CurveType) -> (KeyAlias, Prefix, String) {
    let subject_alias = KeyAlias::new_unchecked(label);
    let device = add_device(&h.ctx, &h.issuer_alias, &subject_alias, curve).expect("delegate");
    let subject_prefix = parse_did_keri(&device.device_did).expect("subject prefix");
    let issued = issue(
        &h.ctx,
        &h.issuer_alias,
        &device.device_did,
        &[auths_keri::Capability::sign_commit()],
        None,
        None,
    )
    .expect("issue credential");
    (subject_alias, subject_prefix, issued.credential_said)
}

/// Issue a challenge from the store, sign a presentation over it, return the wire shape.
fn present_with_store(
    h: &Harness,
    subject_alias: &KeyAlias,
    cred: &str,
    issued_audience: &Audience,
    store: &InMemoryChallengeStore,
    now: chrono::DateTime<chrono::Utc>,
) -> WirePresentation {
    let issued = store.issue(issued_audience, now).expect("issue challenge");
    let envelope = present_credential(
        &h.ctx,
        subject_alias,
        cred,
        AUDIENCE,
        PresentationChallenge::Challenge {
            nonce: issued.nonce.as_bytes().to_vec(),
        },
    )
    .expect("present");
    WirePresentation::from_envelope(&envelope)
}

#[tokio::test]
async fn valid_presentation_authenticates_and_replay_rejected() {
    let h = setup();
    let (subject_alias, subject_prefix, cred) = issue_to_subject(&h, "agent", CurveType::P256);
    let audience = Audience::parse(AUDIENCE).unwrap();
    let store = InMemoryChallengeStore::new(16);
    let now = chrono::Utc::now();

    let wire = present_with_store(&h, &subject_alias, &cred, &audience, &store, now);

    let principal = authenticate_presentation(
        &h.ctx,
        &h.issuer_alias,
        &store,
        &audience,
        wire.clone(),
        now,
    )
    .await
    .expect("valid presentation authenticates");
    assert_eq!(
        principal.subject().as_str(),
        format!("did:keri:{}", subject_prefix.as_str()),
        "the authenticated principal is the delegated subject"
    );

    // Replaying the same wire fails: the single-use nonce was consumed.
    let replay =
        authenticate_presentation(&h.ctx, &h.issuer_alias, &store, &audience, wire, now).await;
    let err = replay.expect_err("replayed presentation must be rejected");
    assert_eq!(err.http_status(), 401);
}

#[tokio::test]
async fn authenticated_principal_surfaces_offline_freshness_as_unknown() {
    // The relying party resolves the credential slice offline (no fresher issuer tip is threaded
    // through the SDK caller yet), so the honored verdict must surface a *named* freshness —
    // `Unknown` — at the principal boundary, never a bare honored verdict. This is what lets a
    // relying party tell `Valid(Fresh)` from `Valid(Unknown)` and apply a stricter policy if it
    // wants. The default policy tolerates `Unknown`, so the presentation still authenticates.
    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();

    let wire = present_with_store(&h, &subject_alias, &cred, &audience, &store, now);
    let principal =
        authenticate_presentation(&h.ctx, &h.issuer_alias, &store, &audience, wire, now)
            .await
            .expect("offline-resolved presentation authenticates under the default policy");

    assert_eq!(
        principal.freshness(),
        auths_verifier::freshness::Freshness::Unknown,
        "an offline-resolved honored verdict must surface Unknown, not a bare Valid"
    );
}

/// The issuer's KEL prefix (it acts as the org/policy authority in this harness).
fn issuer_prefix(h: &Harness) -> Prefix {
    let did = h
        .ctx
        .identity_storage
        .load_identity()
        .expect("issuer identity")
        .controller_did;
    parse_did_keri(did.as_str()).expect("issuer prefix")
}

/// A revocation-freshness source returning a fixed decision, to drive the gate without a live poll.
struct FakeFreshness(FreshnessDecision);

impl RevocationFreshnessSource for FakeFreshness {
    fn freshness(
        &self,
        _delegator: &Prefix,
        _now: chrono::DateTime<chrono::Utc>,
    ) -> FreshnessDecision {
        self.0
    }
}

#[tokio::test]
async fn revocation_freshness_gate_refuses_a_stale_copy_and_honors_a_fresh_one() {
    // A configured staleness source whose copy is too stale → the presentation is refused
    // (fail-closed: a `rev` may have landed unseen), even though it otherwise verifies.
    {
        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();
        let wire = present_with_store(&h, &subject_alias, &cred, &audience, &store, now);
        let ctx = h.ctx.with_revocation_freshness(Arc::new(FakeFreshness(
            FreshnessDecision::StaleRejected {
                age: chrono::Duration::minutes(5),
            },
        )));
        let err = authenticate_presentation(&ctx, &h.issuer_alias, &store, &audience, wire, now)
            .await
            .expect_err("a stale issuer-log copy must refuse the presentation");
        assert!(
            matches!(err, PresentationAuthError::RevocationStale(_)),
            "expected RevocationStale, got {err:?}"
        );
        assert_eq!(err.http_status(), 401);
    }
    // A fresh copy → the presentation is honored.
    {
        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();
        let wire = present_with_store(&h, &subject_alias, &cred, &audience, &store, now);
        let ctx =
            h.ctx
                .with_revocation_freshness(Arc::new(FakeFreshness(FreshnessDecision::Fresh {
                    age: chrono::Duration::seconds(1),
                })));
        let principal =
            authenticate_presentation(&ctx, &h.issuer_alias, &store, &audience, wire, now)
                .await
                .expect("a fresh issuer-log copy must honor the presentation");
        assert!(!principal.capabilities().is_empty());
    }
}

#[tokio::test]
async fn org_policy_denies_authenticated_presentation_with_403() {
    // E1 A4: a presentation that authenticates cleanly is still denied (403, not 401)
    // when the issuer's org policy is not satisfied by the credential's grant.
    let h = setup();
    let (subject_alias, _p, cred) = issue_to_subject(&h, "agent", CurveType::P256);
    let audience = Audience::parse(AUDIENCE).unwrap();
    let store = InMemoryChallengeStore::new(16);
    let now = chrono::Utc::now();

    // The credential grants `sign_commit`; require a capability it lacks.
    set_org_policy(
        &h.ctx,
        &issuer_prefix(&h),
        &h.issuer_alias,
        &serde_json::to_vec(&Expr::HasCapability("deploy".into())).unwrap(),
    )
    .expect("anchor org policy");

    let wire = present_with_store(&h, &subject_alias, &cred, &audience, &store, now);
    let err = authenticate_presentation(&h.ctx, &h.issuer_alias, &store, &audience, wire, now)
        .await
        .expect_err("org policy must deny");
    assert!(
        matches!(err, PresentationAuthError::PolicyDenied { .. }),
        "expected PolicyDenied, got {err:?}"
    );
    assert_eq!(
        err.http_status(),
        403,
        "an authenticated-but-policy-denied principal is 403, not 401"
    );
}

#[tokio::test]
async fn org_policy_allows_authenticated_presentation_when_satisfied() {
    let h = setup();
    let (subject_alias, _p, cred) = issue_to_subject(&h, "agent", CurveType::P256);
    let audience = Audience::parse(AUDIENCE).unwrap();
    let store = InMemoryChallengeStore::new(16);
    let now = chrono::Utc::now();

    set_org_policy(
        &h.ctx,
        &issuer_prefix(&h),
        &h.issuer_alias,
        &serde_json::to_vec(&Expr::And(vec![
            Expr::NotRevoked,
            Expr::HasCapability("sign_commit".into()),
        ]))
        .unwrap(),
    )
    .expect("anchor org policy");

    let wire = present_with_store(&h, &subject_alias, &cred, &audience, &store, now);
    let principal =
        authenticate_presentation(&h.ctx, &h.issuer_alias, &store, &audience, wire, now)
            .await
            .expect("policy satisfied → authenticates");
    assert!(!principal.capabilities().is_empty());
}

#[tokio::test]
async fn presentation_for_audience_a_rejected_at_server_b() {
    let h = setup();
    let (subject_alias, _subject_prefix, cred) = issue_to_subject(&h, "agent", CurveType::Ed25519);
    let real = Audience::parse(AUDIENCE).unwrap();
    let store = InMemoryChallengeStore::new(16);
    let now = chrono::Utc::now();

    let wire = present_with_store(&h, &subject_alias, &cred, &real, &store, now);

    // The relying party is configured for a DIFFERENT audience — confused-deputy defense.
    let other = Audience::parse("evil.example.com").unwrap();
    let result =
        authenticate_presentation(&h.ctx, &h.issuer_alias, &store, &other, wire, now).await;
    assert!(
        result.is_err(),
        "a presentation bound to audience A must be rejected at server B"
    );
}

#[tokio::test]
async fn revoked_credential_presentation_rejected() {
    let h = setup();
    let (subject_alias, _subject_prefix, cred) = issue_to_subject(&h, "agent", CurveType::P256);
    let audience = Audience::parse(AUDIENCE).unwrap();
    let store = InMemoryChallengeStore::new(16);
    let now = chrono::Utc::now();

    revoke(&h.ctx, &h.issuer_alias, &cred).expect("revoke credential");

    let wire = present_with_store(&h, &subject_alias, &cred, &audience, &store, now);
    let result =
        authenticate_presentation(&h.ctx, &h.issuer_alias, &store, &audience, wire, now).await;
    assert!(
        result.is_err(),
        "a presentation of a revoked credential must be rejected"
    );
}

#[tokio::test]
async fn valid_then_revoked_presentation_transition() {
    let h = setup();
    let (subject_alias, _subject_prefix, cred) = issue_to_subject(&h, "agent", CurveType::P256);
    let audience = Audience::parse(AUDIENCE).unwrap();
    let store = InMemoryChallengeStore::new(16);
    let now = chrono::Utc::now();

    // Before revocation: a fresh presentation authenticates.
    let wire_before = present_with_store(&h, &subject_alias, &cred, &audience, &store, now);
    authenticate_presentation(&h.ctx, &h.issuer_alias, &store, &audience, wire_before, now)
        .await
        .expect("presentation authenticates before revocation");

    // Revoke the credential (anchors a `rev` in the issuer KEL).
    revoke(&h.ctx, &h.issuer_alias, &cred).expect("revoke credential");

    // After revocation: a fresh presentation of the same credential is rejected.
    let wire_after = present_with_store(&h, &subject_alias, &cred, &audience, &store, now);
    let after =
        authenticate_presentation(&h.ctx, &h.issuer_alias, &store, &audience, wire_after, now)
            .await;
    assert!(
        after.is_err(),
        "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:?}"
    );
}