vta-sdk 0.18.12

SDK for Verifiable Trust Agents operating in Verifiable Trust Communities
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
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
//! Sealed transfer — unified envelope for moving sensitive bundles between
//! workspace tools.
//!
//! See `sealed-bootstrap.md` at the repo root for the full design. The short
//! version: every secret-bearing artifact (admin credentials, context
//! provisioning bundles, DID secret exports, raw key material) is encrypted
//! end-to-end to a recipient-chosen ephemeral X25519 public key using HPKE
//! (RFC 9180), framed in a PGP/SSH-style ASCII armor with strict integrity
//! checks. Producer authenticity is established via one of three assertion
//! types depending on the trust mode (DID-signed, attestation quote, or
//! pinned-pubkey + out-of-band digest).

pub mod armor;
pub mod bundle;
pub mod chunk;
pub mod error;
pub mod hpke;
pub mod nonce;
pub mod request;
pub mod template_bootstrap;
pub mod verify;

pub use bundle::{
    ArmoredChunk, AssertionProof, AttestationQuoteAssertion, DidSignedAssertion,
    IssuedCredentialBundle, LabeledKey, MessagingBridgeCredentialsBundle, ProducerAssertion,
    RawPrivateKey, SealedBundle, SealedPayloadV1,
};
pub use chunk::{ChunkPlaintext, MAX_PAYLOAD_FRAGMENT, VERSION};
pub use error::SealedTransferError;
pub use hpke::{HpkeSealed, generate_keypair, open as hpke_open, seal as hpke_seal};
pub use nonce::{InMemoryNonceStore, NonceStore};
pub use request::BootstrapRequest;
pub use template_bootstrap::{
    AdminRotationPayload, DidKeyMaterial, KeyPair, TemplateBootstrapConfig,
    TemplateBootstrapPayload, TemplateOutput, VtaTrustBundle,
};

use sha2::{Digest, Sha256};
use zeroize::Zeroizing;

/// Generate a fresh Ed25519 keypair for a consumer bootstrap request.
///
/// Returns `(seed, pubkey)`. The seed is the 32-byte Ed25519 private key that
/// the consumer persists (e.g. on disk under `bootstrap-secrets/`) to open the
/// eventual sealed bundle; the pubkey is what goes into
/// [`BootstrapRequest::new`] and is encoded as a `did:key` on the wire.
///
/// At open time the consumer passes the seed to
/// [`ed25519_seed_to_x25519_secret`] and hands the result to [`open_bundle`].
/// Keeping the seed (rather than pre-deriving the X25519 secret) means the
/// same identity can later be used for signing without regenerating.
pub fn generate_ed25519_keypair() -> (Zeroizing<[u8; 32]>, [u8; 32]) {
    let mut seed = [0u8; 32];
    getrandom::fill(&mut seed).expect("OS CSPRNG failed — see hpke::OsCsprng docs");
    let signing = ed25519_dalek::SigningKey::from_bytes(&seed);
    let pubkey = signing.verifying_key().to_bytes();
    (Zeroizing::new(seed), pubkey)
}

/// Derive the X25519 HPKE secret that pairs with an Ed25519 seed. Thin wrapper
/// around [`affinidi_crypto::ed25519::ed25519_private_to_x25519`] that keeps
/// the returned bytes in a [`Zeroizing`] wrapper so the call site doesn't
/// accidentally leave the derived scalar on the stack.
pub fn ed25519_seed_to_x25519_secret(seed: &[u8; 32]) -> Zeroizing<[u8; 32]> {
    Zeroizing::new(affinidi_crypto::ed25519::ed25519_private_to_x25519(seed))
}

/// Default digest algorithm for new bundles.
pub const DEFAULT_DIGEST_ALGO: &str = "sha256";

