Skip to main content

heddle_api/
signing.rs

1//! Contract-owned request-signing bytes and header vocabulary.
2
3use sha2::{Digest, Sha256};
4
5pub const DOMAIN: &str = "heddle-req-sig-v1";
6pub const HEADER_ALGORITHM: &str = "x-heddle-sig-alg";
7pub const HEADER_SIGNATURE_BIN: &str = "x-heddle-sig-bin";
8pub const HEADER_TIMESTAMP: &str = "x-heddle-sig-ts";
9pub const HEADER_NONCE_BIN: &str = "x-heddle-sig-nonce-bin";
10pub const HEADER_IDENTITY: &str = "x-heddle-sig-identity";
11pub const HEADER_WEBAUTHN_CLIENT_DATA_BIN: &str = "x-heddle-sig-webauthn-client-data-bin";
12pub const HEADER_WEBAUTHN_AUTH_DATA_BIN: &str = "x-heddle-sig-webauthn-auth-data-bin";
13pub const HEADER_WEBAUTHN_USER_HANDLE_BIN: &str = "x-heddle-sig-webauthn-user-handle-bin";
14pub const HEADER_REQUIRED: &str = "x-heddle-sig-required";
15pub const HEADER_ACTION_URL: &str = "x-heddle-sig-action-url";
16
17/// Returns the canonical bytes signed for a unary request.
18pub fn unary_bytes(
19    signing_identity: &str,
20    route: &str,
21    timestamp_millis: i64,
22    nonce: &[u8],
23    deterministic_request: &[u8],
24) -> Vec<u8> {
25    canonical(
26        "unary",
27        &[
28            ("identity", signing_identity.as_bytes().to_vec()),
29            ("route", route.as_bytes().to_vec()),
30            ("timestamp_ms", timestamp_millis.to_string().into_bytes()),
31            ("nonce", hex::encode(nonce).into_bytes()),
32            (
33                "request_sha256",
34                hex::encode(Sha256::digest(deterministic_request)).into_bytes(),
35            ),
36        ],
37    )
38}
39
40/// Returns the canonical bytes signed by the opening frame of a stream.
41pub fn stream_open_bytes(
42    signing_identity: &str,
43    stream_id: &str,
44    route: &str,
45    repository: &str,
46    resume_cursor: &str,
47    capability_context: &[u8],
48) -> Vec<u8> {
49    canonical(
50        "stream-open",
51        &[
52            ("identity", signing_identity.as_bytes().to_vec()),
53            ("stream_id", stream_id.as_bytes().to_vec()),
54            ("route", route.as_bytes().to_vec()),
55            ("repository", repository.as_bytes().to_vec()),
56            ("resume_cursor", resume_cursor.as_bytes().to_vec()),
57            (
58                "capability_sha256",
59                hex::encode(Sha256::digest(capability_context)).into_bytes(),
60            ),
61        ],
62    )
63}
64
65/// Hashes the retry identity without conflating it with the request payload.
66pub fn retry_key_hash(route: &str, client_operation_id: &str, request: &[u8]) -> [u8; 32] {
67    Sha256::digest(canonical(
68        "retry-key",
69        &[
70            ("route", route.as_bytes().to_vec()),
71            (
72                "client_operation_id",
73                client_operation_id.as_bytes().to_vec(),
74            ),
75            (
76                "request_sha256",
77                hex::encode(Sha256::digest(request)).into_bytes(),
78            ),
79        ],
80    ))
81    .into()
82}
83
84fn canonical(kind: &str, fields: &[(&str, Vec<u8>)]) -> Vec<u8> {
85    let mut result = format!("{DOMAIN}\nkind={}:{}", kind.len(), kind).into_bytes();
86    for (name, value) in fields {
87        result.extend_from_slice(format!("\n{name}={}:", value.len()).as_bytes());
88        result.extend_from_slice(value);
89    }
90    result
91}
92
93#[cfg(test)]
94mod tests {
95    use serde::Deserialize;
96
97    use super::*;
98
99    #[derive(Deserialize)]
100    struct UnaryVector {
101        identity: String,
102        route: String,
103        timestamp_millis: i64,
104        nonce_hex: String,
105        request_hex: String,
106        canonical_hex: String,
107    }
108
109    #[test]
110    fn canonical_fields_are_length_delimited() {
111        let first = unary_bytes("ab", "/c", 1, &[0], &[1]);
112        let second = unary_bytes("a", "b/c", 1, &[0], &[1]);
113        assert_ne!(first, second);
114        assert!(first.starts_with(b"heddle-req-sig-v1\nkind=5:unary"));
115    }
116
117    #[test]
118    fn unary_signature_matches_cross_language_vector() {
119        let vector: UnaryVector =
120            serde_json::from_str(include_str!("../tests/fixtures/unary-signing-v1.json"))
121                .expect("valid fixture");
122        let actual = unary_bytes(
123            &vector.identity,
124            &vector.route,
125            vector.timestamp_millis,
126            &hex::decode(vector.nonce_hex).expect("nonce hex"),
127            &hex::decode(vector.request_hex).expect("request hex"),
128        );
129        assert_eq!(hex::encode(actual), vector.canonical_hex);
130    }
131}