Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions openvtc-core/src/config/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<crate::join::SubjectLinkage, OpenVTCError> {
// 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
Expand Down
53 changes: 53 additions & 0 deletions openvtc-core/src/join.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,30 @@ pub fn subject_linkage_signing_bytes(vic_id: &str, presenter_did: &str) -> Vec<u
bytes
}

/// Produce a subject-linkage proof: sign [`subject_linkage_signing_bytes`] with
/// the VIC subject's Ed25519 private key (`private_seed`, 32 raw bytes — e.g.
/// `Secret::get_private_bytes`), authorizing `presenter_did` to redeem the
/// invitation `vic_id`. `verification_method` is the subject's assertionMethod
/// VM id the VTC resolves to verify the signature.
///
/// Signs via the TDK's Ed25519 routine
/// ([`affinidi_tdk::affinidi_crypto::jose::signing::sign`]) — the same
/// primitive the workspace uses elsewhere — not a hand-rolled signer.
pub fn sign_subject_linkage(
private_seed: &[u8; 32],
verification_method: impl Into<String>,
vic_id: &str,
presenter_did: &str,
) -> Result<SubjectLinkage, OpenVTCError> {
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)
Expand Down Expand Up @@ -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");
Expand Down
73 changes: 50 additions & 23 deletions openvtc/src/state_handler/join_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -426,6 +422,36 @@ async fn load_invitation_from_vault(admin_vta: &VtaClient) -> Option<serde_json:
got.get("credential").cloned()
}

/// #1b: build a subject-linkage proof when the presenting DID differs from the
/// loaded VIC's subject — the invited persona authorizes the presenter. Returns
/// `None` on the join-as-subject path (presenter == subject), when no invitation
/// is loaded, or when the subject isn't one of our personas (we can't sign for a
/// key we don't hold; the VTC then refuses the mismatched binding). Best-effort:
/// a signing failure is logged and yields `None`.
async fn build_linkage_proof(
config: &Config,
admin_vta: &VtaClient,
state: &State,
presenter_did: &str,
) -> Option<openvtc_core::join::SubjectLinkage> {
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 {
Expand Down Expand Up @@ -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,
Expand Down
Loading