/// Compute the canonical bundle digest used for out-of-band verification.
///
/// `sha256(sealed_bytes_chunk_0 || sealed_bytes_chunk_1 || ...)`. The chunks
/// are concatenated in chunk-index order; the digest is over the post-HPKE
/// ciphertexts, so any tamper to the armored bytes invalidates it. Returned as
/// lowercase hex.
pub fn bundle_digest(bundle: &SealedBundle) -> String {
    let mut chunks: Vec<&ArmoredChunk> = bundle.chunks.iter().collect();
    chunks.sort_by_key(|c| c.chunk_index);
    let mut hasher = Sha256::new();
    for c in chunks {
        hasher.update(&c.sealed_bytes);
    }
    // sha2 0.11's `finalize()` returns a hybrid-array `Array<u8, _>` which
    // doesn't implement `LowerHex`. Encode the bytes explicitly.
    crate::hex::lower(hasher.finalize().as_slice())
}

/// Seal a [`SealedPayloadV1`] for delivery to `recipient_pubkey`.
///
/// `bundle_id` should be the consumer's request nonce. The producer's
/// [`NonceStore`] is consulted to enforce single-use semantics: re-sealing the
/// same `bundle_id` returns [`SealedTransferError::NonceReplay`].
pub async fn seal_payload(
    recipient_pubkey: &[u8; 32],
    bundle_id: [u8; 16],
    producer: ProducerAssertion,
    payload: &SealedPayloadV1,
    nonce_store: &dyn NonceStore,
) -> Result<SealedBundle, SealedTransferError> {
    nonce_store.check_and_record(&bundle_id).await?;

    let mut payload_bytes = Vec::new();
    ciborium::ser::into_writer(payload, &mut payload_bytes)
        .map_err(|e| SealedTransferError::CborEncode(e.to_string()))?;

    let total_chunks_usize = payload_bytes.len().div_ceil(MAX_PAYLOAD_FRAGMENT).max(1);
    let total_chunks: u16 = total_chunks_usize
        .try_into()
        .map_err(|_| SealedTransferError::Wire("payload too large for u16 chunk count".into()))?;

    let mut chunks: Vec<ArmoredChunk> = Vec::with_capacity(total_chunks_usize);
    for i in 0..total_chunks_usize {
        let start = i * MAX_PAYLOAD_FRAGMENT;
        let end = (start + MAX_PAYLOAD_FRAGMENT).min(payload_bytes.len());
        let fragment = payload_bytes[start..end].to_vec();
        let chunk_index = i as u16;
        let plaintext = ChunkPlaintext {
            version: VERSION,
            bundle_id,
            chunk_index,
            total_chunks,
            producer_did: if i == 0 {
                Some(producer.producer_did.clone())
            } else {
                None
            },
            producer_assertion: if i == 0 { Some(producer.clone()) } else { None },
            payload_fragment: fragment,
        };
        let aad = plaintext.aad(DEFAULT_DIGEST_ALGO);
        let mut pt_cbor = Vec::new();
        ciborium::ser::into_writer(&plaintext, &mut pt_cbor)
            .map_err(|e| SealedTransferError::CborEncode(e.to_string()))?;
        let sealed = hpke_seal(recipient_pubkey, &pt_cbor, &aad)?;
        let mut sealed_cbor = Vec::new();
        ciborium::ser::into_writer(&sealed, &mut sealed_cbor)
            .map_err(|e| SealedTransferError::CborEncode(e.to_string()))?;
        chunks.push(ArmoredChunk {
            chunk_index,
            total_chunks,
            sealed_bytes: sealed_cbor,
        });
    }

    Ok(SealedBundle {
        bundle_id,
        digest_algo: DEFAULT_DIGEST_ALGO.to_string(),
        chunks,
    })
}

/// The result of opening a sealed bundle: the payload, plus the producer
/// assertion the caller must verify against its trust policy.
#[derive(Debug)]
pub struct OpenedBundle {
    pub payload: SealedPayloadV1,
    pub producer: ProducerAssertion,
    pub bundle_id: [u8; 16],
}

