Skip to main content

heddle_api/
signing.rs

1//! Contract-owned request-signing bytes and header vocabulary.
2
3use sha2::{Digest, Sha256};
4
5use crate::heddle::api::v1alpha1::{EndpointDescriptor, RelayAdmissionClaims};
6use prost::Message;
7
8pub const DOMAIN: &str = "heddle-req-sig-v1";
9pub const PROVIDER_PLAN_DOMAIN: &str = "heddle-provider-plan-v1";
10pub const HEADER_ALGORITHM: &str = "x-heddle-sig-alg";
11pub const HEADER_SIGNATURE_BIN: &str = "x-heddle-sig-bin";
12pub const HEADER_TIMESTAMP: &str = "x-heddle-sig-ts";
13pub const HEADER_NONCE_BIN: &str = "x-heddle-sig-nonce-bin";
14pub const HEADER_IDENTITY: &str = "x-heddle-sig-identity";
15pub const HEADER_WEBAUTHN_CLIENT_DATA_BIN: &str = "x-heddle-sig-webauthn-client-data-bin";
16pub const HEADER_WEBAUTHN_AUTH_DATA_BIN: &str = "x-heddle-sig-webauthn-auth-data-bin";
17pub const HEADER_WEBAUTHN_USER_HANDLE_BIN: &str = "x-heddle-sig-webauthn-user-handle-bin";
18pub const HEADER_REQUIRED: &str = "x-heddle-sig-required";
19pub const HEADER_ACTION_URL: &str = "x-heddle-sig-action-url";
20
21/// Returns the canonical bytes signed for a unary request.
22pub fn unary_bytes(
23    signing_identity: &str,
24    route: &str,
25    timestamp_millis: i64,
26    nonce: &[u8],
27    deterministic_request: &[u8],
28) -> Vec<u8> {
29    canonical(
30        "unary",
31        &[
32            ("identity", signing_identity.as_bytes().to_vec()),
33            ("route", route.as_bytes().to_vec()),
34            ("timestamp_ms", timestamp_millis.to_string().into_bytes()),
35            ("nonce", hex::encode(nonce).into_bytes()),
36            (
37                "request_sha256",
38                hex::encode(Sha256::digest(deterministic_request)).into_bytes(),
39            ),
40        ],
41    )
42}
43
44/// Returns the canonical bytes signed by the opening frame of a stream.
45pub fn stream_open_bytes(
46    signing_identity: &str,
47    stream_id: &str,
48    route: &str,
49    repository: &str,
50    resume_cursor: &str,
51    capability_context: &[u8],
52) -> Vec<u8> {
53    canonical(
54        "stream-open",
55        &[
56            ("identity", signing_identity.as_bytes().to_vec()),
57            ("stream_id", stream_id.as_bytes().to_vec()),
58            ("route", route.as_bytes().to_vec()),
59            ("repository", repository.as_bytes().to_vec()),
60            ("resume_cursor", resume_cursor.as_bytes().to_vec()),
61            (
62                "capability_sha256",
63                hex::encode(Sha256::digest(capability_context)).into_bytes(),
64            ),
65        ],
66    )
67}
68
69/// Returns the canonical bytes signed to consent to one exact provider batch.
70///
71/// The server and Worker independently establish authorization from the
72/// owner-anchored capability. This signature proves possession of the same
73/// device key used for the stream opening and binds consent to one repository,
74/// endpoint, nonce, and exact private-batch digest.
75pub fn provider_plan_bytes(
76    signing_identity: &str,
77    stream_id: &str,
78    repository: &str,
79    client_endpoint_id: &str,
80    plan_nonce: &[u8],
81    grant_batch_digest: &[u8],
82) -> Vec<u8> {
83    provider_plan_canonical(
84        "exact-batch",
85        &[
86            ("identity", signing_identity.as_bytes().to_vec()),
87            ("stream_id", stream_id.as_bytes().to_vec()),
88            ("repository", repository.as_bytes().to_vec()),
89            ("client_endpoint_id", client_endpoint_id.as_bytes().to_vec()),
90            ("plan_nonce", hex::encode(plan_nonce).into_bytes()),
91            (
92                "grant_batch_digest",
93                hex::encode(grant_batch_digest).into_bytes(),
94            ),
95        ],
96    )
97}
98
99/// Hashes the retry identity without conflating it with the request payload.
100pub fn retry_key_hash(route: &str, client_operation_id: &str, request: &[u8]) -> [u8; 32] {
101    Sha256::digest(canonical(
102        "retry-key",
103        &[
104            ("route", route.as_bytes().to_vec()),
105            (
106                "client_operation_id",
107                client_operation_id.as_bytes().to_vec(),
108            ),
109            (
110                "request_sha256",
111                hex::encode(Sha256::digest(request)).into_bytes(),
112            ),
113        ],
114    ))
115    .into()
116}
117
118/// Returns the domain-separated bytes signed for an HTTPS endpoint descriptor.
119pub fn endpoint_descriptor_bytes(descriptor: &EndpointDescriptor) -> Vec<u8> {
120    bootstrap_bytes("endpoint-descriptor", descriptor)
121}
122
123/// Returns the domain-separated bytes signed for a relay admission token.
124pub fn relay_admission_bytes(claims: &RelayAdmissionClaims) -> Vec<u8> {
125    bootstrap_bytes("relay-admission", claims)
126}
127
128fn bootstrap_bytes(kind: &str, message: &impl Message) -> Vec<u8> {
129    canonical(kind, &[("protobuf", message.encode_to_vec())])
130}
131
132fn canonical(kind: &str, fields: &[(&str, Vec<u8>)]) -> Vec<u8> {
133    let mut result = format!("{DOMAIN}\nkind={}:{}", kind.len(), kind).into_bytes();
134    for (name, value) in fields {
135        result.extend_from_slice(format!("\n{name}={}:", value.len()).as_bytes());
136        result.extend_from_slice(value);
137    }
138    result
139}
140
141fn provider_plan_canonical(kind: &str, fields: &[(&str, Vec<u8>)]) -> Vec<u8> {
142    let mut result = format!("{PROVIDER_PLAN_DOMAIN}\nkind={}:{}", kind.len(), kind).into_bytes();
143    for (name, value) in fields {
144        result.extend_from_slice(format!("\n{name}={}:", value.len()).as_bytes());
145        result.extend_from_slice(value);
146    }
147    result
148}
149
150#[cfg(test)]
151mod tests {
152    use serde::Deserialize;
153
154    use super::*;
155
156    #[derive(Deserialize)]
157    struct UnaryVector {
158        identity: String,
159        route: String,
160        timestamp_millis: i64,
161        nonce_hex: String,
162        request_hex: String,
163        canonical_hex: String,
164    }
165
166    #[test]
167    fn canonical_fields_are_length_delimited() {
168        let first = unary_bytes("ab", "/c", 1, &[0], &[1]);
169        let second = unary_bytes("a", "b/c", 1, &[0], &[1]);
170        assert_ne!(first, second);
171        assert!(first.starts_with(b"heddle-req-sig-v1\nkind=5:unary"));
172    }
173
174    #[test]
175    fn provider_plan_signature_changes_with_every_authorization_binding() {
176        let endpoint = "11".repeat(32);
177        let baseline = provider_plan_bytes(
178            "principal:alice",
179            "pull:one",
180            "acme/widgets",
181            &endpoint,
182            &[7; 16],
183            &[9; 32],
184        );
185        let different_digest = provider_plan_bytes(
186            "principal:alice",
187            "pull:one",
188            "acme/widgets",
189            &endpoint,
190            &[7; 16],
191            &[8; 32],
192        );
193        let different_nonce = provider_plan_bytes(
194            "principal:alice",
195            "pull:one",
196            "acme/widgets",
197            &endpoint,
198            &[6; 16],
199            &[9; 32],
200        );
201
202        assert!(baseline.starts_with(b"heddle-provider-plan-v1\nkind=11:exact-batch"));
203        assert_ne!(baseline, different_digest);
204        assert_ne!(baseline, different_nonce);
205    }
206
207    #[test]
208    fn unary_signature_matches_cross_language_vector() {
209        let vector: UnaryVector =
210            serde_json::from_str(include_str!("../tests/fixtures/unary-signing-v1.json"))
211                .expect("valid fixture");
212        let actual = unary_bytes(
213            &vector.identity,
214            &vector.route,
215            vector.timestamp_millis,
216            &hex::decode(vector.nonce_hex).expect("nonce hex"),
217            &hex::decode(vector.request_hex).expect("request hex"),
218        );
219        assert_eq!(hex::encode(actual), vector.canonical_hex);
220    }
221}