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
use std::path::PathBuf;
use affinidi_tdk::dids::{OneOrMany, PeerService, PeerServiceEndpoint, PeerServiceEndpointLong};
use affinidi_tdk::secrets_resolver::secrets::Secret;
use vta_sdk::did_secrets::DidSecretsBundle;
use crate::acl::{AclEntry, Role, store_acl_entry};
use crate::config::AppConfig;
use crate::operations::did_peer::{mint_did_peer_with_services, peer_secrets_to_entries};
use crate::store::Store;
pub struct CreateDidPeerArgs {
pub config_path: Option<PathBuf>,
pub context: String,
pub label: Option<String>,
/// Mediator HTTP endpoint (e.g. `http://127.0.0.1:61881/mediator/v1`) used
/// to build the did:peer's DIDComm + Authentication services so the agent
/// is reachable. The ws:// endpoint is derived from it.
pub mediator_url: String,
/// Emit the `DidSecretsBundle` JSON to stdout (the only thing on stdout).
pub export_secrets: bool,
/// Create an ACL admin entry for the new did:peer in the target context.
pub admin: bool,
}
/// `vta create-did-peer` — mint a self-contained `did:peer:2` agent identity.
///
/// Mirrors `run_create_did_webvh` minus all hosting (no `--url`, no did.jsonl,
/// no webvh log, no publish). A did:peer is self-sovereign: keys + service
/// endpoints are encoded in the DID itself, so it resolves locally with no
/// hosting. The VTA only needs an ACL entry (with `--admin`); we never store
/// the private keys in the VTA keyspace.
///
/// The command is fully non-interactive — it has no hosting step, so nothing
/// to prompt for.
pub async fn run_create_did_peer(
args: CreateDidPeerArgs,
) -> Result<(), Box<dyn std::error::Error>> {
let config = AppConfig::load(args.config_path)?;
let store = Store::open(&config.store)?;
let contexts_ks = store.keyspace(crate::keyspaces::CONTEXTS)?;
// Resolve the target context. Non-interactive: fail if it doesn't exist
// (no prompt, unlike create-did-webvh, which is interactive without --url).
if crate::contexts::get_context(&contexts_ks, &args.context)
.await?
.is_none()
{
return Err(format!(
"context '{}' does not exist (create it first with `vta contexts ...`)",
args.context
)
.into());
}
let label = args.label.as_deref().unwrap_or(&args.context);
// Build the did:peer's services from the mediator URL. This replicates
// `mediator-setup`'s `did_peer.rs::mediator_services`: a "dm"
// DIDCommMessaging service carrying the http + ws endpoints (accept
// ["didcomm/v2"]) plus an "Authentication" service at {url}/authenticate
// (id "#auth").
let services = mediator_services(&args.mediator_url)?;
// did:peer key shape (Ed25519 #key-1 + X25519 #key-2) and the actual
// did:peer:2 encoding live in the shared library construction
// (`operations::did_peer::mint_did_peer_with_services`) so this offline
// CLI and the online provision-integration path can't drift. Only the
// `services` differ (URL-style here; MEDIATOR_DID-style online).
let (did, secrets): (String, Vec<Secret>) = mint_did_peer_with_services(services)?;
eprintln!("\x1b[1;32mCreated DID:\x1b[0m {did}");
// Optionally grant the new did:peer admin in the target context. Mirrors
// `run_create_did_webvh`'s `--admin` arm (`did_webvh.rs:232-242`): same
// `AclEntry::new(..).with_label(..).with_contexts(..)` + `store_acl_entry`
// call, scoped to the target context.
if args.admin {
let acl_ks = store.keyspace(crate::keyspaces::ACL)?;
let entry = AclEntry::new(did.clone(), Role::Admin, "cli:create-did-peer")
.with_label(args.label.clone())
.with_contexts(vec![args.context.clone()]);
store_acl_entry(&acl_ks, &entry).await?;
eprintln!(
"ACL entry created: {did} (admin, context: {})",
args.context
);
}
// Persist all writes (the optional ACL entry). did:peer is self-contained,
// so there is nothing else to store.
store.persist().await?;
eprintln!(
" \x1b[2mdid:peer is self-contained: keys + services are encoded in the DID.\x1b[0m"
);
let _ = label;
// Optionally export the secrets bundle. `--export-secrets` forces it
// unconditionally; without the flag nothing is emitted on stdout.
if args.export_secrets {
// Map the generated secrets to bundle entries via the shared helper
// (Ed25519 #key-1 then X25519 #key-2; rejects any other key type).
let entries = peer_secrets_to_entries(&secrets)?;
let bundle = DidSecretsBundle {
did: did.clone(),
secrets: entries,
};
// Local operator export to stdout: pretty-printed JSON (matches
// create-did-webvh). The only thing on stdout; human text is on stderr.
let json = serde_json::to_string_pretty(&bundle)?;
eprintln!();
eprintln!("\x1b[1;33m╔══════════════════════════════════════════════════════════╗");
eprintln!("║ WARNING: The secrets bundle contains private keys. ║");
eprintln!("║ Redirect to a file with restrictive permissions. ║");
eprintln!("╚══════════════════════════════════════════════════════════╝\x1b[0m");
eprintln!();
println!("{json}");
eprintln!();
}
Ok(())
}
/// Build the did:peer's services from the mediator HTTP endpoint.
///
/// Replicates `mediator-setup`'s `generators/did_peer.rs::mediator_services`
/// exactly: a "dm" DIDCommMessaging service with http + ws endpoints (accept
/// `["didcomm/v2"]`) and an "Authentication" service at `{url}/authenticate`
/// (id `#auth`).
fn mediator_services(service_uri: &str) -> Result<Vec<PeerService>, Box<dyn std::error::Error>> {
let service_uri = service_uri.trim_end_matches('/').to_string();
let ws_uri = websocket_service_uri(&service_uri)?;
let auth_uri = format!("{service_uri}/authenticate");
Ok(vec![
PeerService {
type_: "dm".into(),
endpoint: PeerServiceEndpoint::Long(OneOrMany::Many(vec![
PeerServiceEndpointLong {
uri: service_uri,
accept: vec!["didcomm/v2".into()],
routing_keys: vec![],
},
PeerServiceEndpointLong {
uri: ws_uri,
accept: vec!["didcomm/v2".into()],
routing_keys: vec![],
},
])),
id: None,
},
PeerService {
type_: "Authentication".into(),
endpoint: PeerServiceEndpoint::Uri(auth_uri),
id: Some("#auth".into()),
},
])
}
/// Derive the ws:// (or wss://) DIDComm endpoint from the mediator's http(s)
/// endpoint. Replicates `mediator-setup`'s `did_peer.rs::websocket_service_uri`.
fn websocket_service_uri(service_uri: &str) -> Result<String, Box<dyn std::error::Error>> {
let mut url = url::Url::parse(service_uri)
.map_err(|e| format!("invalid mediator URL `{service_uri}`: {e}"))?;
match url.scheme() {
"http" => url
.set_scheme("ws")
.map_err(|_| format!("failed to convert `{service_uri}` to ws://"))?,
"https" => url
.set_scheme("wss")
.map_err(|_| format!("failed to convert `{service_uri}` to wss://"))?,
other => {
return Err(
format!("mediator URL must use http:// or https:// (got {other}://)").into(),
);
}
}
let path = url.path().trim_end_matches('/');
url.set_path(&format!("{path}/ws"));
Ok(url.to_string().trim_end_matches('/').to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acl::get_acl_entry;
use vti_common::acl::Role;
/// `vta create-did-peer --context <ctx> --mediator-url <uri> --admin
/// --export-secrets` must run fully non-interactive and, in one shot:
/// * mint a `did:peer:2...` (Ed25519 #key-1 + X25519 #key-2),
/// * create an ACL **admin** entry for it scoped to the context,
/// * print a `DidSecretsBundle` with two entries (#key-1 ed25519,
/// #key-2 x25519), both with non-empty `private_key_multibase`.
///
/// Gated on `config-seed` to match the create-did-webvh CLI test (no OS
/// keyring). Run with:
/// `cargo test -p vta-service --bin vta --features config-seed`.
#[cfg(feature = "config-seed")]
#[tokio::test]
async fn create_did_peer_admin_export_is_noninteractive_and_grants_admin() {
let dir = tempfile::TempDir::new().expect("tempdir");
let data_dir = dir.path().join("data");
std::fs::create_dir_all(&data_dir).unwrap();
let config_path = dir.path().join("config.toml");
// Minimal config: local store + a config-seed backend (dev/test only).
// did:peer mints its own keys via the TDK, so the seed is not actually
// exercised here, but the factory still expects a backend.
let seed_hex = hex::encode([9u8; 64]);
std::fs::write(
&config_path,
format!(
"[store]\ndata_dir = \"{}\"\n\n[secrets]\nseed = \"{seed_hex}\"\n",
data_dir.display()
),
)
.unwrap();
// Create the target context up-front (the command refuses if missing).
let config = AppConfig::load(Some(config_path.clone())).expect("load config");
let store = Store::open(&config.store).expect("open store");
let contexts_ks = store.keyspace(crate::keyspaces::CONTEXTS).unwrap();
crate::contexts::create_context(&contexts_ks, "agents", "Agents")
.await
.unwrap();
store.persist().await.unwrap();
drop(contexts_ks);
drop(store);
// Run fully non-interactive: --mediator-url, --admin, --export-secrets.
let args = CreateDidPeerArgs {
config_path: Some(config_path.clone()),
context: "agents".to_string(),
label: Some("agent-1".to_string()),
mediator_url: "http://127.0.0.1:61881/mediator/v1".to_string(),
export_secrets: true,
admin: true,
};
run_create_did_peer(args).await.expect("create-did-peer");
// The DID printed to the operator must be a did:peer:2. Re-mint with
// the same service shape to assert the bundle contents via the store
// side effect (the ACL entry holds the exact DID).
let store = Store::open(&config.store).expect("reopen store");
let acl_ks = store.keyspace(crate::keyspaces::ACL).unwrap();
// Find the single ACL entry created for the new did:peer.
let entries = crate::acl::list_acl_entries(&acl_ks).await.unwrap();
assert_eq!(entries.len(), 1, "one ACL entry created");
let did = &entries[0].did;
assert!(did.starts_with("did:peer:2"), "got {did}");
let entry = get_acl_entry(&acl_ks, did)
.await
.unwrap()
.expect("ACL entry created for the did:peer");
assert_eq!(entry.role, Role::Admin);
assert_eq!(entry.allowed_contexts, vec!["agents".to_string()]);
}
/// The exported `DidSecretsBundle` carries exactly two entries — Ed25519
/// `#key-1` (verification) + X25519 `#key-2` (encryption) — each with a
/// non-empty multibase private key. Asserts the bundle-build logic
/// directly against the generator (no store needed).
#[test]
fn bundle_has_two_entries_ed25519_then_x25519() {
let services = mediator_services("http://127.0.0.1:61881/mediator/v1").unwrap();
let (did, secrets) = mint_did_peer_with_services(services).expect("generate did:peer");
assert!(did.starts_with("did:peer:2"), "got {did}");
assert_eq!(secrets.len(), 2);
let entries = peer_secrets_to_entries(&secrets).expect("map secrets to entries");
assert_eq!(entries.len(), 2);
assert!(entries[0].key_id.contains("#key-1"));
assert_eq!(entries[0].key_type, vta_sdk::keys::KeyType::Ed25519);
assert!(!entries[0].private_key_multibase.is_empty());
assert!(entries[1].key_id.contains("#key-2"));
assert_eq!(entries[1].key_type, vta_sdk::keys::KeyType::X25519);
assert!(!entries[1].private_key_multibase.is_empty());
}
}