1use sha2::{Digest, Sha256};
4
5use crate::heddle::api::v1alpha1::{EndpointDescriptor, RelayAdmissionClaims};
6use prost::Message;
7
8pub const IDENTITY_BINDING_CHALLENGE_V2_DOMAIN: &[u8] = b"heddle-device-binding-v2\0";
11pub const POP_DELEGATION_V1_DOMAIN: &[u8] = b"heddle-pop-delegation-v1\0";
14pub const TIER_1_REQUEST_SIGNING_V1_DOMAIN: &str = "heddle-req-sig-v1";
17pub const TRANSPORT_BOOTSTRAP_SIGNING_V1_DOMAIN: &str = "heddle-req-sig-v1";
21pub const GRANT_ENVELOPE_V2_DOMAIN: &[u8] = b"heddle-grant-envelope-v2\0";
23pub const ONE_KEY_DEVICE_BINDING_CHALLENGE_DOMAIN: &str = "heddle-one-key-device-binding-v1";
31pub const RECOVERY_NEW_DEVICE_POP_V1_DOMAIN: &str = "heddle-recovery-new-device-pop-v1";
37
38pub const BISCUIT_AUTHORITY_PUBLIC_KEY_ROLE: &[u8] = b"biscuit_authority_public_key\0";
40pub const DEVICE_PROOF_PUBLIC_KEY_ROLE: &[u8] = b"device_proof_public_key\0";
42pub 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#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct GrantEnvelopeV2Right {
63 pub kind: String,
64 pub path: String,
65 pub action: String,
66}
67
68#[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#[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
102pub 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
162pub 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
313pub 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
331pub 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
347pub 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
372fn 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
396pub 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
419pub 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
444pub 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
474pub 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
493pub fn endpoint_descriptor_bytes(descriptor: &EndpointDescriptor) -> Vec<u8> {
495 bootstrap_bytes("endpoint-descriptor", descriptor)
496}
497
498pub 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 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 assert_eq!(
649 ONE_KEY_DEVICE_BINDING_CHALLENGE_DOMAIN,
650 "heddle-one-key-device-binding-v1"
651 );
652 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 let digest = recovery_new_device_pop_digest("recovery-attempt-0001", &[0x11u8; 32]);
672 assert_eq!(
673 hex::encode(digest),
674 "3dd4241504103a8fbc6bfdfb37f6ac2aa3ff224668c4f50f6b818d1d5727b2a8"
675 );
676 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 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}