Skip to main content

ldp_protocol/
signing.rs

1//! HMAC message signing and verification for LDP envelopes.
2
3use crate::types::messages::{LdpEnvelope, LdpMessageBody};
4use hmac::{Hmac, Mac};
5use sha2::Sha256;
6use subtle::ConstantTimeEq;
7
8type HmacSha256 = Hmac<Sha256>;
9
10/// Sign an envelope and return the hex-encoded HMAC-SHA256 signature.
11///
12/// Uses a canonical field order (not JSON serialization) to ensure
13/// cross-SDK compatibility between Rust and Python.
14pub fn sign_envelope(envelope: &LdpEnvelope, secret: &str) -> String {
15    let mut mac =
16        HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
17
18    // Canonical signing input: fixed field order, "|" separator
19    mac.update(envelope.from.as_bytes());
20    mac.update(b"|");
21    mac.update(envelope.to.as_bytes());
22    mac.update(b"|");
23    mac.update(envelope.session_id.as_bytes());
24    mac.update(b"|");
25    mac.update(envelope.timestamp.as_bytes());
26    mac.update(b"|");
27    mac.update(envelope.message_id.as_bytes());
28
29    // Include nonce in signing payload only when present (backward compat)
30    if let Some(ref nonce) = envelope.nonce {
31        mac.update(b"|");
32        mac.update(nonce.as_bytes());
33    }
34
35    mac.update(b"|");
36
37    // Sign body type and key identifying fields
38    let body_type = match &envelope.body {
39        LdpMessageBody::Hello { delegate_id, .. } => {
40            mac.update(delegate_id.as_bytes());
41            "HELLO"
42        }
43        LdpMessageBody::CapabilityManifest { .. } => "CAPABILITY_MANIFEST",
44        LdpMessageBody::SessionPropose { .. } => "SESSION_PROPOSE",
45        LdpMessageBody::SessionAccept { session_id, .. } => {
46            mac.update(session_id.as_bytes());
47            "SESSION_ACCEPT"
48        }
49        LdpMessageBody::SessionReject { reason, .. } => {
50            mac.update(reason.as_bytes());
51            "SESSION_REJECT"
52        }
53        LdpMessageBody::TaskSubmit { task_id, skill, .. } => {
54            mac.update(task_id.as_bytes());
55            mac.update(b"|");
56            mac.update(skill.as_bytes());
57            "TASK_SUBMIT"
58        }
59        LdpMessageBody::TaskUpdate { task_id, .. } => {
60            mac.update(task_id.as_bytes());
61            "TASK_UPDATE"
62        }
63        LdpMessageBody::TaskResult { task_id, .. } => {
64            mac.update(task_id.as_bytes());
65            "TASK_RESULT"
66        }
67        LdpMessageBody::TaskFailed { task_id, .. } => {
68            mac.update(task_id.as_bytes());
69            "TASK_FAILED"
70        }
71        LdpMessageBody::TaskCancel { task_id } => {
72            mac.update(task_id.as_bytes());
73            "TASK_CANCEL"
74        }
75        LdpMessageBody::Attestation { .. } => "ATTESTATION",
76        LdpMessageBody::SessionClose { .. } => "SESSION_CLOSE",
77    };
78    mac.update(b"|");
79    mac.update(body_type.as_bytes());
80
81    hex::encode(mac.finalize().into_bytes())
82}
83
84/// Verify an envelope's signature using constant-time comparison.
85pub fn verify_envelope(envelope: &LdpEnvelope, secret: &str, signature: &str) -> bool {
86    let expected = sign_envelope(envelope, secret);
87    let expected_bytes = expected.as_bytes();
88    let signature_bytes = signature.as_bytes();
89    if expected_bytes.len() != signature_bytes.len() {
90        return false;
91    }
92    expected_bytes.ct_eq(signature_bytes).into()
93}
94
95/// Apply a signature to an envelope (mutates in place).
96pub fn apply_signature(envelope: &mut LdpEnvelope, secret: &str) {
97    let sig = sign_envelope(envelope, secret);
98    envelope.signature = Some(sig);
99    envelope.signature_algorithm = Some("hmac-sha256".into());
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::types::payload::PayloadMode;
106
107    fn make_envelope() -> LdpEnvelope {
108        LdpEnvelope::new(
109            "session-1",
110            "from-delegate",
111            "to-delegate",
112            LdpMessageBody::Hello {
113                delegate_id: "test".into(),
114                supported_modes: vec![PayloadMode::Text],
115            },
116            PayloadMode::Text,
117        )
118    }
119
120    #[test]
121    fn sign_and_verify_roundtrip() {
122        let envelope = make_envelope();
123        let sig = sign_envelope(&envelope, "test-secret");
124        assert!(!sig.is_empty());
125        assert!(verify_envelope(&envelope, "test-secret", &sig));
126    }
127
128    #[test]
129    fn tampered_message_fails() {
130        let envelope = make_envelope();
131        let sig = sign_envelope(&envelope, "test-secret");
132        let mut tampered = envelope.clone();
133        tampered.from = "attacker".into();
134        assert!(!verify_envelope(&tampered, "test-secret", &sig));
135    }
136
137    #[test]
138    fn wrong_secret_fails() {
139        let envelope = make_envelope();
140        let sig = sign_envelope(&envelope, "secret-a");
141        assert!(!verify_envelope(&envelope, "secret-b", &sig));
142    }
143
144    #[test]
145    fn apply_signature_sets_fields() {
146        let mut envelope = make_envelope();
147        apply_signature(&mut envelope, "test-secret");
148        assert!(envelope.signature.is_some());
149        assert_eq!(envelope.signature_algorithm.as_deref(), Some("hmac-sha256"));
150    }
151
152    #[test]
153    fn task_submit_signing() {
154        let envelope = LdpEnvelope::new(
155            "s1",
156            "from",
157            "to",
158            LdpMessageBody::TaskSubmit {
159                task_id: "t1".into(),
160                skill: "echo".into(),
161                input: serde_json::json!({"data": 1}),
162                contract: None,
163            },
164            PayloadMode::Text,
165        );
166        let sig = sign_envelope(&envelope, "secret");
167        assert!(verify_envelope(&envelope, "secret", &sig));
168    }
169}