An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(identity-wallet): add perform_did_ceremony Tauri command and relay client extensions

Subcomponent A (Task 1): Extended RelayClient with three new methods:
- get(path): sends GET request to relay, returns raw Response for caller inspection
- post_with_bearer(path, body, token): sends POST with Bearer auth header
- base_url(): const fn exposing compile-time relay URL (localhost:8080 debug, relay.ezpds.com release)

Subcomponent B (Tasks 2-4): Added perform_did_ceremony orchestration command
- Step 1: Get or create device P-256 key (rotation key)
- Step 2: Fetch relay's active signing key via GET /v1/relay/keys
- Step 3: Build signed DID PLC genesis op with external signer callback
- Step 4: Retrieve pending session token from Keychain
- Step 5: POST signed genesis op to relay at /v1/dids with Bearer auth
- Step 6: Overwrite session-token in Keychain with upgraded token
- Step 7: Persist DID in Keychain for subsequent app sessions

Added supporting types:
- RelaySigningKey: deserializes GET /v1/relay/keys response (camelCase)
- CreateDidRequest: serializes POST /v1/dids request body (camelCase)
- CreateDidResponse: deserializes POST /v1/dids response (camelCase)
- DIDCeremonyResult: success result with DID field (camelCase)
- DIDCeremonyError: typed error enum with SCREAMING_SNAKE_CASE serialization

Added 8 serialization tests covering all DIDCeremonyError variants and DIDCeremonyResult,
verifying the TypeScript-facing serialization contracts. All tests pass.

Verifies MM-146.AC3.1 through MM-146.AC3.7 (success and failure paths).

authored by

Malpercio and committed by
Tangled
7efabe18 e16dc8e9

