Skip to main content

qualia_client_core/
handshake.rs

1//! Mutual challenge-response handshake proving peer identity.
2//!
3//! When Alice connects to Bob, she needs to know the peer answering is *actually*
4//! Bob and not an interposer. This module implements a minimal challenge-response:
5//! Alice sends a random [`Challenge`] naming a nonce; the responder signs a
6//! canonical message binding that nonce to its own DID with its ed25519 key and
7//! returns a [`ChallengeResponse`]. Alice verifies the signature against the
8//! embedded public key, then confirms the responder DID is the one she expected
9//! out-of-band (Bob's known DID) via [`responder_is`].
10//!
11//! Signature scheme: ed25519 (`ed25519-dalek` v2). The signed message is
12//! domain-separated with the `qhs1` tag (Qualia HandShake v1) so a signature made
13//! here cannot be replayed as some other ed25519 signature over unrelated bytes.
14//!
15//! Pure: no filesystem, no network. All I/O of keys/signatures is via hex strings
16//! so the structs serialize cleanly with serde.
17
18use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
19
20/// A connection challenge issued by the initiator (e.g. Alice).
21///
22/// `nonce` is a freshly generated, single-use value the responder must sign.
23/// `from_did` records who issued the challenge (informational; the security
24/// property rides on the nonce + responder signature).
25#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
26pub struct Challenge {
27    pub nonce: String,
28    pub from_did: String,
29}
30
31/// The responder's answer, proving control of the key bound to `responder_did`.
32///
33/// `responder_pubkey_hex` is the 32-byte ed25519 public key (hex); `signature_hex`
34/// is the 64-byte ed25519 signature (hex) over [`signed_message`]`(nonce, responder_did)`.
35#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
36pub struct ChallengeResponse {
37    pub nonce: String,
38    pub responder_did: String,
39    pub responder_pubkey_hex: String,
40    pub signature_hex: String,
41}
42
43/// Construct a challenge from the initiator's DID and a caller-supplied nonce.
44///
45/// The nonce must be fresh and single-use; nonce generation is the caller's
46/// responsibility so this function stays pure and deterministic.
47pub fn make_challenge(from_did: &str, nonce: &str) -> Challenge {
48    Challenge {
49        nonce: nonce.to_string(),
50        from_did: from_did.to_string(),
51    }
52}
53
54/// Canonical, domain-separated message that the responder signs.
55///
56/// Binding both the nonce and the responder DID means a valid signature proves
57/// "the holder of this key asserts *this DID* in response to *this nonce*" — a
58/// signature cannot be lifted to a different nonce or re-attributed to a
59/// different DID.
60pub fn signed_message(nonce: &str, responder_did: &str) -> Vec<u8> {
61    format!("qhs1|{nonce}|{responder_did}").into_bytes()
62}
63
64/// Sign a challenge, producing a [`ChallengeResponse`] the initiator can verify.
65///
66/// Signs [`signed_message`]`(challenge.nonce, responder_did)` with `key` and
67/// embeds the corresponding public key so the verifier needs nothing but the
68/// response and the expected DID.
69pub fn answer_challenge(
70    challenge: &Challenge,
71    responder_did: &str,
72    key: &SigningKey,
73) -> ChallengeResponse {
74    let msg = signed_message(&challenge.nonce, responder_did);
75    let sig: Signature = key.sign(&msg);
76    let vk = VerifyingKey::from(key);
77    ChallengeResponse {
78        nonce: challenge.nonce.clone(),
79        responder_did: responder_did.to_string(),
80        responder_pubkey_hex: hex::encode(vk.to_bytes()),
81        signature_hex: hex::encode(sig.to_bytes()),
82    }
83}
84
85/// Verify that a response answers the given challenge and carries a valid signature.
86///
87/// Checks nonce agreement, decodes the embedded public key (32 bytes) and
88/// signature (64 bytes), then verifies the signature over the canonical message.
89/// Returns `Ok(())` only when the response is cryptographically sound. A caller
90/// must still confirm *which* DID responded via [`responder_is`].
91pub fn verify_response(challenge: &Challenge, resp: &ChallengeResponse) -> Result<(), String> {
92    if resp.nonce != challenge.nonce {
93        return Err(format!(
94            "nonce mismatch: expected {:?}, got {:?}",
95            challenge.nonce, resp.nonce
96        ));
97    }
98
99    let pk_bytes =
100        hex::decode(&resp.responder_pubkey_hex).map_err(|e| format!("invalid pubkey hex: {e}"))?;
101    let pk_arr: [u8; 32] = pk_bytes
102        .as_slice()
103        .try_into()
104        .map_err(|_| format!("pubkey must be 32 bytes, got {}", pk_bytes.len()))?;
105    let vk = VerifyingKey::from_bytes(&pk_arr).map_err(|e| format!("invalid pubkey: {e}"))?;
106
107    let sig_bytes =
108        hex::decode(&resp.signature_hex).map_err(|e| format!("invalid signature hex: {e}"))?;
109    let sig_arr: [u8; 64] = sig_bytes
110        .as_slice()
111        .try_into()
112        .map_err(|_| format!("signature must be 64 bytes, got {}", sig_bytes.len()))?;
113    let sig = Signature::from_bytes(&sig_arr);
114
115    let msg = signed_message(&resp.nonce, &resp.responder_did);
116    vk.verify(&msg, &sig)
117        .map_err(|e| format!("signature verification failed: {e}"))
118}
119
120/// The "actually Bob" check: does the response come from the DID we expected?
121///
122/// [`verify_response`] proves the signature is valid for the DID the responder
123/// *claims*; this confirms that claimed DID matches the one the initiator knows
124/// out-of-band. Both together establish "when Alice connects to Bob, it's Bob".
125pub fn responder_is(resp: &ChallengeResponse, expected_did: &str) -> bool {
126    resp.responder_did == expected_did
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    fn test_key() -> SigningKey {
134        SigningKey::from_bytes(&[7u8; 32])
135    }
136
137    const BOB_DID: &str = "did:qhs:bob";
138    const ALICE_DID: &str = "did:qhs:alice";
139
140    #[test]
141    fn valid_handshake_verifies() {
142        let key = test_key();
143        let challenge = make_challenge(ALICE_DID, "nonce-abc-123");
144        let resp = answer_challenge(&challenge, BOB_DID, &key);
145        assert_eq!(verify_response(&challenge, &resp), Ok(()));
146    }
147
148    #[test]
149    fn wrong_nonce_errs() {
150        let key = test_key();
151        let challenge = make_challenge(ALICE_DID, "nonce-abc-123");
152        let mut resp = answer_challenge(&challenge, BOB_DID, &key);
153        resp.nonce = "different-nonce".to_string();
154        assert!(verify_response(&challenge, &resp).is_err());
155    }
156
157    #[test]
158    fn tampered_responder_did_errs() {
159        let key = test_key();
160        let challenge = make_challenge(ALICE_DID, "nonce-abc-123");
161        let mut resp = answer_challenge(&challenge, BOB_DID, &key);
162        // Tamper with the DID *after* signing: the signature covered the original
163        // DID, so verification over the new message must fail.
164        resp.responder_did = "did:qhs:mallory".to_string();
165        assert!(verify_response(&challenge, &resp).is_err());
166    }
167
168    #[test]
169    fn responder_is_matches_real_did_only() {
170        let key = test_key();
171        let challenge = make_challenge(ALICE_DID, "nonce-abc-123");
172        let resp = answer_challenge(&challenge, BOB_DID, &key);
173        assert!(responder_is(&resp, BOB_DID));
174        assert!(!responder_is(&resp, "did:qhs:mallory"));
175    }
176}