ans-verify 0.1.4

ANS Trust Verification library for the Agent Name Service
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
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
//! C2SP root key format parsing and trusted key store.
//!
//! The C2SP key format encodes an ECDSA P-256 public key as:
//!
//! ```text
//! <name>+<key_hash_hex>+<base64-SPKI-DER>
//! ```
//!
//! where `key_hash_hex` is the first 4 bytes of `SHA-256(SPKI-DER)` encoded
//! as lowercase hex.
//!
//! The [`ScittKeyStore`] indexes parsed keys by their 4-byte key ID for O(1)
//! COSE `kid` lookup during receipt and status token verification.

use std::collections::HashMap;

use base64::Engine as _;
use base64::prelude::BASE64_STANDARD;
use p256::ecdsa::VerifyingKey;
use p256::pkcs8::DecodePublicKey as _;
use sha2::{Digest, Sha256};
use tracing::warn;

use super::error::ScittError;

/// Maximum base64 length for SPKI-DER in C2SP keys.
/// P-256 SPKI-DER is 91 bytes (~124 base64 chars). 1KB is extremely generous.
const MAX_SPKI_BASE64_LEN: usize = 1024;

/// A trusted ECDSA P-256 signing key parsed from C2SP format.
#[derive(Debug, Clone)]
pub struct TrustedKey {
    /// The TL domain this key belongs to.
    pub name: String,
    /// 4-byte key ID (first 4 bytes of SHA-256 of SPKI-DER).
    pub kid: [u8; 4],
    /// The P-256 verifying key.
    pub key: VerifyingKey,
}

/// Store of trusted root keys, indexed by 4-byte key ID for O(1) lookup.
///
/// Both TL keys (for receipts) and RA keys (for status tokens) come from the
/// same `/root-keys` endpoint. They are distinguished by the `kid` in the
/// COSE protected header at verification time.
#[derive(Debug, Clone)]
pub struct ScittKeyStore {
    keys: HashMap<[u8; 4], TrustedKey>,
}

impl ScittKeyStore {
    /// Parse a set of C2SP key strings into a key store.
    ///
    /// Invalid keys are logged at `tracing::warn!` and skipped (not fatal).
    /// Returns error only if NO valid keys could be parsed.
    ///
    /// # Errors
    ///
    /// Returns [`ScittError::InvalidKeyFormat`] if no valid keys could be parsed
    /// from the input.
    pub fn from_c2sp_keys(key_strings: &[String]) -> Result<Self, ScittError> {
        let mut keys: HashMap<[u8; 4], TrustedKey> = HashMap::new();

        for key_string in key_strings {
            match parse_c2sp_key(key_string) {
                Ok(trusted_key) => {
                    if let Some(existing) = keys.get(&trusted_key.kid)
                        && existing.name != trusted_key.name
                    {
                        warn!(
                            kid = %hex::encode(trusted_key.kid),
                            existing = %existing.name,
                            new = %trusted_key.name,
                            "Key ID collision detected — overwriting existing key"
                        );
                    }
                    keys.insert(trusted_key.kid, trusted_key);
                }
                Err(err) => {
                    warn!(key = %key_string, error = %err, "Skipping invalid C2SP key");
                }
            }
        }

        if keys.is_empty() {
            return Err(ScittError::InvalidKeyFormat(
                "no valid keys could be parsed from input".to_string(),
            ));
        }

        Ok(Self { keys })
    }

    /// Look up a key by its 4-byte key ID.
    ///
    /// # Errors
    ///
    /// Returns [`ScittError::UnknownKeyId`] if no key with the given ID exists.
    pub fn get(&self, kid: [u8; 4]) -> Result<&TrustedKey, ScittError> {
        self.keys.get(&kid).ok_or(ScittError::UnknownKeyId(kid))
    }

    /// Returns the number of trusted keys in the store.
    pub fn len(&self) -> usize {
        self.keys.len()
    }

    /// Returns true if the store contains no keys.
    pub fn is_empty(&self) -> bool {
        self.keys.is_empty()
    }