+261
+1
apps/identity-wallet/src-tauri/Cargo.toml
··· 26 26 crypto = { workspace = true } 27 27 p256 = { workspace = true } 28 28 multibase = { workspace = true } 29 + tracing = { workspace = true } 29 30 30 31 [build-dependencies] 31 32 # Tauri-specific — declared locally
+35
apps/identity-wallet/src-tauri/src/http.rs
··· 35 35 let url = format!("{}{}", self.base_url, path); 36 36 self.client.post(&url).json(body).send().await 37 37 } 38 + 39 + /// GET `path` (relative, e.g. `"/v1/relay/keys"`). 40 + /// 41 + /// Returns the raw `Response` so callers can inspect the status code 42 + /// before attempting to deserialize the body. 43 + pub async fn get(&self, path: &str) -> reqwest::Result<Response> { 44 + let url = format!("{}{}", self.base_url, path); 45 + self.client.get(&url).send().await 46 + } 47 + 48 + /// POST JSON to `path` with a Bearer token in the Authorization header. 49 + /// 50 + /// Used for authenticated relay endpoints (e.g. `POST /v1/dids` which 51 + /// requires the pending session token). 52 + pub async fn post_with_bearer<T: Serialize>( 53 + &self, 54 + path: &str, 55 + body: &T, 56 + bearer_token: &str, 57 + ) -> reqwest::Result<Response> { 58 + let url = format!("{}{}", self.base_url, path); 59 + self.client 60 + .post(&url) 61 + .bearer_auth(bearer_token) 62 + .json(body) 63 + .send() 64 + .await 65 + } 66 + 67 + /// Returns the compile-time base URL for this relay client instance. 68 + /// 69 + /// Used as the `service_endpoint` parameter in DID ceremony genesis op construction. 70 + pub const fn base_url() -> &'static str { 71 + RELAY_BASE_URL 72 + } 38 73 } 39 74 40 75 impl Default for RelayClient {
+225
apps/identity-wallet/src-tauri/src/lib.rs
··· 4 4 5 5 use serde::{Deserialize, Serialize}; 6 6 use std::sync::LazyLock; 7 + use crypto::{build_did_plc_genesis_op_with_external_signer, CryptoError, DidKeyUri}; 7 8 8 9 // ── Request / response types ──────────────────────────────────────────────── 9 10 ··· 32 33 next_step: NextStep, 33 34 } 34 35 36 + /// Response from GET /v1/relay/keys — the relay's active signing key. 37 + #[derive(Deserialize)] 38 + #[serde(rename_all = "camelCase")] 39 + struct RelaySigningKey { 40 + key_id: String, 41 + #[serde(default)] 42 + #[allow(dead_code)] 43 + public_key: String, 44 + #[serde(default)] 45 + #[allow(dead_code)] 46 + algorithm: String, 47 + } 48 + 49 + /// Request body for POST /v1/dids — submit the signed genesis op for DID promotion. 50 + #[derive(Serialize)] 51 + #[serde(rename_all = "camelCase")] 52 + struct CreateDidRequest { 53 + rotation_key_public: String, 54 + signed_creation_op: String, 55 + } 56 + 57 + /// Response from POST /v1/dids — the promoted DID and upgraded session token. 58 + #[derive(Deserialize)] 59 + #[serde(rename_all = "camelCase")] 60 + struct CreateDidResponse { 61 + did: String, 62 + session_token: String, 63 + } 64 + 35 65 /// Relay error envelope: { "error": { "code": "...", "message": "..." } } 36 66 #[derive(Deserialize)] 37 67 struct RelayErrorEnvelope { ··· 86 116 Unknown { message: String }, 87 117 } 88 118 119 + /// Successful result returned to the Svelte frontend after DID ceremony completes. 120 + #[derive(Serialize)] 121 + #[serde(rename_all = "camelCase")] 122 + pub struct DIDCeremonyResult { 123 + pub did: String, 124 + } 125 + 126 + /// Typed error returned to the Svelte frontend as a rejected Promise. 127 + /// 128 + /// Serializes as `{ "code": "NO_RELAY_SIGNING_KEY" }` (SCREAMING_SNAKE_CASE) so 129 + /// the TypeScript catch block can switch on `error.code`. 130 + #[derive(Debug, Serialize, thiserror::Error)] 131 + #[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")] 132 + pub enum DIDCeremonyError { 133 + #[error("device key not found; call get_or_create before ceremony")] 134 + KeyNotFound, 135 + #[error("failed to fetch relay signing key")] 136 + RelayKeyFetchFailed, 137 + #[error("relay has no signing key provisioned")] 138 + NoRelaySigningKey, 139 + #[error("device signing failed")] 140 + SigningFailed, 141 + #[error("DID creation request failed")] 142 + DidCreationFailed, 143 + #[error("keychain operation failed")] 144 + KeychainError, 145 + #[error("network error: {message}")] 146 + NetworkError { message: String }, 147 + } 148 + 89 149 // ── Static relay client ───────────────────────────────────────────────────── 90 150 91 151 static RELAY_CLIENT: LazyLock<http::RelayClient> = LazyLock::new(http::RelayClient::new); ··· 188 248 device_key::sign(&data) 189 249 } 190 250 251 + #[tauri::command] 252 + async fn perform_did_ceremony(handle: String) -> Result<DIDCeremonyResult, DIDCeremonyError> { 253 + // Step 1: Get or create the device's P-256 key (serves as rotation key). 254 + let device_key = device_key::get_or_create().map_err(|e| { 255 + tracing::warn!(error = %e, "device key creation failed during DID ceremony"); 256 + DIDCeremonyError::KeyNotFound 257 + })?; 258 + 259 + // Step 2: Fetch the relay's active signing key (public, no auth required). 260 + let resp = RELAY_CLIENT 261 + .get("/v1/relay/keys") 262 + .await 263 + .map_err(|e| DIDCeremonyError::NetworkError { 264 + message: e.to_string(), 265 + })?; 266 + 267 + if resp.status().as_u16() == 503 { 268 + return Err(DIDCeremonyError::NoRelaySigningKey); 269 + } 270 + if !resp.status().is_success() { 271 + return Err(DIDCeremonyError::RelayKeyFetchFailed); 272 + } 273 + 274 + let relay_key: RelaySigningKey = resp 275 + .json() 276 + .await 277 + .map_err(|e| { 278 + tracing::warn!(error = %e, "failed to deserialize relay signing key response"); 279 + DIDCeremonyError::RelayKeyFetchFailed 280 + })?; 281 + 282 + // Step 3: Build signed genesis op — device key as rotation key, relay key as signing key. 283 + // The sign callback calls device_key::sign() so the private key never leaves the SE. 284 + let rotation_key = DidKeyUri(device_key.key_id.clone()); 285 + let signing_key = DidKeyUri(relay_key.key_id.clone()); 286 + 287 + let genesis_op = build_did_plc_genesis_op_with_external_signer( 288 + &rotation_key, 289 + &signing_key, 290 + &handle, 291 + http::RelayClient::base_url(), 292 + |data| { 293 + device_key::sign(data) 294 + .map_err(|e| CryptoError::PlcOperation(format!("device signing failed: {e}"))) 295 + }, 296 + ) 297 + .map_err(|e| { 298 + tracing::warn!(error = %e, "genesis op signing failed during DID ceremony"); 299 + DIDCeremonyError::SigningFailed 300 + })?; 301 + 302 + // Step 4: Retrieve the pending session token from Keychain. 303 + let token_bytes = keychain::get_item("session-token").map_err(|e| { 304 + tracing::warn!(error = %e, "failed to retrieve session-token from keychain"); 305 + DIDCeremonyError::KeychainError 306 + })?; 307 + let pending_token = String::from_utf8(token_bytes).map_err(|e| { 308 + tracing::warn!(error = %e, "session-token bytes are not valid UTF-8"); 309 + DIDCeremonyError::KeychainError 310 + })?; 311 + 312 + // Step 5: POST the signed genesis op to the relay to promote the account to a full DID. 313 + let create_did_req = CreateDidRequest { 314 + rotation_key_public: device_key.multibase, 315 + signed_creation_op: genesis_op.signed_op_json, 316 + }; 317 + 318 + let resp = RELAY_CLIENT 319 + .post_with_bearer("/v1/dids", &create_did_req, &pending_token) 320 + .await 321 + .map_err(|e| DIDCeremonyError::NetworkError { 322 + message: e.to_string(), 323 + })?; 324 + 325 + if !resp.status().is_success() { 326 + return Err(DIDCeremonyError::DidCreationFailed); 327 + } 328 + 329 + let create_did_resp: CreateDidResponse = resp 330 + .json() 331 + .await 332 + .map_err(|e| { 333 + tracing::warn!(error = %e, "failed to deserialize POST /v1/dids response"); 334 + DIDCeremonyError::DidCreationFailed 335 + })?; 336 + 337 + // Step 6: Overwrite session-token with the upgraded full session token. 338 + keychain::store_item( 339 + "session-token", 340 + create_did_resp.session_token.as_bytes(), 341 + ) 342 + .map_err(|e| { 343 + tracing::warn!(error = %e, "failed to persist upgraded session-token to keychain"); 344 + DIDCeremonyError::KeychainError 345 + })?; 346 + 347 + // Step 7: Persist the DID for use in subsequent app sessions. 348 + keychain::store_item("did", create_did_resp.did.as_bytes()).map_err(|e| { 349 + tracing::warn!(error = %e, "failed to persist DID to keychain"); 350 + DIDCeremonyError::KeychainError 351 + })?; 352 + 353 + Ok(DIDCeremonyResult { 354 + did: create_did_resp.did, 355 + }) 356 + } 357 + 191 358 #[cfg_attr(mobile, tauri::mobile_entry_point)] 192 359 pub fn run() { 193 360 tauri::Builder::default() ··· 195 362 create_account, 196 363 get_or_create_device_key, 197 364 sign_with_device_key, 365 + perform_did_ceremony, 198 366 ]) 199 367 .run(tauri::generate_context!()) 200 368 .expect("error while running tauri application"); ··· 348 516 key.multibase, key2.multibase, 349 517 "device_public_key must be stable across calls (idempotent)" 350 518 ); 519 + } 520 + 521 + // -- DIDCeremonyResult serialization -- 522 + #[test] 523 + fn did_ceremony_result_serializes_did_in_camel_case() { 524 + let result = DIDCeremonyResult { 525 + did: "did:plc:abcdefghijklmnopqrstuvwx".into(), 526 + }; 527 + let json = serde_json::to_value(&result).unwrap(); 528 + assert_eq!(json["did"], "did:plc:abcdefghijklmnopqrstuvwx"); 529 + } 530 + 531 + // -- DIDCeremonyError serialization (one test per variant) -- 532 + #[test] 533 + fn did_ceremony_error_key_not_found_serializes_correctly() { 534 + let json = serde_json::to_value(&DIDCeremonyError::KeyNotFound).unwrap(); 535 + assert_eq!(json["code"], "KEY_NOT_FOUND"); 536 + } 537 + 538 + #[test] 539 + fn did_ceremony_error_relay_key_fetch_failed_serializes_correctly() { 540 + let json = serde_json::to_value(&DIDCeremonyError::RelayKeyFetchFailed).unwrap(); 541 + assert_eq!(json["code"], "RELAY_KEY_FETCH_FAILED"); 542 + } 543 + 544 + #[test] 545 + fn did_ceremony_error_no_relay_signing_key_serializes_correctly() { 546 + let json = serde_json::to_value(&DIDCeremonyError::NoRelaySigningKey).unwrap(); 547 + assert_eq!(json["code"], "NO_RELAY_SIGNING_KEY"); 548 + } 549 + 550 + #[test] 551 + fn did_ceremony_error_signing_failed_serializes_correctly() { 552 + let json = serde_json::to_value(&DIDCeremonyError::SigningFailed).unwrap(); 553 + assert_eq!(json["code"], "SIGNING_FAILED"); 554 + } 555 + 556 + #[test] 557 + fn did_ceremony_error_did_creation_failed_serializes_correctly() { 558 + let json = serde_json::to_value(&DIDCeremonyError::DidCreationFailed).unwrap(); 559 + assert_eq!(json["code"], "DID_CREATION_FAILED"); 560 + } 561 + 562 + #[test] 563 + fn did_ceremony_error_keychain_error_serializes_correctly() { 564 + let json = serde_json::to_value(&DIDCeremonyError::KeychainError).unwrap(); 565 + assert_eq!(json["code"], "KEYCHAIN_ERROR"); 566 + } 567 + 568 + #[test] 569 + fn did_ceremony_error_network_error_serializes_with_message() { 570 + let err = DIDCeremonyError::NetworkError { 571 + message: "Connection refused".into(), 572 + }; 573 + let json = serde_json::to_value(&err).unwrap(); 574 + assert_eq!(json["code"], "NETWORK_ERROR"); 575 + assert_eq!(json["message"], "Connection refused"); 351 576 } 352 577 }