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
8/// Domain for the WebAuthn assertion that binds both client-minted session
9/// signing roles. Includes its terminal NUL byte.
10pub const IDENTITY_BINDING_CHALLENGE_V2_DOMAIN: &[u8] = b"heddle-device-binding-v2\0";
11/// Domain owned authoritatively by Weft's strict `pop_delegation` verifier.
12/// Mirrored here so Rust and TypeScript producers cannot drift.
13pub const POP_DELEGATION_V1_DOMAIN: &[u8] = b"heddle-pop-delegation-v1\0";
14/// Deployed domain for Tier-1 request signatures. This legacy v1 value
15/// predates the terminal-NUL convention and MUST remain byte-for-byte stable.
16pub const TIER_1_REQUEST_SIGNING_V1_DOMAIN: &str = "heddle-req-sig-v1";
17/// Deployed domain used by endpoint-descriptor and relay-admission bootstrap
18/// signatures. It currently has the same bytes as request signing, but remains
19/// a separate constant so the two protocol purposes cannot drift implicitly.
20pub const TRANSPORT_BOOTSTRAP_SIGNING_V1_DOMAIN: &str = "heddle-req-sig-v1";
21/// Domain prepended to every server-signed GrantEnvelope v2 canonical payload.
22pub const GRANT_ENVELOPE_V2_DOMAIN: &[u8] = b"heddle-grant-envelope-v2\0";
23/// Domain separator for the one-key passkey↔device-key binding challenge
24/// introduced by weft#2047 (possession-first identity, two-key model retired).
25/// Promoted here from weft-local `weft-authz::webauthn` so weft, heddle, and
26/// tapestry share ONE definition. Unlike the `-v2` NUL-terminated domains, this
27/// value is the bare string with NO terminal NUL: the challenge framing inserts
28/// an explicit `0x00` separator between the domain and the key. This matches
29/// weft's `ONE_KEY_DEVICE_BINDING_CHALLENGE_DOMAIN` byte-for-byte.
30pub const ONE_KEY_DEVICE_BINDING_CHALLENGE_DOMAIN: &str = "heddle-one-key-device-binding-v1";
31/// Domain separator for the recovery new-device-key proof-of-possession
32/// (weft#2047 leg 2). Distinct from the credential-rotation (`heddle-credential-
33/// rotation-v1`) and SA-issuance (`heddle-sa-credential-issue-v1`) PoP domains
34/// so a signature captured against one RPC cannot be replayed against another.
35/// Bare string with explicit `0x00` field separators, matching that PoP family.
36pub const RECOVERY_NEW_DEVICE_POP_V1_DOMAIN: &str = "heddle-recovery-new-device-pop-v1";
37
38/// Exact signed/wire role label for the ephemeral Biscuit authority key.
39pub const BISCUIT_AUTHORITY_PUBLIC_KEY_ROLE: &[u8] = b"biscuit_authority_public_key\0";
40/// Exact signed/wire role label for the non-extractable device proof key.
41pub const DEVICE_PROOF_PUBLIC_KEY_ROLE: &[u8] = b"device_proof_public_key\0";
42/// Signed format discriminator immediately following the GrantEnvelope domain.
43pub const GRANT_ENVELOPE_V2_FORMAT_VERSION: u8 = 2;
44pub const PROVIDER_PLAN_DOMAIN: &str = "heddle-provider-plan-v1";
45pub const HEADER_ALGORITHM: &str = "x-heddle-sig-alg";
46pub const HEADER_SIGNATURE_BIN: &str = "x-heddle-sig-bin";
47pub const HEADER_TIMESTAMP: &str = "x-heddle-sig-ts";
48pub const HEADER_NONCE_BIN: &str = "x-heddle-sig-nonce-bin";
49pub const HEADER_IDENTITY: &str = "x-heddle-sig-identity";
50pub const HEADER_WEBAUTHN_CLIENT_DATA_BIN: &str = "x-heddle-sig-webauthn-client-data-bin";
51pub const HEADER_WEBAUTHN_AUTH_DATA_BIN: &str = "x-heddle-sig-webauthn-auth-data-bin";
52pub const HEADER_WEBAUTHN_USER_HANDLE_BIN: &str = "x-heddle-sig-webauthn-user-handle-bin";
53pub const HEADER_REQUIRED: &str = "x-heddle-sig-required";
54pub const HEADER_ACTION_URL: &str = "x-heddle-sig-action-url";
55
56const MAX_GRANT_ENVELOPE_SUBJECT_BYTES: usize = 256;
57const MAX_GRANT_ENVELOPE_RIGHTS: usize = 64;
58const MAX_GRANT_ENVELOPE_RIGHT_FIELD_BYTES: usize = 1024;
59
60/// One ordered right in a GrantEnvelope v2 canonical payload.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct GrantEnvelopeV2Right {
63    pub kind: String,
64    pub path: String,
65    pub action: String,
66}
67
68/// The fields covered by a GrantEnvelope v2 server signature.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct GrantEnvelopeV2Payload {
71    pub biscuit_authority_public_key: [u8; 32],
72    pub device_proof_public_key: [u8; 32],
73    pub subject: String,
74    pub rights: Vec<GrantEnvelopeV2Right>,
75    pub issued_at: i64,
76    pub expires_at: i64,
77}
78
79/// A fail-closed GrantEnvelope v2 canonical-payload codec error.
80#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
81pub enum GrantEnvelopeV2CodecError {
82    #[error("biscuit authority and device proof keys must be distinct")]
83    EqualKeys,
84    #[error("{field} exceeds {max} bytes")]
85    FieldTooLong { field: &'static str, max: usize },
86    #[error("rights_count exceeds {max}")]
87    TooManyRights { max: usize },
88    #[error("invalid GrantEnvelope v2 domain")]
89    InvalidDomain,
90    #[error("unsupported GrantEnvelope format version {0:#04x}")]
91    InvalidFormatVersion(u8),
92    #[error("invalid {0} role label")]
93    InvalidRoleLabel(&'static str),
94    #[error("truncated GrantEnvelope v2 while reading {0}")]
95    Truncated(&'static str),
96    #[error("{0} is not valid UTF-8")]
97    InvalidUtf8(&'static str),
98    #[error("GrantEnvelope v2 has {0} trailing bytes")]
99    TrailingBytes(usize),
100}
101
102/// Encodes the exact bytes that a GrantEnvelope v2 issuer signs.
103///
104/// The encoder enforces all contract bounds and rejects equal role keys before
105/// an envelope can be issued.
106pub fn grant_envelope_v2_canonical_payload(
107    payload: &GrantEnvelopeV2Payload,
108) -> Result<Vec<u8>, GrantEnvelopeV2CodecError> {
109    if payload.biscuit_authority_public_key == payload.device_proof_public_key {
110        return Err(GrantEnvelopeV2CodecError::EqualKeys);
111    }
112    checked_u16_len(
113        "subject",
114        payload.subject.len(),
115        MAX_GRANT_ENVELOPE_SUBJECT_BYTES,
116    )?;
117    if payload.rights.len() > MAX_GRANT_ENVELOPE_RIGHTS {
118        return Err(GrantEnvelopeV2CodecError::TooManyRights {
119            max: MAX_GRANT_ENVELOPE_RIGHTS,
120        });
121    }
122
123    let mut encoded = Vec::new();
124    encoded.extend_from_slice(GRANT_ENVELOPE_V2_DOMAIN);
125    encoded.push(GRANT_ENVELOPE_V2_FORMAT_VERSION);
126    encoded.extend_from_slice(BISCUIT_AUTHORITY_PUBLIC_KEY_ROLE);
127    encoded.extend_from_slice(&payload.biscuit_authority_public_key);
128    encoded.extend_from_slice(DEVICE_PROOF_PUBLIC_KEY_ROLE);
129    encoded.extend_from_slice(&payload.device_proof_public_key);
130    push_counted_string(
131        &mut encoded,
132        "subject",
133        &payload.subject,
134        MAX_GRANT_ENVELOPE_SUBJECT_BYTES,
135    )?;
136    encoded.extend_from_slice(&(payload.rights.len() as u16).to_be_bytes());
137    for right in &payload.rights {
138        push_counted_string(
139            &mut encoded,
140            "right.kind",
141            &right.kind,
142            MAX_GRANT_ENVELOPE_RIGHT_FIELD_BYTES,
143        )?;
144        push_counted_string(
145            &mut encoded,
146            "right.path",
147            &right.path,
148            MAX_GRANT_ENVELOPE_RIGHT_FIELD_BYTES,
149        )?;
150        push_counted_string(
151            &mut encoded,
152            "right.action",
153            &right.action,
154            MAX_GRANT_ENVELOPE_RIGHT_FIELD_BYTES,
155        )?;
156    }
157    encoded.extend_from_slice(&payload.issued_at.to_be_bytes());
158    encoded.extend_from_slice(&payload.expires_at.to_be_bytes());
159    Ok(encoded)
160}
161
162/// Parses one complete GrantEnvelope v2 canonical payload without fallback.
163pub fn parse_grant_envelope_v2_canonical_payload(
164    encoded: &[u8],
165) -> Result<GrantEnvelopeV2Payload, GrantEnvelopeV2CodecError> {
166    let mut reader = GrantEnvelopeReader::new(encoded);
167    if reader.take(GRANT_ENVELOPE_V2_DOMAIN.len(), "domain")? != GRANT_ENVELOPE_V2_DOMAIN {
168        return Err(GrantEnvelopeV2CodecError::InvalidDomain);
169    }
170    let version = reader.take(1, "format_version")?[0];
171    if version != GRANT_ENVELOPE_V2_FORMAT_VERSION {
172        return Err(GrantEnvelopeV2CodecError::InvalidFormatVersion(version));
173    }
174    if reader.take(
175        BISCUIT_AUTHORITY_PUBLIC_KEY_ROLE.len(),
176        "authority role label",
177    )? != BISCUIT_AUTHORITY_PUBLIC_KEY_ROLE
178    {
179        return Err(GrantEnvelopeV2CodecError::InvalidRoleLabel(
180            "biscuit authority",
181        ));
182    }
183    let biscuit_authority_public_key = reader.take_array("biscuit authority public key")?;
184    if reader.take(
185        DEVICE_PROOF_PUBLIC_KEY_ROLE.len(),
186        "device proof role label",
187    )? != DEVICE_PROOF_PUBLIC_KEY_ROLE
188    {
189        return Err(GrantEnvelopeV2CodecError::InvalidRoleLabel("device proof"));
190    }
191    let device_proof_public_key = reader.take_array("device proof public key")?;
192    if biscuit_authority_public_key == device_proof_public_key {
193        return Err(GrantEnvelopeV2CodecError::EqualKeys);
194    }
195    let subject = reader.take_counted_string("subject", MAX_GRANT_ENVELOPE_SUBJECT_BYTES)?;
196    let rights_count = reader.take_u16("rights_count")? as usize;
197    if rights_count > MAX_GRANT_ENVELOPE_RIGHTS {
198        return Err(GrantEnvelopeV2CodecError::TooManyRights {
199            max: MAX_GRANT_ENVELOPE_RIGHTS,
200        });
201    }
202    let mut rights = Vec::with_capacity(rights_count);
203    for _ in 0..rights_count {
204        rights.push(GrantEnvelopeV2Right {
205            kind: reader.take_counted_string("right.kind", MAX_GRANT_ENVELOPE_RIGHT_FIELD_BYTES)?,
206            path: reader.take_counted_string("right.path", MAX_GRANT_ENVELOPE_RIGHT_FIELD_BYTES)?,
207            action: reader
208                .take_counted_string("right.action", MAX_GRANT_ENVELOPE_RIGHT_FIELD_BYTES)?,
209        });
210    }
211    let issued_at = reader.take_i64("issued_at")?;
212    let expires_at = reader.take_i64("expires_at")?;
213    if reader.remaining() != 0 {
214        return Err(GrantEnvelopeV2CodecError::TrailingBytes(reader.remaining()));
215    }
216    Ok(GrantEnvelopeV2Payload {
217        biscuit_authority_public_key,
218        device_proof_public_key,
219        subject,
220        rights,
221        issued_at,
222        expires_at,
223    })
224}
225
226fn checked_u16_len(
227    field: &'static str,
228    length: usize,
229    maximum: usize,
230) -> Result<u16, GrantEnvelopeV2CodecError> {
231    if length > maximum {
232        return Err(GrantEnvelopeV2CodecError::FieldTooLong {
233            field,
234            max: maximum,
235        });
236    }
237    Ok(length as u16)
238}
239
240fn push_counted_string(
241    encoded: &mut Vec<u8>,
242    field: &'static str,
243    value: &str,
244    maximum: usize,
245) -> Result<(), GrantEnvelopeV2CodecError> {
246    let length = checked_u16_len(field, value.len(), maximum)?;
247    encoded.extend_from_slice(&length.to_be_bytes());
248    encoded.extend_from_slice(value.as_bytes());
249    Ok(())
250}
251
252struct GrantEnvelopeReader<'a> {
253    encoded: &'a [u8],
254    offset: usize,
255}
256
257impl<'a> GrantEnvelopeReader<'a> {
258    fn new(encoded: &'a [u8]) -> Self {
259        Self { encoded, offset: 0 }
260    }
261
262    fn take(
263        &mut self,
264        length: usize,
265        field: &'static str,
266    ) -> Result<&'a [u8], GrantEnvelopeV2CodecError> {
267        let end = self
268            .offset
269            .checked_add(length)
270            .filter(|end| *end <= self.encoded.len())
271            .ok_or(GrantEnvelopeV2CodecError::Truncated(field))?;
272        let value = &self.encoded[self.offset..end];
273        self.offset = end;
274        Ok(value)
275    }
276
277    fn take_array<const N: usize>(
278        &mut self,
279        field: &'static str,
280    ) -> Result<[u8; N], GrantEnvelopeV2CodecError> {
281        Ok(self.take(N, field)?.try_into().expect("length checked"))
282    }
283
284    fn take_u16(&mut self, field: &'static str) -> Result<u16, GrantEnvelopeV2CodecError> {
285        Ok(u16::from_be_bytes(self.take_array(field)?))
286    }
287
288    fn take_i64(&mut self, field: &'static str) -> Result<i64, GrantEnvelopeV2CodecError> {
289        Ok(i64::from_be_bytes(self.take_array(field)?))
290    }
291
292    fn take_counted_string(
293        &mut self,
294        field: &'static str,
295        maximum: usize,
296    ) -> Result<String, GrantEnvelopeV2CodecError> {
297        let length = self.take_u16(field)? as usize;
298        if length > maximum {
299            return Err(GrantEnvelopeV2CodecError::FieldTooLong {
300                field,
301                max: maximum,
302            });
303        }
304        let value = self.take(length, field)?;
305        String::from_utf8(value.to_vec()).map_err(|_| GrantEnvelopeV2CodecError::InvalidUtf8(field))
306    }
307
308    fn remaining(&self) -> usize {
309        self.encoded.len() - self.offset
310    }
311}
312
313/// Returns the exact WebAuthn challenge bytes that bind both client-minted
314/// session roles. The array types pin both keys to raw 32-byte Ed25519 public
315/// keys; callers base64url-encode the returned bytes without padding for
316/// `clientDataJSON.challenge`.
317pub fn identity_binding_challenge_v2_bytes(
318    biscuit_authority_public_key: &[u8; 32],
319    device_proof_public_key: &[u8; 32],
320) -> Vec<u8> {
321    [
322        IDENTITY_BINDING_CHALLENGE_V2_DOMAIN,
323        BISCUIT_AUTHORITY_PUBLIC_KEY_ROLE,
324        biscuit_authority_public_key,
325        DEVICE_PROOF_PUBLIC_KEY_ROLE,
326        device_proof_public_key,
327    ]
328    .concat()
329}
330
331/// Computes the one-key passkey binding challenge string (weft#2047).
332///
333/// The exact bytes are
334/// `base64url_nopad(SHA256(ONE_KEY_DEVICE_BINDING_CHALLENGE_DOMAIN || 0x00 || device_proof_public_key))`.
335/// The client passes the returned string as the WebAuthn assertion
336/// `clientDataJSON.challenge` when binding the human's passkey to the sole
337/// persistent device key. Mirrors weft's `derive_one_key_device_binding_challenge`
338/// byte-for-byte so server and every client agree on the challenge.
339pub fn one_key_device_binding_challenge(device_proof_public_key: &[u8]) -> String {
340    let mut hasher = Sha256::new();
341    hasher.update(ONE_KEY_DEVICE_BINDING_CHALLENGE_DOMAIN.as_bytes());
342    hasher.update([0u8]);
343    hasher.update(device_proof_public_key);
344    base64url_nopad(&hasher.finalize())
345}
346
347/// Returns the 32-byte digest the NEW device key signs to prove possession
348/// during recovery completion (weft#2047 leg 2).
349///
350/// The digest is
351/// `SHA256(RECOVERY_NEW_DEVICE_POP_V1_DOMAIN || 0x00 || recovery_attempt_id (UTF-8) || 0x00 || new_device_public_key)`.
352/// The client signs these 32 bytes with the new device Ed25519 private key and
353/// sends the raw 64-byte signature in
354/// `SubmitRecoveryProofRequest.new_device_proof_signature`. Binding the
355/// `recovery_attempt_id` makes the proof single-use for one attempt; binding
356/// `new_device_public_key` prevents an attacker from planting a key they do not
357/// hold. The server recomputes this digest and verifies the signature against
358/// the `new_device_public_key` carried in the accepted proof variant.
359pub fn recovery_new_device_pop_digest(
360    recovery_attempt_id: &str,
361    new_device_public_key: &[u8],
362) -> [u8; 32] {
363    let mut hasher = Sha256::new();
364    hasher.update(RECOVERY_NEW_DEVICE_POP_V1_DOMAIN.as_bytes());
365    hasher.update([0u8]);
366    hasher.update(recovery_attempt_id.as_bytes());
367    hasher.update([0u8]);
368    hasher.update(new_device_public_key);
369    hasher.finalize().into()
370}
371
372/// Encodes bytes as unpadded base64url (RFC 4648 §5, URL/filename-safe alphabet,
373/// no `=` padding). Deliberately dependency-free and matched to the
374/// `URL_SAFE_NO_PAD` engine weft uses, so the one-key challenge string is
375/// byte-for-byte identical across implementations.
376fn base64url_nopad(input: &[u8]) -> String {
377    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
378    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
379    for chunk in input.chunks(3) {
380        let b0 = u32::from(chunk[0]);
381        let b1 = u32::from(chunk.get(1).copied().unwrap_or(0));
382        let b2 = u32::from(chunk.get(2).copied().unwrap_or(0));
383        let packed = (b0 << 16) | (b1 << 8) | b2;
384        out.push(ALPHABET[((packed >> 18) & 0x3f) as usize] as char);
385        out.push(ALPHABET[((packed >> 12) & 0x3f) as usize] as char);
386        if chunk.len() > 1 {
387            out.push(ALPHABET[((packed >> 6) & 0x3f) as usize] as char);
388        }
389        if chunk.len() > 2 {
390            out.push(ALPHABET[(packed & 0x3f) as usize] as char);
391        }
392    }
393    out
394}
395
396/// Returns the canonical bytes signed for a unary request.
397pub fn unary_bytes(
398    signing_identity: &str,
399    route: &str,
400    timestamp_millis: i64,
401    nonce: &[u8],
402    deterministic_request: &[u8],
403) -> Vec<u8> {
404    canonical(
405        "unary",
406        &[
407            ("identity", signing_identity.as_bytes().to_vec()),
408            ("route", route.as_bytes().to_vec()),
409            ("timestamp_ms", timestamp_millis.to_string().into_bytes()),
410            ("nonce", hex::encode(nonce).into_bytes()),
411            (
412                "request_sha256",
413                hex::encode(Sha256::digest(deterministic_request)).into_bytes(),
414            ),
415        ],
416    )
417}
418
419/// Returns the canonical bytes signed by the opening frame of a stream.
420pub fn stream_open_bytes(
421    signing_identity: &str,
422    stream_id: &str,
423    route: &str,
424    repository: &str,
425    resume_cursor: &str,
426    capability_context: &[u8],
427) -> Vec<u8> {
428    canonical(
429        "stream-open",
430        &[
431            ("identity", signing_identity.as_bytes().to_vec()),
432            ("stream_id", stream_id.as_bytes().to_vec()),
433            ("route", route.as_bytes().to_vec()),
434            ("repository", repository.as_bytes().to_vec()),
435            ("resume_cursor", resume_cursor.as_bytes().to_vec()),
436            (
437                "capability_sha256",
438                hex::encode(Sha256::digest(capability_context)).into_bytes(),
439            ),
440        ],
441    )
442}
443
444/// Returns the canonical bytes signed to consent to one exact provider batch.
445///
446/// The server and Worker independently establish authorization from the
447/// owner-anchored capability. This signature proves possession of the same
448/// device key used for the stream opening and binds consent to one repository,
449/// endpoint, nonce, and exact private-batch digest.
450pub fn provider_plan_bytes(
451    signing_identity: &str,
452    stream_id: &str,
453    repository: &str,
454    client_endpoint_id: &str,
455    plan_nonce: &[u8],
456    grant_batch_digest: &[u8],
457) -> Vec<u8> {
458    provider_plan_canonical(
459        "exact-batch",
460        &[
461            ("identity", signing_identity.as_bytes().to_vec()),
462            ("stream_id", stream_id.as_bytes().to_vec()),
463            ("repository", repository.as_bytes().to_vec()),
464            ("client_endpoint_id", client_endpoint_id.as_bytes().to_vec()),
465            ("plan_nonce", hex::encode(plan_nonce).into_bytes()),
466            (
467                "grant_batch_digest",
468                hex::encode(grant_batch_digest).into_bytes(),
469            ),
470        ],
471    )
472}
473
474/// Hashes the retry identity without conflating it with the request payload.
475pub fn retry_key_hash(route: &str, client_operation_id: &str, request: &[u8]) -> [u8; 32] {
476    Sha256::digest(canonical(
477        "retry-key",
478        &[
479            ("route", route.as_bytes().to_vec()),
480            (
481                "client_operation_id",
482                client_operation_id.as_bytes().to_vec(),
483            ),
484            (
485                "request_sha256",
486                hex::encode(Sha256::digest(request)).into_bytes(),
487            ),
488        ],
489    ))
490    .into()
491}
492
493/// Returns the domain-separated bytes signed for an HTTPS endpoint descriptor.
494pub fn endpoint_descriptor_bytes(descriptor: &EndpointDescriptor) -> Vec<u8> {
495    bootstrap_bytes("endpoint-descriptor", descriptor)
496}
497
498/// Returns the domain-separated bytes signed for a relay admission token.
499pub fn relay_admission_bytes(claims: &RelayAdmissionClaims) -> Vec<u8> {
500    bootstrap_bytes("relay-admission", claims)
501}
502
503fn bootstrap_bytes(kind: &str, message: &impl Message) -> Vec<u8> {
504    canonical_with_domain(
505        TRANSPORT_BOOTSTRAP_SIGNING_V1_DOMAIN,
506        kind,
507        &[("protobuf", message.encode_to_vec())],
508    )
509}
510
511fn canonical(kind: &str, fields: &[(&str, Vec<u8>)]) -> Vec<u8> {
512    canonical_with_domain(TIER_1_REQUEST_SIGNING_V1_DOMAIN, kind, fields)
513}
514
515pub(crate) fn canonical_with_domain(
516    domain: &str,
517    kind: &str,
518    fields: &[(&str, Vec<u8>)],
519) -> Vec<u8> {
520    let mut result = format!("{domain}\nkind={}:{}", kind.len(), kind).into_bytes();
521    for (name, value) in fields {
522        result.extend_from_slice(format!("\n{name}={}:", value.len()).as_bytes());
523        result.extend_from_slice(value);
524    }
525    result
526}
527
528fn provider_plan_canonical(kind: &str, fields: &[(&str, Vec<u8>)]) -> Vec<u8> {
529    let mut result = format!("{PROVIDER_PLAN_DOMAIN}\nkind={}:{}", kind.len(), kind).into_bytes();
530    for (name, value) in fields {
531        result.extend_from_slice(format!("\n{name}={}:", value.len()).as_bytes());
532        result.extend_from_slice(value);
533    }
534    result
535}
536
537#[cfg(test)]
538mod tests {
539    use serde::Deserialize;
540
541    use super::*;
542
543    #[derive(Deserialize)]
544    struct UnaryVector {
545        identity: String,
546        route: String,
547        timestamp_millis: i64,
548        nonce_hex: String,
549        request_hex: String,
550        canonical_hex: String,
551    }
552
553    #[test]
554    fn canonical_fields_are_length_delimited() {
555        let first = unary_bytes("ab", "/c", 1, &[0], &[1]);
556        let second = unary_bytes("a", "b/c", 1, &[0], &[1]);
557        assert_ne!(first, second);
558        assert!(first.starts_with(b"heddle-req-sig-v1\nkind=5:unary"));
559    }
560
561    #[test]
562    fn client_mint_domains_are_distinct_and_versioned() {
563        let domains: [&[u8]; 3] = [
564            IDENTITY_BINDING_CHALLENGE_V2_DOMAIN,
565            POP_DELEGATION_V1_DOMAIN,
566            GRANT_ENVELOPE_V2_DOMAIN,
567        ];
568        assert!(domains.iter().all(|domain| domain.ends_with(&[0])));
569        assert!(
570            domains
571                .iter()
572                .all(|domain| domain.windows(2).any(|part| part == b"-v"))
573        );
574        assert_ne!(domains[0], domains[1]);
575        assert_ne!(domains[0], domains[2]);
576        assert_ne!(domains[1], domains[2]);
577        assert_eq!(TIER_1_REQUEST_SIGNING_V1_DOMAIN, "heddle-req-sig-v1");
578        assert!(!TIER_1_REQUEST_SIGNING_V1_DOMAIN.as_bytes().contains(&0));
579    }
580
581    #[test]
582    fn binding_challenge_contains_both_fixed_role_key_pairs() {
583        let authority = [0x11; 32];
584        let proof = [0x22; 32];
585        let challenge = identity_binding_challenge_v2_bytes(&authority, &proof);
586        let expected = [
587            IDENTITY_BINDING_CHALLENGE_V2_DOMAIN,
588            BISCUIT_AUTHORITY_PUBLIC_KEY_ROLE,
589            authority.as_slice(),
590            DEVICE_PROOF_PUBLIC_KEY_ROLE,
591            proof.as_slice(),
592        ]
593        .concat();
594        assert_eq!(challenge, expected);
595        assert_ne!(
596            challenge,
597            identity_binding_challenge_v2_bytes(&proof, &authority)
598        );
599    }
600
601    #[test]
602    fn provider_plan_signature_changes_with_every_authorization_binding() {
603        let endpoint = "11".repeat(32);
604        let baseline = provider_plan_bytes(
605            "principal:alice",
606            "pull:one",
607            "acme/widgets",
608            &endpoint,
609            &[7; 16],
610            &[9; 32],
611        );
612        let different_digest = provider_plan_bytes(
613            "principal:alice",
614            "pull:one",
615            "acme/widgets",
616            &endpoint,
617            &[7; 16],
618            &[8; 32],
619        );
620        let different_nonce = provider_plan_bytes(
621            "principal:alice",
622            "pull:one",
623            "acme/widgets",
624            &endpoint,
625            &[6; 16],
626            &[9; 32],
627        );
628
629        assert!(baseline.starts_with(b"heddle-provider-plan-v1\nkind=11:exact-batch"));
630        assert_ne!(baseline, different_digest);
631        assert_ne!(baseline, different_nonce);
632    }
633
634    #[test]
635    fn base64url_nopad_matches_rfc4648_url_safe_vectors() {
636        // Independently computed with Python `base64.urlsafe_b64encode(..).rstrip(b"=")`.
637        assert_eq!(base64url_nopad(b""), "");
638        assert_eq!(base64url_nopad(&[0xff]), "_w");
639        assert_eq!(base64url_nopad(&[0xff, 0xff]), "__8");
640        assert_eq!(base64url_nopad(&[0xff, 0xff, 0xff]), "____");
641        assert_eq!(base64url_nopad(&[0xfb, 0xef, 0xbe]), "----");
642        assert_eq!(base64url_nopad(&[0x00, 0x01, 0x02, 0x03, 0x04]), "AAECAwQ");
643    }
644
645    #[test]
646    fn one_key_device_binding_challenge_matches_weft_bytes() {
647        // The domain string must equal weft's weft-local constant exactly.
648        assert_eq!(
649            ONE_KEY_DEVICE_BINDING_CHALLENGE_DOMAIN,
650            "heddle-one-key-device-binding-v1"
651        );
652        // Fixed vector, independently computed:
653        //   base64url_nopad(SHA256("heddle-one-key-device-binding-v1" || 0x00 || [0x42; 32]))
654        // Replicates weft `derive_one_key_device_binding_challenge([0x42; 32])`.
655        let device_proof_public_key = [0x42u8; 32];
656        assert_eq!(
657            one_key_device_binding_challenge(&device_proof_public_key),
658            "3RHXb4oa6eEf61uPDjWBZT83WJ7UsOzp16r8Fa_Ho5U"
659        );
660    }
661
662    #[test]
663    fn recovery_new_device_pop_digest_is_domain_separated_and_fixed() {
664        assert_eq!(
665            RECOVERY_NEW_DEVICE_POP_V1_DOMAIN,
666            "heddle-recovery-new-device-pop-v1"
667        );
668        // Fixed vector, independently computed:
669        //   SHA256("heddle-recovery-new-device-pop-v1" || 0x00 ||
670        //          "recovery-attempt-0001" || 0x00 || [0x11; 32])
671        let digest = recovery_new_device_pop_digest("recovery-attempt-0001", &[0x11u8; 32]);
672        assert_eq!(
673            hex::encode(digest),
674            "3dd4241504103a8fbc6bfdfb37f6ac2aa3ff224668c4f50f6b818d1d5727b2a8"
675        );
676        // The attempt id and the key each independently change the digest.
677        assert_ne!(
678            digest,
679            recovery_new_device_pop_digest("recovery-attempt-0002", &[0x11u8; 32])
680        );
681        assert_ne!(
682            digest,
683            recovery_new_device_pop_digest("recovery-attempt-0001", &[0x22u8; 32])
684        );
685        // Domain separation from the credential-rotation PoP family.
686        assert_ne!(
687            RECOVERY_NEW_DEVICE_POP_V1_DOMAIN,
688            "heddle-credential-rotation-v1"
689        );
690    }
691
692    #[test]
693    fn unary_signature_matches_cross_language_vector() {
694        let vector: UnaryVector =
695            serde_json::from_str(include_str!("../tests/fixtures/unary-signing-v1.json"))
696                .expect("valid fixture");
697        let actual = unary_bytes(
698            &vector.identity,
699            &vector.route,
700            vector.timestamp_millis,
701            &hex::decode(vector.nonce_hex).expect("nonce hex"),
702            &hex::decode(vector.request_hex).expect("request hex"),
703        );
704        assert_eq!(hex::encode(actual), vector.canonical_hex);
705    }
706}