    /// Create a new store containing all keys from `self` plus any newly
    /// parsed keys from `additional_key_strings`.
    ///
    /// - Keys already present (same `kid`) are kept unchanged.
    /// - New valid keys are merged in.
    /// - Invalid keys in `additional_key_strings` are warned and skipped.
    ///
    /// Unlike [`from_c2sp_keys`](Self::from_c2sp_keys), this method never
    /// returns an error: if zero new keys are parsed, the existing keys are
    /// still present.
    pub fn merge_from(&self, additional_key_strings: &[String]) -> Self {
        let mut keys = self.keys.clone();
        let before = keys.len();

        for key_string in additional_key_strings {
            match parse_c2sp_key(key_string) {
                Ok(trusted_key) => {
                    use std::collections::hash_map::Entry;
                    match keys.entry(trusted_key.kid) {
                        Entry::Occupied(e) => {
                            if e.get().name != trusted_key.name {
                                warn!(
                                    kid = %hex::encode(trusted_key.kid),
                                    existing = %e.get().name,
                                    new = %trusted_key.name,
                                    "Key ID collision detected during merge — keeping existing key"
                                );
                            }
                        }
                        Entry::Vacant(e) => {
                            e.insert(trusted_key);
                        }
                    }
                }
                Err(err) => {
                    warn!(key = %key_string, error = %err, "Skipping invalid C2SP key during merge");
                }
            }
        }

        let added = keys.len() - before;
        if added > 0 {
            tracing::debug!(added, total = keys.len(), "Merged new root keys into store");
        }

        Self { keys }
    }
}

/// Parse a single C2SP key string into its components.
///
/// Public for testing; production code uses [`ScittKeyStore::from_c2sp_keys`].
///
/// # Format
///
/// ```text
/// <name>+<key_hash_hex>+<base64-SPKI-DER>
/// ```
///
/// # Errors
///
/// - [`ScittError::InvalidKeyFormat`] — wrong number of `+` delimiters, invalid
///   hex, wrong key hash length, or invalid Base64
/// - [`ScittError::KeyHashMismatch`] — key hash does not match SHA-256 of SPKI-DER
/// - [`ScittError::InvalidPublicKey`] — SPKI-DER does not encode a valid P-256 key
pub fn parse_c2sp_key(key_string: &str) -> Result<TrustedKey, ScittError> {
    let parts: Vec<&str> = key_string.splitn(3, '+').collect();
    if parts.len() != 3 {
        return Err(ScittError::InvalidKeyFormat(format!(
            "expected 3 '+'-delimited parts, got {}",
            parts.len()
        )));
    }

    let name = parts[0];
    let key_hash_hex = parts[1];
    let spki_b64 = parts[2];

    if name.is_empty() {
        return Err(ScittError::InvalidKeyFormat(
            "name (part 0) is empty".to_string(),
        ));
    }

    // Hex-decode the key hash
    let key_hash_bytes = hex::decode(key_hash_hex)
        .map_err(|e| ScittError::InvalidKeyFormat(format!("key_hash is not valid hex: {e}")))?;

    // Must be exactly 4 bytes
    if key_hash_bytes.len() != 4 {
        return Err(ScittError::InvalidKeyFormat(format!(
            "key_hash must be 4 bytes (8 hex chars), got {} bytes",
            key_hash_bytes.len()
        )));
    }

    let kid: [u8; 4] = [
        key_hash_bytes[0],
        key_hash_bytes[1],
        key_hash_bytes[2],
        key_hash_bytes[3],
    ];

    // Base64-decode the key material.
    // Cap before allocation to prevent amplification from a crafted response.
    if spki_b64.len() > MAX_SPKI_BASE64_LEN {
        return Err(ScittError::InvalidKeyFormat(format!(
            "SPKI base64 is {} chars, maximum is {MAX_SPKI_BASE64_LEN}",
            spki_b64.len()
        )));
    }
    let decoded = BASE64_STANDARD
        .decode(spki_b64)
        .map_err(|e| ScittError::InvalidKeyFormat(format!("SPKI-DER is not valid Base64: {e}")))?;

    // C2SP signed-note format: base64 content may be prefixed with a type byte
    // (0x02 = ECDSA). Strip it to get the raw SPKI-DER.
    let spki_der = if decoded.first() == Some(&0x02) && decoded.len() > 1 {
        &decoded[1..]
    } else {
        &decoded
    };

    // Verify key hash: SHA-256(spki_der)[0..4] must equal kid
    let digest = Sha256::digest(spki_der);
    let expected_kid: [u8; 4] = [digest[0], digest[1], digest[2], digest[3]];
    if expected_kid != kid {
        return Err(ScittError::KeyHashMismatch);
    }

    // Parse SPKI-DER as a P-256 verifying key
    let key = VerifyingKey::from_public_key_der(spki_der)
        .map_err(|e| ScittError::InvalidPublicKey(e.to_string()))?;

    Ok(TrustedKey {
        name: name.to_string(),
        kid,
        key,
    })
}

