vta-sdk 0.18.13

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
//! `SealedPayloadV1::TemplateBootstrap` payload shape.
//!
//! Lives under `sealed_transfer` (not `provision_integration`) so the
//! enum variant compiles whenever the `sealed-transfer` feature is on —
//! opening a bundle never requires `affinidi-vc`. The VC inside is
//! stored as `serde_json::Value`; consumers that want a typed view parse
//! it via `crate::provision_integration::credential`.
//!
//! Carries:
//! - The VTA-issued admin-authorization VC (short-lived, opaque JSON).
//! - Private key material the VTA minted for DIDs the integration will
//!   operate (zeroized on drop).
//! - First-boot config (template outputs, VTA trust bundle, connect URL).
//!
//! See `docs/02-vta/provision-integration.md` §"Payload" for the full
//! design.

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;

/// Top-level payload for `SealedPayloadV1::AdminRotation`.
///
/// Carries everything an integration needs to switch its long-term
/// admin DID from the ephemeral setup `did:key` over to a fresh,
/// VTA-minted admin identity. Unlike [`TemplateBootstrapPayload`],
/// there is no integration DID, no `did_document`, and no template
/// outputs — the consumer brings (or mints elsewhere) its own
/// integration-side DIDs and only needs an admin credential at this
/// VTA.
///
/// Produced by the `BootstrapAsk::AdminRotation` flow on the VTA;
/// opened on the consumer side by the SDK's `provision_client`
/// runners which surface it as a `VtaReply::AdminOnly`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AdminRotationPayload {
    /// VTA-issued `VtaAuthorizationCredential` (no `operator_of`
    /// claim — there's no integration to operate). Short-lived,
    /// verified once at bundle open, then archived.
    pub authorization: serde_json::Value,

    /// Key material for the freshly-minted admin DID. The consumer
    /// installs this in its keystore and uses it as the long-term
    /// authentication identity at the VTA.
    pub admin: DidKeyMaterial,

    /// URL the integration should use to reach the VTA's REST API.
    /// `None` when the VTA is DIDComm-only.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub vta_url: Option<String>,

    /// VTA identity material — enough to verify the authorization VC
    /// offline at first boot.
    pub vta_trust: VtaTrustBundle,
}

/// Top-level payload for `SealedPayloadV1::TemplateBootstrap`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TemplateBootstrapPayload {
    /// VTA-issued `VtaAuthorizationCredential`. Short-lived; verified at
    /// bundle open; never re-verified after that (ACL is the steady-
    /// state authority).
    pub authorization: serde_json::Value,

    /// Private key material for DIDs the VTA minted on the integration's
    /// behalf, keyed by DID URI. Usually one entry (the agent DID the
    /// template rendered); may be empty if the template only needed
    /// admin-level authorization.
    pub secrets: BTreeMap<String, DidKeyMaterial>,

    /// Non-credential first-boot configuration.
    pub config: TemplateBootstrapConfig,
}

/// Key material for a single DID the integration now controls. Secret
/// bytes are zeroized on drop.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DidKeyMaterial {
    /// The DID this material is for (e.g. the rendered integration DID).
    pub did: String,
    /// Ed25519 signing keypair.
    pub signing_key: KeyPair,
    /// X25519 key-agreement keypair.
    pub ka_key: KeyPair,
}

/// A single keypair with DID-URL-qualified key id. The private half is
/// held in a [`Zeroizing`] buffer at rest.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct KeyPair {
    /// DID URL with fragment, e.g. `did:webvh:host/path#key-0`. Matches
    /// the `id` of the corresponding verification method in the DID doc.
    pub key_id: String,
    /// Multibase-encoded public key.
    pub public_key_multibase: String,
    /// Multibase-encoded private key. Not zeroized during serde to keep
    /// the derived `Serialize`/`Deserialize` simple; wrap in
    /// [`Zeroizing`] via [`Self::private_zeroizing`] when loading into
    /// live memory.
    pub private_key_multibase: String,
}

