diff --git a/openvtc-core/src/config/keys.rs b/openvtc-core/src/config/keys.rs index d78cc85..2e774fa 100644 --- a/openvtc-core/src/config/keys.rs +++ b/openvtc-core/src/config/keys.rs @@ -92,6 +92,98 @@ impl Config { }) } + /// Build a subject-linkage proof (#1b) authorizing `presenter_did` to redeem + /// the invitation `vic_id` that is bound to `subject_did` — one of *our* + /// personas. Signs `TAG‖vic_id‖presenter` with that persona's signing key + /// (via the TDK Ed25519 routine), under its assertionMethod verification + /// method. Used when joining under a different/fresh DID than the invited one. + /// + /// Errors if `subject_did` is not one of our personas (we can't prove + /// control of a key we don't hold). + pub async fn build_subject_linkage( + &self, + subject_did: &str, + vta_client: Option<&vta_sdk::client::VtaClient>, + vic_id: &str, + presenter_did: &str, + ) -> Result { + // The subject persona's assertionMethod VM id (the VTC resolves it to + // verify the signature). + let persona = self + .account + .personas + .values() + .find(|p| p.did == subject_did) + .ok_or_else(|| { + OpenVTCError::Config(format!( + "invitation subject {subject_did} is not one of your personas" + )) + })?; + let doc = persona.did_document.as_ref().ok_or_else(|| { + OpenVTCError::Config("subject persona has no cached DID document".to_string()) + })?; + let vm = doc + .find_assertion_method(None) + .first() + .copied() + .ok_or_else(|| { + OpenVTCError::Config("subject persona has no assertionMethod".to_string()) + })? + .to_string(); + + let seed = self.persona_signing_seed(subject_did, vta_client).await?; + crate::join::sign_subject_linkage(&seed, vm, vic_id, presenter_did) + } + + /// Resolve a persona's Ed25519 signing-key seed (32 bytes) by DID, from + /// whichever backing the key uses (BIP32-derived / imported / VTA-managed). + /// Mirrors the per-key resolution in [`Self::load_persona_secrets`]. + async fn persona_signing_seed( + &self, + persona_did: &str, + vta_client: Option<&vta_sdk::client::VtaClient>, + ) -> Result<[u8; 32], OpenVTCError> { + for (key_id, ki) in &self.key_info { + if !key_id.starts_with(persona_did) || !matches!(ki.purpose, KeyTypes::PersonaSigning) { + continue; + } + let secret = match &ki.path { + KeySourceMaterial::Derived { path } => { + let KeyBackend::Bip32 { root, .. } = &self.key_backend else { + return Err(OpenVTCError::Config( + "Derived key requires a BIP32 backend".to_string(), + )); + }; + root.get_secret_from_path(path, KeyPurpose::Signing) + .map_err(|e| { + OpenVTCError::Secret(format!("derive subject signing key: {e}")) + })? + } + KeySourceMaterial::Imported { seed } => { + Secret::from_multibase(seed.expose_secret(), None) + .map_err(|e| OpenVTCError::Secret(format!("imported signing key: {e}")))? + } + KeySourceMaterial::VtaManaged { key_id: vta_key_id } => { + let client = vta_client.ok_or_else(|| { + OpenVTCError::Config( + "VTA-managed signing key requires a VTA client".to_string(), + ) + })?; + let resp = client.get_key_secret(vta_key_id).await.map_err(|e| { + OpenVTCError::Config(format!("fetch subject signing key: {e}")) + })?; + secret_from_vta_response(&resp, KeyPurpose::Signing)? + } + }; + return secret.get_private_bytes().try_into().map_err(|_| { + OpenVTCError::Secret("signing key is not a 32-byte Ed25519 seed".to_string()) + }); + } + Err(OpenVTCError::Config(format!( + "no signing key found for persona {persona_did}" + ))) + } + /// Load persona DID key secrets into the TDK resolver from this Config. /// /// Call this after creating a new config (e.g., after setup wizard) so the diff --git a/openvtc-core/src/join.rs b/openvtc-core/src/join.rs index 9ad55ab..4fe7a75 100644 --- a/openvtc-core/src/join.rs +++ b/openvtc-core/src/join.rs @@ -168,6 +168,30 @@ pub fn subject_linkage_signing_bytes(vic_id: &str, presenter_did: &str) -> Vec, + vic_id: &str, + presenter_did: &str, +) -> Result { + let bytes = subject_linkage_signing_bytes(vic_id, presenter_did); + let signature = affinidi_tdk::affinidi_crypto::jose::signing::sign(&bytes, private_seed) + .map_err(|e| OpenVTCError::Config(format!("subject-linkage signing failed: {e}")))?; + Ok(SubjectLinkage { + verification_method: verification_method.into(), + signature_hex: hex::encode(signature), + }) +} + /// The DID a VIC is bound to (`credentialSubject.id`). pub fn invitation_subject(vic: &Value) -> Option<&str> { vic.pointer("/credentialSubject/id").and_then(Value::as_str) @@ -246,6 +270,35 @@ mod tests { assert_eq!(invitation_subject(&json!({})), None); } + #[test] + fn sign_subject_linkage_verifies_with_the_tdk_routine() { + use affinidi_tdk::affinidi_crypto::jose::signing; + let seed = [7u8; 32]; + let pubkey = signing::public_key_from_private(&seed); + let linkage = sign_subject_linkage( + &seed, + "did:webvh:example.com:alice#key-0", + "urn:uuid:vic-1", + "did:key:zFreshB", + ) + .expect("sign"); + assert_eq!( + linkage.verification_method, + "did:webvh:example.com:alice#key-0" + ); + // The signature verifies over the canonical bytes — the exact check the + // VTC performs against the subject's resolved key. + let sig: [u8; 64] = hex::decode(&linkage.signature_hex) + .unwrap() + .try_into() + .unwrap(); + let bytes = subject_linkage_signing_bytes("urn:uuid:vic-1", "did:key:zFreshB"); + assert!(signing::verify(&bytes, &sig, &pubkey).is_ok()); + // A different presenter's bytes must NOT verify against this signature. + let other = subject_linkage_signing_bytes("urn:uuid:vic-1", "did:key:zOther"); + assert!(signing::verify(&other, &sig, &pubkey).is_err()); + } + #[test] fn linkage_signing_bytes_are_tag_id_nul_presenter() { let bytes = subject_linkage_signing_bytes("urn:uuid:vic-1", "did:key:zB"); diff --git a/openvtc/src/state_handler/join_flow.rs b/openvtc/src/state_handler/join_flow.rs index e3065f6..1bf09c9 100644 --- a/openvtc/src/state_handler/join_flow.rs +++ b/openvtc/src/state_handler/join_flow.rs @@ -143,26 +143,22 @@ impl StateHandler { let _ = self.state_tx.send(state.clone()); continue; } - // #1a join-as-subject: if a loaded invitation is bound - // to one of our personas, present that persona - // automatically — holder-binding then matches at the - // VTC (presenter == VIC subject) with no linkage proof. + // #1a / #1b: a loaded invitation bound to one of our + // personas. Show the identity choice with that persona + // pre-selected — pressing Enter joins as the invited + // identity (#1a, presenter == VIC subject, no linkage); + // choosing a different / fresh identity builds a + // subject-linkage proof so the invited persona + // authorizes the presented one (#1b). if let Some(pid) = invitation_subject_persona(config, state) { - if let Some(interrupted) = self - .launch_join_sequence( - JoinIdentityChoice::Reuse(pid), - vtc_did, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - ) - .await - { - return Ok(JoinExit::Exit(interrupted)); - } + let options = build_persona_options(config); + let idx = options.iter().position(|o| o.id == pid).unwrap_or(0); + state.join.pending_vtc = Some(vtc_did); + state.join.persona_options = options; + state.join.identity_selected = idx; + state.join.reuse_confirm = None; + state.join.page = JoinPage::IdentityChoice; + let _ = self.state_tx.send(state.clone()); continue; } // R-B-3 / D1: with existing personas, let the user choose @@ -426,6 +422,36 @@ async fn load_invitation_from_vault(admin_vta: &VtaClient) -> Option Option { + let vic = state.invitation_credential.as_ref()?; + let subject = openvtc_core::join::invitation_subject(vic)?; + if subject == presenter_did { + return None; // join-as-subject — no linkage needed + } + let vic_id = openvtc_core::join::invitation_id(vic)?; + match config + .build_subject_linkage(subject, Some(admin_vta), vic_id, presenter_did) + .await + { + Ok(linkage) => Some(linkage), + Err(e) => { + debug!(subject = %subject, error = %e, "subject-linkage proof unavailable"); + None + } + } +} + /// Whether a pasted/loaded JSON value is an InvitationCredential (its `type` /// array carries the `InvitationCredential` tag). fn is_invitation_credential(value: &serde_json::Value) -> bool { @@ -763,13 +789,14 @@ async fn run_join_sequence( .info("Presenting your invitation credential to the community…"); let _ = handler.state_tx.send(state.clone()); } - // Subject-linkage (#1b) is built only when the presenting DID differs from - // the VIC subject. On the join-as-subject path (#1a) — where this persona is - // the invited DID — no linkage is needed and `None` is passed. + // Subject-linkage (#1b): when the presenting DID differs from the VIC + // subject, prove the subject authorized this presenter (signed with the + // subject persona's key). On the join-as-subject path (#1a) this is `None`. + let linkage = build_linkage_proof(config, admin_vta, state, &applicant_did).await; let vp = openvtc_core::join::build_join_vp( &applicant_did, state.invitation_credential.as_ref(), - None, + linkage.as_ref(), ); let request_id = match openvtc_core::join::submit_join_request( atm,