#[allow(clippy::unwrap_used, clippy::expect_used)]
#[cfg(test)]
mod tests {
    use p256::ecdsa::SigningKey;
    use p256::pkcs8::EncodePublicKey as _;

    use super::*;

    /// Build a valid C2SP key string for a test P-256 key derived from a fixed seed byte.
    fn make_c2sp_key(seed: u8, name: &str) -> (String, TrustedKey) {
        let seed_bytes = [seed; 32];
        let signing_key = SigningKey::from_slice(&seed_bytes).unwrap();
        let verifying_key = signing_key.verifying_key();
        let spki_doc = verifying_key.to_public_key_der().unwrap();
        let spki_der = spki_doc.as_bytes();
        let digest = Sha256::digest(spki_der);
        let kid: [u8; 4] = [digest[0], digest[1], digest[2], digest[3]];
        let key_hash_hex = hex::encode(kid);
        let spki_b64 = BASE64_STANDARD.encode(spki_der);
        let key_string = format!("{name}+{key_hash_hex}+{spki_b64}");
        let trusted_key = TrustedKey {
            name: name.to_string(),
            kid,
            key: *verifying_key,
        };
        (key_string, trusted_key)
    }

    // ── parse_c2sp_key happy path ──

    #[test]
    fn parse_valid_c2sp_key() {
        let (key_string, expected) = make_c2sp_key(1, "tl.example.com");
        let parsed = parse_c2sp_key(&key_string).unwrap();
        assert_eq!(parsed.name, expected.name);
        assert_eq!(parsed.kid, expected.kid);
        // Compare by encoding both back to DER
        let parsed_der = parsed.key.to_public_key_der().unwrap();
        let expected_der = expected.key.to_public_key_der().unwrap();
        assert_eq!(parsed_der.as_bytes(), expected_der.as_bytes());
    }

    // ── wrong number of '+' delimiters ──

    #[test]
    fn error_zero_plus_delimiters() {
        let err = parse_c2sp_key("noplusdelimiters").unwrap_err();
        assert!(matches!(err, ScittError::InvalidKeyFormat(_)));
    }

    #[test]
    fn error_one_plus_delimiter() {
        let err = parse_c2sp_key("tl.example.com+a1b2c3d4").unwrap_err();
        assert!(matches!(err, ScittError::InvalidKeyFormat(_)));
    }

    #[test]
    fn error_no_extra_parts_via_splitn() {
        // splitn(3) means a 4th+ part gets merged into the last segment;
        // so with 4 real '+' chars we get 3 parts and the last part has a '+' in it.
        // That's fine as long as the SPKI base64 is invalid.
        // But with only 2 '+' we get only 2 parts (splitn returns <=n pieces).
        let err = parse_c2sp_key("a+b").unwrap_err();
        assert!(matches!(err, ScittError::InvalidKeyFormat(_)));
    }

    // ── key_hash not valid hex ──