/// Policy for opening a bundle whose producer uses the `PinnedOnly`
/// assertion (no in-band integrity anchor).
///
/// PinnedOnly tells the opener "the bundle itself carries no proof of
/// who produced it — rely on something outside the bundle for that".
/// If that "something outside" is missing, opening the bundle means
/// trusting anyone who happened to know the recipient's X25519 public
/// key. The enum forces the caller to name what the outside trust
/// anchor is.
#[derive(Debug, Clone, Copy)]
pub enum PinnedOnlyPolicy {
    /// Require the caller to supply `expect_digest` to
    /// [`open_bundle_with_policy`]. The OOB digest is the integrity
    /// anchor. This is the safe default and what `open_bundle` uses.
    RequireOobDigest,
    /// The caller has an independent trust anchor — e.g. the bundle
    /// arrived over an authenticated HTTP session, or is consumed
    /// inside a single-process key-import handshake where the peer is
    /// the same authenticated caller. Document the anchor at the call
    /// site. Using this variant without a real anchor is equivalent to
    /// accepting bundles from anyone who knows the recipient pubkey.
    CallerHasIndependentTrustAnchor,
}

/// Open a [`SealedBundle`] with the recipient's secret. Performs:
///
/// 1. Optional canonical-digest verification when `expect_digest` is `Some`.
/// 2. Per-chunk HPKE open with header AAD binding.
/// 3. Chunk header consistency + reassembly.
/// 4. Extraction of the chunk-0 producer assertion.
/// 5. Coupling check: if the producer's assertion is `PinnedOnly`,
///    `expect_digest` **must** have been supplied. PinnedOnly carries
///    no in-band integrity anchor, so the out-of-band digest is the
///    only thing preventing a party with the recipient's X25519 public
///    key from forging a bundle. See
///    [`SealedTransferError::PinnedOnlyRequiresDigest`].
///
/// The caller is then responsible for verifying the producer assertion against
/// its trust policy via
/// [`crate::sealed_transfer::verify::verify_producer_assertion_with_pubkey`].
///
/// For flows where the trust anchor is elsewhere (HTTP session auth on
/// a key-import endpoint, in-process handoff, etc.), use
/// [`open_bundle_with_policy`] with
/// [`PinnedOnlyPolicy::CallerHasIndependentTrustAnchor`].
pub fn open_bundle(
    recipient_secret: &[u8; 32],
    bundle: &SealedBundle,
    expect_digest: Option<&str>,
) -> Result<OpenedBundle, SealedTransferError> {
    open_bundle_with_policy(
        recipient_secret,
        bundle,
        expect_digest,
        PinnedOnlyPolicy::RequireOobDigest,
    )
}