impl KeyPair {
    /// Take the private key out into a [`Zeroizing`] buffer. Call at
    /// the moment you use the key (e.g. feed to a signer) so the
    /// cleartext scalar lives on the stack for as little time as
    /// possible.
    pub fn private_zeroizing(&self) -> Zeroizing<String> {
        Zeroizing::new(self.private_key_multibase.clone())
    }
}

/// First-boot configuration carried alongside the authorization VC.
/// Non-credential data: template metadata, rendered DID document,
/// template-declared side outputs, connect URL, VTA trust material.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TemplateBootstrapConfig {
    /// Name of the template the VTA rendered (audit).
    pub template_name: String,
    /// Template's `kind` field (`"mediator"`, `"webvh-hosting"`, etc.).
    /// Consumers dispatch on this for kind-specific handling.
    pub template_kind: String,
    /// The fully-rendered DID document for the integration's DID, as
    /// JSON. The integration's own webvh host should publish this.
    pub did_document: serde_json::Value,
    /// Template-declared side outputs (e.g. `did.jsonl` log for webvh,
    /// DIDComm service advertisement for mediators).
    pub outputs: Vec<TemplateOutput>,
    /// URL the integration should use to reach the VTA's REST API.
    /// None when the integration doesn't make outbound VTA calls.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub vta_url: Option<String>,
    /// VTA identity material — enough for the integration to verify the
    /// authorization VC offline at first boot.
    pub vta_trust: VtaTrustBundle,
}

/// Side outputs a template renderer emits alongside the DID document.
///
/// Typed variants exist for output kinds every VTA deployment knows
/// about; a catch-all [`Self::Generic`] variant carries arbitrary
/// template-declared outputs so operator-uploaded templates that need
/// novel first-boot artefacts (status-list URLs, OOB invitations, TLS
/// CSRs, webhook configs, …) work without an SDK change.
///
/// Downstream consumers that understand a specific `kind` string match
/// on `Generic { kind, payload }` for that kind; everything else they
/// forward to a default handler or log-and-ignore. Preserves the
/// "uploading a template is the whole author surface for new
/// integrations" invariant from `CLAUDE.md`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TemplateOutput {
    /// Raw `did.jsonl` log for a `did:webvh` DID. The integration writes
    /// this to `/.well-known/did.jsonl` on its own webvh host at first
    /// boot.
    WebvhLog {
        /// Which DID the log describes.
        did: String,
        /// Raw newline-delimited JSON log content.
        log: String,
    },
    /// DIDComm v2 service-endpoint advertisement the integration should
    /// publish on its DID doc (and which the template already embedded
    /// there — duplicated here for operational config convenience).
    DidCommService {
        /// Which DID the service belongs to.
        did: String,
        url: String,
        accept: Vec<String>,
        routing_keys: Vec<String>,
    },
    /// Extensibility escape hatch for template-declared outputs the SDK
    /// doesn't know about. `kind` is a short string the template author
    /// picks (lowercase kebab-case by convention — e.g. `status-list`,
    /// `oob-invitation`, `tls-csr`). `payload` is whatever JSON the
    /// template emitted; consumers dispatch on `kind` and parse
    /// `payload` as their expected shape.
    Generic {
        /// Caller-chosen tag identifying the output kind. Must not
        /// collide with the snake_case discriminant of an existing
        /// typed variant (`webvh_log`, `didcomm_service`) — consumers
        /// match typed variants first, so a `Generic { kind:
        /// "webvh_log", … }` would still deserialize as
        /// `Generic` but shadow nothing, just confuse readers. Enforce
        /// naming elsewhere (template validator) rather than in the
        /// wire shape.
        kind: String,
        /// Free-form JSON the template author produced. Consumers that
        /// understand `kind` parse this into a typed shape of their
        /// own; consumers that don't log + skip.
        payload: serde_json::Value,
    },
}