    #[test]
    fn error_key_hash_not_valid_hex() {
        let (_, _) = make_c2sp_key(1, "tl.example.com");
        let err = parse_c2sp_key("tl.example.com+ZZZZZZZZ+YWJj").unwrap_err();
        assert!(matches!(err, ScittError::InvalidKeyFormat(_)));
    }

    // ── key_hash wrong length ──

    #[test]
    fn error_key_hash_too_short_3_bytes() {
        // 3 bytes = 6 hex chars
        let err = parse_c2sp_key("tl.example.com+a1b2c3+YWJj").unwrap_err();
        assert!(matches!(err, ScittError::InvalidKeyFormat(_)));
    }

    #[test]
    fn error_key_hash_too_long_5_bytes() {
        // 5 bytes = 10 hex chars
        let err = parse_c2sp_key("tl.example.com+a1b2c3d4e5+YWJj").unwrap_err();
        assert!(matches!(err, ScittError::InvalidKeyFormat(_)));
    }

    // ── SPKI-DER not valid Base64 ──

    #[test]
    fn error_spki_not_valid_base64() {
        let err = parse_c2sp_key("tl.example.com+a1b2c3d4+!!!not_base64!!!").unwrap_err();
        assert!(matches!(err, ScittError::InvalidKeyFormat(_)));
    }

    // ── key hash doesn't match SPKI-DER hash ──

    #[test]
    fn error_key_hash_mismatch() {
        let (key_string, _) = make_c2sp_key(1, "tl.example.com");
        // Tamper the hash portion (parts[1]) by flipping first nibble
        let parts: Vec<&str> = key_string.splitn(3, '+').collect();
        let bad_hash = format!("ff{}", &parts[1][2..]);
        let tampered = format!("{}+{}+{}", parts[0], bad_hash, parts[2]);
        let err = parse_c2sp_key(&tampered).unwrap_err();
        assert!(matches!(err, ScittError::KeyHashMismatch));
    }

    // ── SPKI-DER valid Base64 but not a valid P-256 key ──

    #[test]
    fn error_spki_valid_base64_but_not_p256() {
        // Build a fake SPKI-DER that is just random bytes
        let fake_der = vec![0u8; 32];
        let digest = Sha256::digest(&fake_der);
        let kid: [u8; 4] = [digest[0], digest[1], digest[2], digest[3]];
        let key_hash_hex = hex::encode(kid);
        let spki_b64 = BASE64_STANDARD.encode(&fake_der);
        let key_string = format!("tl.example.com+{key_hash_hex}+{spki_b64}");
        let err = parse_c2sp_key(&key_string).unwrap_err();
        assert!(matches!(err, ScittError::InvalidPublicKey(_)));
    }

    // ── ScittKeyStore lookup found and not found ──

    #[test]
    fn keystore_lookup_found_and_not_found() {
        let (key_string, expected) = make_c2sp_key(1, "tl.example.com");
        let store = ScittKeyStore::from_c2sp_keys(&[key_string]).unwrap();

        // Found
        let found = store.get(expected.kid).unwrap();
        assert_eq!(found.name, "tl.example.com");
        assert_eq!(found.kid, expected.kid);

        // Not found — a random different kid
        let other_kid = [0xde, 0xad, 0xbe, 0xef];
        let err = store.get(other_kid).unwrap_err();
        assert!(matches!(err, ScittError::UnknownKeyId(k) if k == other_kid));
    }

    // ── ScittKeyStore with multiple keys having different kids ──

    #[test]
    fn keystore_multiple_keys() {
        let (k1, trusted1) = make_c2sp_key(1, "tl.example.com");
        let (k2, trusted2) = make_c2sp_key(2, "tl2.example.com");
        let store = ScittKeyStore::from_c2sp_keys(&[k1, k2]).unwrap();
        assert_eq!(store.len(), 2);
        assert!(!store.is_empty());

        let found1 = store.get(trusted1.kid).unwrap();
        assert_eq!(found1.name, "tl.example.com");

        let found2 = store.get(trusted2.kid).unwrap();
        assert_eq!(found2.name, "tl2.example.com");
    }

    // ── ScittKeyStore: all keys invalid returns error ──

