Skip to main content

open_agent_id/
signing.rs

1//! Canonical payload construction, signing, and verification for the two V2 signing domains.
2//!
3//! # Domains
4//!
5//! - **`oaid-http/v1`** — HTTP request signing
6//! - **`oaid-msg/v1`** — Agent-to-agent message signing
7
8use std::collections::BTreeMap;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use ed25519_dalek::{Signature, SigningKey, VerifyingKey};
12
13use crate::crypto;
14use crate::error::Error;
15
16/// Protocol constant: maximum clock skew allowed for HTTP signatures (seconds).
17pub const HTTP_TIMESTAMP_WINDOW: u64 = 300;
18
19/// Protocol constant: default expiration for messages when `expires_at` is not set.
20pub const DEFAULT_EXPIRE_SECONDS: u64 = 300;
21
22// ---------------------------------------------------------------------------
23// oaid-http/v1
24// ---------------------------------------------------------------------------
25
26/// Input parameters for constructing an HTTP signature.
27#[derive(Debug, Clone)]
28pub struct HttpSignInput<'a> {
29    /// HTTP method (e.g. `"POST"`). Will be uppercased.
30    pub method: &'a str,
31    /// The full request URL. Will be canonicalized.
32    pub url: &'a str,
33    /// The raw request body bytes (empty slice for bodyless requests).
34    pub body: &'a [u8],
35    /// Unix timestamp in seconds. If `None`, the current time is used.
36    pub timestamp: Option<u64>,
37    /// 16-byte hex nonce. If `None`, a random one is generated.
38    pub nonce: Option<String>,
39}
40
41/// The result of constructing an HTTP signature, containing the headers to attach.
42#[derive(Debug, Clone)]
43pub struct HttpSignOutput {
44    /// Unix timestamp used in the signature.
45    pub timestamp: u64,
46    /// The hex nonce used in the signature.
47    pub nonce: String,
48    /// Base64url-encoded Ed25519 signature.
49    pub signature: String,
50}
51
52/// Build the canonical payload for `oaid-http/v1`.
53///
54/// Format:
55/// ```text
56/// oaid-http/v1\n{METHOD}\n{CANONICAL_URL}\n{BODY_HASH}\n{TIMESTAMP}\n{NONCE}
57/// ```
58pub fn build_http_payload(
59    method: &str,
60    url: &str,
61    body: &[u8],
62    timestamp: u64,
63    nonce: &str,
64) -> Result<String, Error> {
65    let canonical_url = canonicalize_url(url)?;
66    let body_hash = crypto::sha256_hex(body);
67
68    Ok(format!(
69        "oaid-http/v1\n{}\n{}\n{}\n{}\n{}",
70        method.to_uppercase(),
71        canonical_url,
72        body_hash,
73        timestamp,
74        nonce,
75    ))
76}
77
78/// Sign an HTTP request using `oaid-http/v1`.
79///
80/// Returns the signature output containing timestamp, nonce, and the base64url signature.
81pub fn sign_http(input: &HttpSignInput, key: &SigningKey) -> Result<HttpSignOutput, Error> {
82    let timestamp = input.timestamp.unwrap_or_else(now_unix);
83    let nonce = input
84        .nonce
85        .clone()
86        .unwrap_or_else(crypto::generate_nonce);
87
88    let payload = build_http_payload(input.method, input.url, input.body, timestamp, &nonce)?;
89    let sig = crypto::sign(payload.as_bytes(), key);
90
91    Ok(HttpSignOutput {
92        timestamp,
93        nonce,
94        signature: crypto::base64url_encode(&sig.to_bytes()),
95    })
96}
97
98/// Verify an `oaid-http/v1` signature.
99///
100/// Reconstructs the canonical payload from the provided parameters and verifies
101/// the Ed25519 signature. Does **not** check timestamp freshness (caller should
102/// enforce the +-300s window).
103pub fn verify_http(
104    method: &str,
105    url: &str,
106    body: &[u8],
107    timestamp: u64,
108    nonce: &str,
109    signature_b64: &str,
110    key: &VerifyingKey,
111) -> Result<bool, Error> {
112    let payload = build_http_payload(method, url, body, timestamp, nonce)?;
113    let sig_bytes = crypto::base64url_decode(signature_b64)?;
114    let sig_arr: [u8; 64] = sig_bytes
115        .try_into()
116        .map_err(|_| Error::Verification("signature must be 64 bytes".into()))?;
117    let sig = Signature::from_bytes(&sig_arr);
118    Ok(crypto::verify(payload.as_bytes(), &sig, key))
119}
120
121// ---------------------------------------------------------------------------
122// oaid-msg/v1
123// ---------------------------------------------------------------------------
124
125/// Input parameters for constructing a message signature.
126#[derive(Debug, Clone)]
127pub struct MsgSignInput<'a> {
128    /// Message type (e.g. `"consensus/ballot"`).
129    pub msg_type: &'a str,
130    /// Message UUID (v7 recommended).
131    pub id: &'a str,
132    /// Sender DID.
133    pub from: &'a str,
134    /// Recipient DIDs.
135    pub to: &'a [&'a str],
136    /// Optional reference ID (e.g. parent message). Use `""` for none.
137    pub reference: &'a str,
138    /// Unix timestamp in seconds. If `None`, the current time is used.
139    pub timestamp: Option<u64>,
140    /// Expiration as Unix seconds. `0` means use default (`timestamp + DEFAULT_EXPIRE_SECONDS`).
141    pub expires_at: u64,
142    /// The message body as a JSON value. Will be canonicalized (sorted keys, no whitespace).
143    pub body: &'a serde_json::Value,
144}
145
146/// The result of constructing a message signature.
147#[derive(Debug, Clone)]
148pub struct MsgSignOutput {
149    /// Unix timestamp used in the signature.
150    pub timestamp: u64,
151    /// Base64url-encoded Ed25519 signature.
152    pub signature: String,
153}
154
155/// Build the canonical payload for `oaid-msg/v1`.
156///
157/// Format:
158/// ```text
159/// oaid-msg/v1\n{TYPE}\n{ID}\n{FROM}\n{SORTED_TO}\n{REF}\n{TIMESTAMP}\n{EXPIRES_AT}\n{BODY_HASH}
160/// ```
161pub fn build_msg_payload(
162    msg_type: &str,
163    id: &str,
164    from: &str,
165    to: &[&str],
166    reference: &str,
167    timestamp: u64,
168    expires_at: u64,
169    body: &serde_json::Value,
170) -> String {
171    let sorted_to = {
172        let mut v: Vec<&str> = to.to_vec();
173        v.sort();
174        v.join(",")
175    };
176
177    let body_hash = crypto::sha256_hex(canonical_json(body).as_bytes());
178
179    format!(
180        "oaid-msg/v1\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}",
181        msg_type, id, from, sorted_to, reference, timestamp, expires_at, body_hash,
182    )
183}
184
185/// Sign an agent-to-agent message using `oaid-msg/v1`.
186pub fn sign_msg(input: &MsgSignInput, key: &SigningKey) -> MsgSignOutput {
187    let timestamp = input.timestamp.unwrap_or_else(now_unix);
188    let expires_at = if input.expires_at == 0 {
189        timestamp + DEFAULT_EXPIRE_SECONDS
190    } else {
191        input.expires_at
192    };
193
194    let payload = build_msg_payload(
195        input.msg_type,
196        input.id,
197        input.from,
198        input.to,
199        input.reference,
200        timestamp,
201        expires_at,
202        input.body,
203    );
204
205    let sig = crypto::sign(payload.as_bytes(), key);
206
207    MsgSignOutput {
208        timestamp,
209        signature: crypto::base64url_encode(&sig.to_bytes()),
210    }
211}
212
213/// Verify an `oaid-msg/v1` signature.
214///
215/// Does **not** check expiration (caller should enforce that).
216pub fn verify_msg(
217    msg_type: &str,
218    id: &str,
219    from: &str,
220    to: &[&str],
221    reference: &str,
222    timestamp: u64,
223    expires_at: u64,
224    body: &serde_json::Value,
225    signature_b64: &str,
226    key: &VerifyingKey,
227) -> Result<bool, Error> {
228    let payload = build_msg_payload(msg_type, id, from, to, reference, timestamp, expires_at, body);
229    let sig_bytes = crypto::base64url_decode(signature_b64)?;
230    let sig_arr: [u8; 64] = sig_bytes
231        .try_into()
232        .map_err(|_| Error::Verification("signature must be 64 bytes".into()))?;
233    let sig = Signature::from_bytes(&sig_arr);
234    Ok(crypto::verify(payload.as_bytes(), &sig, key))
235}
236
237// ---------------------------------------------------------------------------
238// Helpers
239// ---------------------------------------------------------------------------
240
241/// Canonicalize a URL according to the V2 spec:
242///
243/// 1. Scheme + host (lowercased) + path + sorted query params
244/// 2. No fragment
245/// 3. Empty query omits the `?`
246pub fn canonicalize_url(raw: &str) -> Result<String, Error> {
247    let parsed =
248        url::Url::parse(raw).map_err(|e| Error::InvalidUrl(format!("{e}: {raw}")))?;
249
250    let scheme = parsed.scheme();
251    let host = parsed
252        .host_str()
253        .ok_or_else(|| Error::InvalidUrl(format!("URL has no host: {raw}")))?
254        .to_ascii_lowercase();
255    let port_suffix = match parsed.port() {
256        Some(p) => format!(":{p}"),
257        None => String::new(),
258    };
259    let path = parsed.path();
260
261    // Sort query parameters by key, then by value (preserving duplicates)
262    let query_string = {
263        let mut pairs: Vec<(String, String)> = parsed
264            .query_pairs()
265            .map(|(k, v)| (k.into_owned(), v.into_owned()))
266            .collect();
267
268        pairs.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
269
270        if pairs.is_empty() {
271            String::new()
272        } else {
273            let parts: Vec<String> = pairs
274                .iter()
275                .map(|(k, v)| format!("{k}={v}"))
276                .collect();
277            format!("?{}", parts.join("&"))
278        }
279    };
280
281    Ok(format!("{scheme}://{host}{port_suffix}{path}{query_string}"))
282}
283
284/// Produce canonical JSON: sorted keys, no extra whitespace.
285///
286/// This is used for body hashing in `oaid-msg/v1`.
287pub fn canonical_json(value: &serde_json::Value) -> String {
288    match value {
289        serde_json::Value::Object(map) => {
290            let mut sorted: BTreeMap<&str, &serde_json::Value> = BTreeMap::new();
291            for (k, v) in map {
292                sorted.insert(k.as_str(), v);
293            }
294            let entries: Vec<String> = sorted
295                .iter()
296                .map(|(k, v)| format!("\"{}\":{}", k, canonical_json(v)))
297                .collect();
298            format!("{{{}}}", entries.join(","))
299        }
300        serde_json::Value::Array(arr) => {
301            let items: Vec<String> = arr.iter().map(canonical_json).collect();
302            format!("[{}]", items.join(","))
303        }
304        _ => serde_json::to_string(value).unwrap_or_default(),
305    }
306}
307
308/// Sign agent authentication headers for the registry API.
309///
310/// Uses the simple payload format: `{did}\n{timestamp}\n{nonce}`.
311/// Returns a map of HTTP headers to attach to the request.
312pub fn sign_agent_auth(
313    did: &str,
314    key: &SigningKey,
315) -> std::collections::HashMap<String, String> {
316    let timestamp = now_unix();
317    let nonce = crypto::generate_nonce();
318
319    let payload = format!("{}\n{}\n{}", did, timestamp, nonce);
320    let sig = crypto::sign(payload.as_bytes(), key);
321
322    let mut headers = std::collections::HashMap::new();
323    headers.insert("X-Agent-DID".to_string(), did.to_string());
324    headers.insert("X-Agent-Timestamp".to_string(), timestamp.to_string());
325    headers.insert("X-Agent-Nonce".to_string(), nonce);
326    headers.insert(
327        "X-Agent-Signature".to_string(),
328        crypto::base64url_encode(&sig.to_bytes()),
329    );
330    headers
331}
332
333/// Verify agent authentication headers from the registry API.
334///
335/// Reconstructs the simple payload `{did}\n{timestamp}\n{nonce}` and verifies
336/// the Ed25519 signature. Does **not** check timestamp freshness (caller should
337/// enforce the +-300s window).
338pub fn verify_agent_auth(
339    did: &str,
340    timestamp: u64,
341    nonce: &str,
342    signature_b64: &str,
343    key: &VerifyingKey,
344) -> Result<bool, Error> {
345    let payload = format!("{}\n{}\n{}", did, timestamp, nonce);
346    let sig_bytes = crypto::base64url_decode(signature_b64)?;
347    let sig_arr: [u8; 64] = sig_bytes
348        .try_into()
349        .map_err(|_| Error::Verification("signature must be 64 bytes".into()))?;
350    let sig = Signature::from_bytes(&sig_arr);
351    Ok(crypto::verify(payload.as_bytes(), &sig, key))
352}
353
354/// Get the current Unix timestamp in seconds.
355fn now_unix() -> u64 {
356    SystemTime::now()
357        .duration_since(UNIX_EPOCH)
358        .expect("system clock before Unix epoch")
359        .as_secs()
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    fn canonical_url_basic() {
368        let result = canonicalize_url("https://API.Example.com/v1/agents").unwrap();
369        assert_eq!(result, "https://api.example.com/v1/agents");
370    }
371
372    #[test]
373    fn canonical_url_sorted_query() {
374        let result =
375            canonicalize_url("https://api.example.com/v1/agents?offset=0&limit=10").unwrap();
376        assert_eq!(
377            result,
378            "https://api.example.com/v1/agents?limit=10&offset=0"
379        );
380    }
381
382    #[test]
383    fn canonical_url_no_query() {
384        let result = canonicalize_url("https://api.example.com/path").unwrap();
385        assert!(!result.contains('?'));
386    }
387
388    #[test]
389    fn canonical_url_strips_fragment() {
390        let result =
391            canonicalize_url("https://api.example.com/path?a=1#section").unwrap();
392        assert!(!result.contains('#'));
393        assert_eq!(result, "https://api.example.com/path?a=1");
394    }
395
396    #[test]
397    fn canonical_url_with_port() {
398        let result = canonicalize_url("https://localhost:8080/api").unwrap();
399        assert_eq!(result, "https://localhost:8080/api");
400    }
401
402    #[test]
403    fn canonical_url_duplicate_query_params() {
404        let result =
405            canonicalize_url("https://api.example.com/v1/agents?tag=b&tag=a&limit=10").unwrap();
406        assert_eq!(
407            result,
408            "https://api.example.com/v1/agents?limit=10&tag=a&tag=b"
409        );
410    }
411
412    #[test]
413    fn canonical_json_sorted_keys() {
414        let val: serde_json::Value =
415            serde_json::from_str(r#"{"z":1,"a":"hello","m":[3,2,1]}"#).unwrap();
416        let result = canonical_json(&val);
417        assert_eq!(result, r#"{"a":"hello","m":[3,2,1],"z":1}"#);
418    }
419
420    #[test]
421    fn canonical_json_nested() {
422        let val: serde_json::Value =
423            serde_json::from_str(r#"{"b":{"d":4,"c":3},"a":1}"#).unwrap();
424        let result = canonical_json(&val);
425        assert_eq!(result, r#"{"a":1,"b":{"c":3,"d":4}}"#);
426    }
427
428    #[test]
429    fn canonical_json_empty_object() {
430        let val: serde_json::Value = serde_json::from_str(r#"{}"#).unwrap();
431        assert_eq!(canonical_json(&val), "{}");
432    }
433
434    #[test]
435    fn http_sign_verify_roundtrip() {
436        let (sk, vk) = crypto::generate_keypair();
437
438        let input = HttpSignInput {
439            method: "POST",
440            url: "https://api.example.com/v1/agents",
441            body: b"{\"name\":\"bot\"}",
442            timestamp: Some(1708123456),
443            nonce: Some("a1b2c3d4e5f6a7b8a1b2c3d4e5f6a7b8".to_string()),
444        };
445
446        let output = sign_http(&input, &sk).unwrap();
447
448        let valid = verify_http(
449            input.method,
450            input.url,
451            input.body,
452            output.timestamp,
453            &output.nonce,
454            &output.signature,
455            &vk,
456        )
457        .unwrap();
458        assert!(valid);
459    }
460
461    #[test]
462    fn http_payload_format() {
463        let payload = build_http_payload(
464            "post",
465            "https://API.Example.com/v1/agents?offset=0&limit=10",
466            b"",
467            1708123456,
468            "deadbeef00000000deadbeef00000000",
469        )
470        .unwrap();
471
472        let lines: Vec<&str> = payload.split('\n').collect();
473        assert_eq!(lines[0], "oaid-http/v1");
474        assert_eq!(lines[1], "POST");
475        assert_eq!(
476            lines[2],
477            "https://api.example.com/v1/agents?limit=10&offset=0"
478        );
479        // SHA-256 of empty body
480        assert_eq!(
481            lines[3],
482            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
483        );
484        assert_eq!(lines[4], "1708123456");
485        assert_eq!(lines[5], "deadbeef00000000deadbeef00000000");
486    }
487
488    #[test]
489    fn msg_sign_verify_roundtrip() {
490        let (sk, vk) = crypto::generate_keypair();
491
492        let body = serde_json::json!({"proposal": "do-something", "quorum": 3});
493        let input = MsgSignInput {
494            msg_type: "consensus/ballot",
495            id: "019504a0-0000-7000-8000-000000000001",
496            from: "did:oaid:base:0x0000000000000000000000000000000000000001",
497            to: &[
498                "did:oaid:base:0x0000000000000000000000000000000000000003",
499                "did:oaid:base:0x0000000000000000000000000000000000000002",
500            ],
501            reference: "",
502            timestamp: Some(1708123456),
503            expires_at: 0,
504            body: &body,
505        };
506
507        let output = sign_msg(&input, &sk);
508
509        // expires_at=0 should resolve to timestamp + DEFAULT_EXPIRE_SECONDS
510        let expected_expires = output.timestamp + DEFAULT_EXPIRE_SECONDS;
511        let valid = verify_msg(
512            input.msg_type,
513            input.id,
514            input.from,
515            input.to,
516            input.reference,
517            output.timestamp,
518            expected_expires,
519            input.body,
520            &output.signature,
521            &vk,
522        )
523        .unwrap();
524        assert!(valid);
525    }
526
527    #[test]
528    fn msg_payload_sorted_to() {
529        let body = serde_json::json!({});
530        let payload = build_msg_payload(
531            "test",
532            "id1",
533            "from",
534            &["did:oaid:base:0xbbbb", "did:oaid:base:0xaaaa"],
535            "",
536            100,
537            0,
538            &body,
539        );
540        let lines: Vec<&str> = payload.split('\n').collect();
541        // to DIDs should be sorted
542        assert_eq!(lines[4], "did:oaid:base:0xaaaa,did:oaid:base:0xbbbb");
543    }
544
545    #[test]
546    fn msg_payload_empty_to() {
547        let body = serde_json::json!({});
548        let payload = build_msg_payload("test", "id1", "from", &[], "", 100, 0, &body);
549        let lines: Vec<&str> = payload.split('\n').collect();
550        assert_eq!(lines[4], ""); // empty sorted_to
551    }
552
553    #[test]
554    fn agent_auth_sign_verify_roundtrip() {
555        let (sk, vk) = crypto::generate_keypair();
556        let did = "did:oaid:base:0x0000000000000000000000000000000000000001";
557
558        let headers = sign_agent_auth(did, &sk);
559        assert_eq!(headers.get("X-Agent-DID").unwrap(), did);
560
561        let ts: u64 = headers.get("X-Agent-Timestamp").unwrap().parse().unwrap();
562        let nonce = headers.get("X-Agent-Nonce").unwrap();
563        let sig = headers.get("X-Agent-Signature").unwrap();
564
565        let valid = verify_agent_auth(did, ts, nonce, sig, &vk).unwrap();
566        assert!(valid);
567    }
568
569    #[test]
570    fn http_verify_wrong_body_fails() {
571        let (sk, vk) = crypto::generate_keypair();
572
573        let input = HttpSignInput {
574            method: "POST",
575            url: "https://api.example.com/test",
576            body: b"original",
577            timestamp: Some(1000),
578            nonce: Some("0".repeat(32)),
579        };
580
581        let output = sign_http(&input, &sk).unwrap();
582
583        // Verify with different body should fail
584        let valid = verify_http(
585            "POST",
586            "https://api.example.com/test",
587            b"tampered",
588            output.timestamp,
589            &output.nonce,
590            &output.signature,
591            &vk,
592        )
593        .unwrap();
594        assert!(!valid);
595    }
596}