/// Variant of [`open_bundle`] that accepts an explicit policy for
/// PinnedOnly producer assertions. Prefer `open_bundle` unless the
/// trust anchor is explicitly elsewhere.
pub fn open_bundle_with_policy(
    recipient_secret: &[u8; 32],
    bundle: &SealedBundle,
    expect_digest: Option<&str>,
    pinned_policy: PinnedOnlyPolicy,
) -> Result<OpenedBundle, SealedTransferError> {
    if let Some(expected) = expect_digest {
        let got = bundle_digest(bundle);
        if !constant_time_eq(expected.as_bytes(), got.as_bytes()) {
            return Err(SealedTransferError::DigestMismatch {
                expected: expected.to_string(),
                got,
            });
        }
    }

    let mut plaintexts: Vec<ChunkPlaintext> = Vec::with_capacity(bundle.chunks.len());
    for chunk in &bundle.chunks {
        let sealed: HpkeSealed = ciborium::de::from_reader(&chunk.sealed_bytes[..])
            .map_err(|e| SealedTransferError::CborDecode(e.to_string()))?;
        // Build the AAD from the *armor-declared* chunk header. If the AEAD
        // open succeeds, the inner header matches the outer (the inner header
        // was the AAD at seal time).
        let header_for_aad = ChunkPlaintext {
            version: VERSION,
            bundle_id: bundle.bundle_id,
            chunk_index: chunk.chunk_index,
            total_chunks: chunk.total_chunks,
            producer_did: None,
            producer_assertion: None,
            payload_fragment: Vec::new(),
        };
        let aad = header_for_aad.aad(&bundle.digest_algo);
        let pt_bytes = hpke_open(recipient_secret, &sealed, &aad)?;
        let pt: ChunkPlaintext = ciborium::de::from_reader(&pt_bytes[..])
            .map_err(|e| SealedTransferError::CborDecode(e.to_string()))?;
        if pt.bundle_id != bundle.bundle_id
            || pt.chunk_index != chunk.chunk_index
            || pt.total_chunks != chunk.total_chunks
        {
            return Err(SealedTransferError::ChunkMismatch(
                "inner header != armor header".into(),
            ));
        }
        plaintexts.push(pt);
    }

    // Extract chunk-0 assertion before consuming the vec for reassembly.
    let chunk0 = plaintexts
        .iter()
        .find(|p| p.chunk_index == 0)
        .ok_or(SealedTransferError::MissingAssertion)?;
    let producer = chunk0
        .producer_assertion
        .clone()
        .ok_or(SealedTransferError::MissingAssertion)?;
    let declared_did = chunk0
        .producer_did
        .clone()
        .ok_or(SealedTransferError::MissingAssertion)?;
    if declared_did != producer.producer_did {
        return Err(SealedTransferError::ProducerMismatch {
            declared: declared_did,
            expected: producer.producer_did.clone(),
        });
    }

    let payload_bytes = chunk::reassemble(plaintexts)?;
    let payload: SealedPayloadV1 = ciborium::de::from_reader(&payload_bytes[..])
        .map_err(|e| SealedTransferError::CborDecode(e.to_string()))?;

    // PinnedOnly + no OOB digest = no integrity anchor unless the
    // caller has named an out-of-band trust anchor (e.g. HTTP session
    // auth). DidSigned verifies via crypto in the caller; Attested via
    // nitro quote verification.
    if matches!(producer.proof, AssertionProof::PinnedOnly)
        && expect_digest.is_none()
        && matches!(pinned_policy, PinnedOnlyPolicy::RequireOobDigest)
    {
        return Err(SealedTransferError::PinnedOnlyRequiresDigest);
    }

    Ok(OpenedBundle {
        payload,
        producer,
        bundle_id: bundle.bundle_id,
    })
}