    #[test]
    fn keystore_all_invalid_returns_error() {
        let bad_keys = vec!["no+plus".to_string(), "also+bad".to_string()];
        let err = ScittKeyStore::from_c2sp_keys(&bad_keys).unwrap_err();
        assert!(matches!(err, ScittError::InvalidKeyFormat(_)));
    }

    // ── ScittKeyStore: some valid some invalid, valid ones stored ──

    #[test]
    fn keystore_mixed_valid_and_invalid() {
        let (valid_key, trusted) = make_c2sp_key(1, "tl.example.com");
        let keys = vec!["not+valid".to_string(), valid_key, "also+bad".to_string()];
        let store = ScittKeyStore::from_c2sp_keys(&keys).unwrap();
        assert_eq!(store.len(), 1);
        let found = store.get(trusted.kid).unwrap();
        assert_eq!(found.name, "tl.example.com");
    }

    // ── ScittKeyStore: empty input returns error ──

    #[test]
    fn keystore_empty_input_returns_error() {
        let err = ScittKeyStore::from_c2sp_keys(&[]).unwrap_err();
        assert!(matches!(err, ScittError::InvalidKeyFormat(_)));
    }

    // ── len / is_empty ──

    #[test]
    fn keystore_len_and_is_empty() {
        let (k1, _) = make_c2sp_key(3, "tl.example.com");
        let store = ScittKeyStore::from_c2sp_keys(&[k1]).unwrap();
        assert_eq!(store.len(), 1);
        assert!(!store.is_empty());
    }

    // ── merge_from ──

    #[test]
    fn merge_from_adds_new_key() {
        let (k1, trusted1) = make_c2sp_key(1, "tl.example.com");
        let (k2, trusted2) = make_c2sp_key(2, "tl2.example.com");
        let store = ScittKeyStore::from_c2sp_keys(&[k1]).unwrap();
        assert_eq!(store.len(), 1);

        let merged = store.merge_from(&[k2]);
        assert_eq!(merged.len(), 2);
        assert!(merged.get(trusted1.kid).is_ok());
        assert!(merged.get(trusted2.kid).is_ok());
    }

    #[test]
    fn merge_from_does_not_overwrite_existing_key() {
        let (k1, trusted1) = make_c2sp_key(1, "tl.example.com");
        let store = ScittKeyStore::from_c2sp_keys(&[k1.clone()]).unwrap();
        let original_der = store
            .get(trusted1.kid)
            .unwrap()
            .key
            .to_public_key_der()
            .unwrap();

        // Merge the same key string again — should not replace
        let merged = store.merge_from(&[k1]);
        assert_eq!(merged.len(), 1);
        let after_der = merged
            .get(trusted1.kid)
            .unwrap()
            .key
            .to_public_key_der()
            .unwrap();
        assert_eq!(original_der.as_bytes(), after_der.as_bytes());
    }

    #[test]
    fn merge_from_with_empty_input_returns_same_keys() {
        let (k1, trusted1) = make_c2sp_key(1, "tl.example.com");
        let (k2, trusted2) = make_c2sp_key(2, "tl2.example.com");
        let store = ScittKeyStore::from_c2sp_keys(&[k1, k2]).unwrap();
        assert_eq!(store.len(), 2);

        let merged = store.merge_from(&[]);
        assert_eq!(merged.len(), 2);
        assert!(merged.get(trusted1.kid).is_ok());
        assert!(merged.get(trusted2.kid).is_ok());
    }

    #[test]
    fn merge_from_skips_invalid_keys() {
        let (k1, trusted1) = make_c2sp_key(1, "tl.example.com");
        let (k2, trusted2) = make_c2sp_key(2, "tl2.example.com");
        let store = ScittKeyStore::from_c2sp_keys(&[k1]).unwrap();

        let merged = store.merge_from(&["not+valid".to_string(), k2, "also+bad".to_string()]);
        assert_eq!(merged.len(), 2);
        assert!(merged.get(trusted1.kid).is_ok());
        assert!(merged.get(trusted2.kid).is_ok());
    }
}