/// VTA identity material an integration needs to verify the returned
/// sealed bundle's contents offline.
///
/// Shipped *inside* every provisioning bundle. On first boot the
/// integration:
///   1. Takes `vta_did_document` as the trust anchor.
///   2. If `vta_did_log` is present, replays the log and confirms the
///      rendered doc matches — cross-verifying the shipped doc.
///   3. Extracts the `assertionMethod` verification method from the doc
///      and uses it to verify the authorization VC's proof.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct VtaTrustBundle {
    pub vta_did: String,
    pub vta_did_document: serde_json::Value,
    /// Raw `did.jsonl` for `did:webvh` VTAs — lets the integration
    /// verify the doc independently. None for self-resolving methods
    /// like `did:key`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub vta_did_log: Option<String>,
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::sealed_transfer::SealedPayloadV1;
    use serde_json::json;

    fn sample_payload() -> TemplateBootstrapPayload {
        TemplateBootstrapPayload {
            authorization: json!({ "type": ["VerifiableCredential", "VtaAuthorizationCredential"] }),
            secrets: BTreeMap::from([(
                "did:webvh:mediator.example.com".to_string(),
                DidKeyMaterial {
                    did: "did:webvh:mediator.example.com".into(),
                    signing_key: KeyPair {
                        key_id: "did:webvh:mediator.example.com#key-0".into(),
                        public_key_multibase: "z6Mk...".into(),
                        private_key_multibase: "z...".into(),
                    },
                    ka_key: KeyPair {
                        key_id: "did:webvh:mediator.example.com#key-1".into(),
                        public_key_multibase: "z6LS...".into(),
                        private_key_multibase: "z...".into(),
                    },
                },
            )]),
            config: TemplateBootstrapConfig {
                template_name: "didcomm-mediator".into(),
                template_kind: "mediator".into(),
                did_document: json!({ "id": "did:webvh:mediator.example.com" }),
                outputs: vec![TemplateOutput::WebvhLog {
                    did: "did:webvh:mediator.example.com".into(),
                    log: "{...}".into(),
                }],
                vta_url: Some("https://vta.example.com".into()),
                vta_trust: VtaTrustBundle {
                    vta_did: "did:webvh:vta.example.com".into(),
                    vta_did_document: json!({ "id": "did:webvh:vta.example.com" }),
                    vta_did_log: Some("{...}".into()),
                },
            },
        }
    }

    #[test]
    fn template_bootstrap_payload_json_round_trip() {
        let payload = sample_payload();
        let json = serde_json::to_string(&payload).unwrap();
        let parsed: TemplateBootstrapPayload = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.config.template_name, "didcomm-mediator");
        assert_eq!(parsed.secrets.len(), 1);
    }

    #[test]
    fn sealed_payload_variant_round_trip() {
        let payload = SealedPayloadV1::TemplateBootstrap(Box::new(sample_payload()));
        // JSON round-trip.
        let json = serde_json::to_string(&payload).unwrap();
        let parsed: SealedPayloadV1 = serde_json::from_str(&json).unwrap();
        match parsed {
            SealedPayloadV1::TemplateBootstrap(p) => {
                assert_eq!(p.config.template_kind, "mediator");
                assert_eq!(p.config.outputs.len(), 1);
            }
            other => panic!("expected TemplateBootstrap, got {other:?}"),
        }
    }

    #[test]
    fn template_output_webvh_log_tag_on_wire() {
        // The `type` tag in the enum's wire form should be snake_case
        // (`webvh_log`, `did_comm_service`) — matches existing
        // SealedPayloadV1 convention and is stable across the wire.
        let out = TemplateOutput::WebvhLog {
            did: "did:webvh:x".into(),
            log: "line".into(),
        };
        let v = serde_json::to_value(&out).unwrap();
        assert_eq!(v["type"], "webvh_log");
    }

    #[test]
    fn template_output_generic_round_trips_arbitrary_payload() {
        // Generic extensibility: a template author who needs a novel
        // first-boot artefact (e.g. a status-list URL, OOB invitation,
        // TLS CSR) embeds it via TemplateOutput::Generic without an
        // SDK change. Wire shape preserves the kind tag + free-form
        // JSON payload verbatim through serialize → deserialize.
        let original = TemplateOutput::Generic {
            kind: "status-list".into(),
            payload: json!({
                "statusListUrl": "https://issuer.example.com/status",
                "expires": "2026-12-31T00:00:00Z",
            }),
        };
        let wire = serde_json::to_value(&original).unwrap();
        assert_eq!(wire["type"], "generic");
        assert_eq!(wire["kind"], "status-list");
        assert_eq!(
            wire["payload"]["statusListUrl"],
            "https://issuer.example.com/status"
        );

        let parsed: TemplateOutput = serde_json::from_value(wire).unwrap();
        match parsed {
            TemplateOutput::Generic { kind, payload } => {
                assert_eq!(kind, "status-list");
                assert_eq!(payload["expires"], "2026-12-31T00:00:00Z");
            }
            other => panic!("expected Generic, got {other:?}"),
        }
    }

    fn sample_admin_rotation_payload() -> AdminRotationPayload {
        AdminRotationPayload {
            authorization: json!({ "type": ["VerifiableCredential", "VtaAuthorizationCredential"] }),
            admin: DidKeyMaterial {
                did: "did:key:z6MkAdmin".into(),
                signing_key: KeyPair {
                    key_id: "did:key:z6MkAdmin#z6MkAdmin".into(),
                    public_key_multibase: "z6MkAdminSigning".into(),
                    private_key_multibase: "zAdminSigningPriv".into(),
                },
                ka_key: KeyPair {
                    key_id: "did:key:z6MkAdmin#z6LSAdmin".into(),
                    public_key_multibase: "z6LSAdminKa".into(),
                    private_key_multibase: "zAdminKaPriv".into(),
                },
            },
            vta_url: Some("https://vta.example.com".into()),
            vta_trust: VtaTrustBundle {
                vta_did: "did:webvh:vta.example.com".into(),
                vta_did_document: json!({ "id": "did:webvh:vta.example.com" }),
                vta_did_log: None,
            },
        }
    }

    #[test]
    fn admin_rotation_payload_json_round_trip() {
        let payload = sample_admin_rotation_payload();
        let json = serde_json::to_string(&payload).unwrap();
        let parsed: AdminRotationPayload = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.admin.did, "did:key:z6MkAdmin");
        assert_eq!(parsed.vta_url.as_deref(), Some("https://vta.example.com"));
    }

    #[test]
    fn admin_rotation_sealed_payload_variant_round_trip() {
        let payload = SealedPayloadV1::AdminRotation(Box::new(sample_admin_rotation_payload()));
        let json = serde_json::to_string(&payload).unwrap();
        // Wire tag must be `admin_rotation` (snake_case) — matches the
        // SealedPayloadV1 convention. Regression net for an accidental
        // rename or for `serde(rename_all = "snake_case")` falling off
        // the enum.
        assert!(
            json.contains("\"admin_rotation\""),
            "wire tag must be snake_case `admin_rotation`, got: {json}"
        );
        let parsed: SealedPayloadV1 = serde_json::from_str(&json).unwrap();
        match parsed {
            SealedPayloadV1::AdminRotation(p) => {
                assert_eq!(p.admin.did, "did:key:z6MkAdmin");
            }
            other => panic!("expected AdminRotation, got {other:?}"),
        }
    }

    #[test]
    fn template_output_generic_coexists_with_typed_variants() {
        // An outputs vec can mix typed + generic entries — simulates
        // a template that emits both a standard WebvhLog and a
        // novel side output.
        let outputs = vec![
            TemplateOutput::WebvhLog {
                did: "did:webvh:mediator.example".into(),
                log: "one\ntwo\n".into(),
            },
            TemplateOutput::Generic {
                kind: "oob-invitation".into(),
                payload: json!({"url": "https://mediator.example/oob?c=abc"}),
            },
        ];
        let wire = serde_json::to_string(&outputs).unwrap();
        let parsed: Vec<TemplateOutput> = serde_json::from_str(&wire).unwrap();
        assert_eq!(parsed.len(), 2);
        assert!(matches!(parsed[0], TemplateOutput::WebvhLog { .. }));
        match &parsed[1] {
            TemplateOutput::Generic { kind, .. } => assert_eq!(kind, "oob-invitation"),
            other => panic!("expected Generic at index 1, got {other:?}"),
        }
    }
}