/// Constant-time byte-slice equality. Returns `false` for different-length
/// inputs without short-circuiting on content.
///
/// Exported so other crates can use a single, audited implementation for
/// secret-dependent comparisons (challenge matching, DID equality on
/// authenticate, etc.). Do not use `==` for anything derived from a
/// challenge, token, or other secret-adjacent material.
pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
    if a.len() != b.len() {
        return false;
    }
    let mut acc = 0u8;
    for (x, y) in a.iter().zip(b.iter()) {
        acc |= x ^ y;
    }
    acc == 0
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::credentials::CredentialBundle;

    fn sample_payload() -> SealedPayloadV1 {
        SealedPayloadV1::AdminCredential(Box::new(CredentialBundle::new(
            "did:key:z6Mk123",
            "z1234567890",
            "did:key:z6MkVTA",
        )))
    }

    fn sample_assertion(producer_did: String) -> ProducerAssertion {
        ProducerAssertion {
            producer_did,
            proof: AssertionProof::PinnedOnly,
        }
    }

    #[tokio::test]
    async fn round_trip_single_chunk() {
        let (recip_sk, recip_pk) = generate_keypair();
        let (_prod_sk, prod_pk) = generate_ed25519_keypair();
        let assertion =
            sample_assertion(affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod_pk));
        let store = InMemoryNonceStore::new();
        let bundle_id = [7u8; 16];

        let bundle = seal_payload(&recip_pk, bundle_id, assertion, &sample_payload(), &store)
            .await
            .unwrap();

        assert_eq!(bundle.chunks.len(), 1);

        // PinnedOnly assertion requires an OOB digest — the bundle has
        // no in-band integrity anchor otherwise.
        let digest = bundle_digest(&bundle);
        let opened = open_bundle(&recip_sk, &bundle, Some(&digest)).unwrap();
        assert_eq!(opened.bundle_id, bundle_id);
        match opened.payload {
            SealedPayloadV1::AdminCredential(c) => assert_eq!(c.did, "did:key:z6Mk123"),
            _ => panic!("wrong payload variant"),
        }
    }

    #[tokio::test]
    async fn round_trip_multi_chunk() {
        let (recip_sk, recip_pk) = generate_keypair();
        let (_prod_sk, prod_pk) = generate_ed25519_keypair();
        let assertion =
            sample_assertion(affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod_pk));
        let store = InMemoryNonceStore::new();
        let bundle_id = [9u8; 16];

        // Force multi-chunk by stuffing a large LabeledKey set.
        let big_keys: Vec<LabeledKey> = (0..2048)
            .map(|i| LabeledKey {
                label: format!("k-{i}"),
                key_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(),
                key_type: Some("ed25519".to_string()),
            })
            .collect();
        let payload = SealedPayloadV1::AdminKeySet(big_keys);

        let bundle = seal_payload(&recip_pk, bundle_id, assertion, &payload, &store)
            .await
            .unwrap();
        assert!(bundle.chunks.len() > 1, "expected multi-chunk bundle");

        let digest = bundle_digest(&bundle);
        let opened = open_bundle(&recip_sk, &bundle, Some(&digest)).unwrap();
        match opened.payload {
            SealedPayloadV1::AdminKeySet(keys) => assert_eq!(keys.len(), 2048),
            _ => panic!("wrong variant"),
        }
    }

    #[tokio::test]
    async fn round_trip_messaging_bridge_credentials() {
        // Issue #512: a connector's platform secrets seal/open through the
        // additive `MessagingBridgeCredentials` variant with no change to the
        // framing or existing variants.
        let (recip_sk, recip_pk) = generate_keypair();
        let (_prod_sk, prod_pk) = generate_ed25519_keypair();
        let assertion =
            sample_assertion(affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod_pk));
        let store = InMemoryNonceStore::new();
        let bundle_id = [12u8; 16];

        let mut fields = serde_json::Map::new();
        fields.insert("botToken".into(), serde_json::json!("123:abc"));
        fields.insert(
            "registration".into(),
            serde_json::json!({ "number": "+15551234", "captcha": "tok" }),
        );
        let payload = SealedPayloadV1::MessagingBridgeCredentials(Box::new(
            MessagingBridgeCredentialsBundle {
                platform: "telegram".to_string(),
                fields: fields.clone(),
            },
        ));

        let bundle = seal_payload(&recip_pk, bundle_id, assertion, &payload, &store)
            .await
            .unwrap();
        let digest = bundle_digest(&bundle);
        let opened = open_bundle(&recip_sk, &bundle, Some(&digest)).unwrap();
        match opened.payload {
            SealedPayloadV1::MessagingBridgeCredentials(b) => {
                assert_eq!(b.platform, "telegram");
                assert_eq!(b.fields, fields);
            }
            other => panic!("wrong variant: {other:?}"),
        }
    }

    #[tokio::test]
    async fn replay_rejected_by_nonce_store() {
        let (_recip_sk, recip_pk) = generate_keypair();
        let (_prod_sk, prod_pk) = generate_ed25519_keypair();
        let assertion =
            sample_assertion(affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod_pk));
        let store = InMemoryNonceStore::new();
        let bundle_id = [1u8; 16];

        seal_payload(
            &recip_pk,
            bundle_id,
            assertion.clone(),
            &sample_payload(),
            &store,
        )
        .await
        .unwrap();
        let err = seal_payload(&recip_pk, bundle_id, assertion, &sample_payload(), &store)
            .await
            .unwrap_err();
        assert!(matches!(err, SealedTransferError::NonceReplay));
    }

    #[tokio::test]
    async fn digest_mismatch_rejected() {
        let (recip_sk, recip_pk) = generate_keypair();
        let (_prod_sk, prod_pk) = generate_ed25519_keypair();
        let assertion =
            sample_assertion(affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod_pk));
        let store = InMemoryNonceStore::new();

        let bundle = seal_payload(&recip_pk, [2u8; 16], assertion, &sample_payload(), &store)
            .await
            .unwrap();
        let err = open_bundle(&recip_sk, &bundle, Some("deadbeef")).unwrap_err();
        assert!(matches!(err, SealedTransferError::DigestMismatch { .. }));
    }

    #[tokio::test]
    async fn digest_match_accepted() {
        let (recip_sk, recip_pk) = generate_keypair();
        let (_prod_sk, prod_pk) = generate_ed25519_keypair();
        let assertion =
            sample_assertion(affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod_pk));
        let store = InMemoryNonceStore::new();

        let bundle = seal_payload(&recip_pk, [3u8; 16], assertion, &sample_payload(), &store)
            .await
            .unwrap();
        let digest = bundle_digest(&bundle);
        open_bundle(&recip_sk, &bundle, Some(&digest)).unwrap();
    }

    #[tokio::test]
    async fn armor_round_trip() {
        let (recip_sk, recip_pk) = generate_keypair();
        let (_prod_sk, prod_pk) = generate_ed25519_keypair();
        let assertion =
            sample_assertion(affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod_pk));
        let store = InMemoryNonceStore::new();

        let bundle = seal_payload(&recip_pk, [4u8; 16], assertion, &sample_payload(), &store)
            .await
            .unwrap();
        let armored = armor::encode(&bundle);
        assert!(armored.contains("BEGIN VTA SEALED BUNDLE"));
        let parsed = armor::decode(&armored).unwrap();
        assert_eq!(parsed.len(), 1);
        let digest = bundle_digest(&parsed[0]);
        let opened = open_bundle(&recip_sk, &parsed[0], Some(&digest)).unwrap();
        match opened.payload {
            SealedPayloadV1::AdminCredential(c) => assert_eq!(c.did, "did:key:z6Mk123"),
            _ => panic!("wrong variant"),
        }
    }

    #[tokio::test]
    async fn pinned_only_without_digest_rejected() {
        // The S-10 coupling: a bundle that uses PinnedOnly has no
        // in-band integrity anchor, so opening without supplying an
        // OOB digest must fail closed rather than silently succeed.
        let (recip_sk, recip_pk) = generate_keypair();
        let (_prod_sk, prod_pk) = generate_ed25519_keypair();
        let assertion =
            sample_assertion(affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod_pk));
        let store = InMemoryNonceStore::new();

        let bundle = seal_payload(&recip_pk, [11u8; 16], assertion, &sample_payload(), &store)
            .await
            .unwrap();

        let err = open_bundle(&recip_sk, &bundle, None)
            .expect_err("PinnedOnly without OOB digest must be rejected");
        assert!(
            matches!(err, SealedTransferError::PinnedOnlyRequiresDigest),
            "expected PinnedOnlyRequiresDigest, got {err:?}"
        );
    }

    #[tokio::test]
    async fn armor_corruption_caught_by_crc24() {
        let (_recip_sk, recip_pk) = generate_keypair();
        let (_prod_sk, prod_pk) = generate_ed25519_keypair();
        let assertion =
            sample_assertion(affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod_pk));
        let store = InMemoryNonceStore::new();

        let bundle = seal_payload(&recip_pk, [5u8; 16], assertion, &sample_payload(), &store)
            .await
            .unwrap();
        let mut armored = armor::encode(&bundle);
        // Flip one base64 character somewhere in the middle of the body.
        let body_offset = armored.find("\n\n").unwrap() + 2;
        let bytes = unsafe { armored.as_bytes_mut() };
        bytes[body_offset] = if bytes[body_offset] == b'A' {
            b'B'
        } else {
            b'A'
        };
        let err = armor::decode(&armored).unwrap_err();
        assert!(
            matches!(err, SealedTransferError::Crc24Mismatch { .. }),
            "expected Crc24Mismatch, got {err:?}"
        );
    }

    #[tokio::test]
    async fn aad_tamper_caught_by_aead() {
        let (recip_sk, recip_pk) = generate_keypair();
        let (_prod_sk, prod_pk) = generate_ed25519_keypair();
        let assertion =
            sample_assertion(affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod_pk));
        let store = InMemoryNonceStore::new();

        let bundle = seal_payload(&recip_pk, [6u8; 16], assertion, &sample_payload(), &store)
            .await
            .unwrap();
        // Tamper: rewrite the bundle.bundle_id without re-sealing. The inner
        // chunk's AAD will use the new bundle_id, which will not match the AAD
        // used at seal time.
        let mut tampered = bundle.clone();
        tampered.bundle_id = [0xff; 16];
        let err = open_bundle(&recip_sk, &tampered, None).unwrap_err();
        assert!(matches!(err, SealedTransferError::Hpke(_)));
    }

    /// Helper: round-trip a SealedBundle through armor → mutate text →
    /// decode → open. Returns the open error.
    async fn roundtrip_with_armor_mutation(
        mutate: impl FnOnce(&mut String),
    ) -> SealedTransferError {
        let (recip_sk, recip_pk) = generate_keypair();
        let (_prod_sk, prod_pk) = generate_ed25519_keypair();
        let assertion =
            sample_assertion(affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod_pk));
        let store = InMemoryNonceStore::new();

        let bundle = seal_payload(&recip_pk, [7u8; 16], assertion, &sample_payload(), &store)
            .await
            .expect("seal");

        let mut armored = armor::encode(&bundle);
        mutate(&mut armored);

        // Some mutations cause armor::decode to fail (e.g. Bundle-Id hex
        // invalidation); others land at AEAD-open. Either is acceptable —
        // the test asserts the open path NEVER returns an OpenedBundle for
        // mutated armor.
        match armor::decode(&armored) {
            Ok(bundles) => {
                let merged = bundles.into_iter().next().expect("at least one bundle");
                open_bundle(&recip_sk, &merged, None).expect_err("open must fail")
            }
            Err(e) => e,
        }
    }

    #[tokio::test]
    async fn armor_bundle_id_tamper_caught() {
        // Flip a hex char in the Bundle-Id header. Either the parser
        // rejects (still valid hex but different bytes) or AEAD fails on
        // open because AAD is built from the (tampered) armor header.
        let err = roundtrip_with_armor_mutation(|s| {
            let line = s.find("Bundle-Id: ").expect("Bundle-Id present");
            let hex_start = line + "Bundle-Id: ".len();
            let bytes = unsafe { s.as_bytes_mut() };
            // Flip one hex nibble. Valid hex → parser accepts → AEAD rejects.
            bytes[hex_start] = if bytes[hex_start] == b'0' { b'1' } else { b'0' };
        })
        .await;
        // Could be Hpke (AEAD AAD mismatch) or Armor (less likely path).
        // Both are acceptable — the test asserts open does not succeed.
        assert!(matches!(
            err,
            SealedTransferError::Hpke(_) | SealedTransferError::Armor(_)
        ));
    }

    #[tokio::test]
    async fn armor_chunk_count_tamper_caught() {
        // Mutate the Chunk header's total to claim more chunks than were
        // produced. Triggers either an "out of range" armor error (1/2 with
        // only one block) or chunk-mismatch on reassembly.
        let err = roundtrip_with_armor_mutation(|s| {
            let line = s.find("Chunk: 1/1").expect("Chunk: 1/1 present");
            let bytes = unsafe { s.as_bytes_mut() };
            // Rewrite "Chunk: 1/1" → "Chunk: 1/9" (claim 9 total chunks
            // when only 1 was sealed). AAD includes total_chunks → AEAD
            // open fails on the AAD mismatch.
            bytes[line + "Chunk: 1/".len()] = b'9';
        })
        .await;
        assert!(
            matches!(
                err,
                SealedTransferError::Hpke(_)
                    | SealedTransferError::ChunkMismatch(_)
                    | SealedTransferError::MissingChunks { .. }
                    | SealedTransferError::Armor(_)
            ),
            "expected Hpke / ChunkMismatch / MissingChunks / Armor, got {err:?}"
        );
    }

    #[tokio::test]
    async fn armor_digest_algo_tamper_caught() {
        // Swap "Digest-Algo: sha256" for a different (but plausible) algo.
        // AAD includes digest_algo → AEAD open fails on AAD mismatch.
        let err = roundtrip_with_armor_mutation(|s| {
            *s = s.replace("Digest-Algo: sha256", "Digest-Algo: sha512");
        })
        .await;
        assert!(matches!(err, SealedTransferError::Hpke(_)), "got {err:?}");
    }

    #[tokio::test]
    async fn armor_unknown_header_rejected() {
        // Forward-compat hazard: prior to this guard, unknown headers were
        // silently dropped by the parser. A future header that participates
        // in AAD would be ignored on older parsers; an attacker could also
        // inject a header today with no covering AEAD. Reject at parse.
        let err = roundtrip_with_armor_mutation(|s| {
            // Insert a bogus header right after Version.
            *s = s.replace("Version: 1\n", "Version: 1\nX-Trust-Me: yes\n");
        })
        .await;
        match err {
            SealedTransferError::Armor(msg) => {
                assert!(
                    msg.contains("X-Trust-Me") || msg.contains("unknown header"),
                    "got {msg}"
                );
            }
            other => panic!("expected Armor(unknown header), got {other:?}"),
        }
    }

    #[tokio::test]
    async fn missing_chunk_rejected() {
        let (recip_sk, recip_pk) = generate_keypair();
        let (_prod_sk, prod_pk) = generate_ed25519_keypair();
        let assertion =
            sample_assertion(affinidi_crypto::did_key::ed25519_pub_to_did_key(&prod_pk));
        let store = InMemoryNonceStore::new();
        let big_keys: Vec<LabeledKey> = (0..2048)
            .map(|i| LabeledKey {
                label: format!("k-{i}"),
                key_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(),
                key_type: None,
            })
            .collect();
        let payload = SealedPayloadV1::AdminKeySet(big_keys);
        let mut bundle = seal_payload(&recip_pk, [10u8; 16], assertion, &payload, &store)
            .await
            .unwrap();
        assert!(bundle.chunks.len() > 1);
        // Drop the last chunk.
        bundle.chunks.pop();
        let err = open_bundle(&recip_sk, &bundle, None).unwrap_err();
        assert!(
            matches!(err, SealedTransferError::MissingChunks { .. }),
            "got {err:?}"
        );
    }

    #[test]
    fn bootstrap_request_round_trip() {
        let (_seed, ed_pub) = generate_ed25519_keypair();
        let req = BootstrapRequest::new(ed_pub, [42u8; 16], Some("test".into()));
        // Wire field is a did:key string, not a raw pubkey.
        assert!(req.client_did.starts_with("did:key:z6Mk"));
        let json = serde_json::to_string(&req).unwrap();
        let back: BootstrapRequest = serde_json::from_str(&json).unwrap();
        assert_eq!(back.decode_client_ed25519_pub().unwrap(), ed_pub);
        assert_eq!(back.decode_nonce().unwrap(), [42u8; 16]);
    }

    #[test]
    fn bootstrap_request_derives_x25519_pub_from_did_key() {
        // The producer only ever sees the did:key; it must derive the same
        // X25519 pubkey that would pair with the consumer-side X25519 secret
        // derived from the same Ed25519 seed.
        let (seed, ed_pub) = generate_ed25519_keypair();
        let req = BootstrapRequest::new(ed_pub, [1u8; 16], None);

        let producer_x25519 = req.decode_client_x25519_pub().unwrap();
        let consumer_x25519 = ed25519_seed_to_x25519_secret(&seed);

        // Round-trip through HPKE's own primitives to confirm the pair agrees.
        let sealed = hpke_seal(&producer_x25519, b"payload", b"aad").unwrap();
        let opened = hpke_open(&consumer_x25519, &sealed, b"aad").unwrap();
        assert_eq!(&opened, b"payload");
    }

    #[test]
    fn bootstrap_request_rejects_unknown_fields() {
        let json = r#"{
            "version": 1,
            "client_did": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
            "nonce": "AA",
            "requested_role": "Admin"
        }"#;
        assert!(serde_json::from_str::<BootstrapRequest>(json).is_err());
    }
}