Skip to main content

guardian_shared/
lookup_auth_message.rs

1use miden_protocol::crypto::hash::rpo::Rpo256;
2use miden_protocol::{Felt, Word};
3use std::sync::OnceLock;
4
5/// Domain-tag byte string. The 4-felt RPO digest of these bytes is prepended to
6/// every `LookupAuthMessage` digest so that lookup signatures are structurally
7/// distinct from `AuthRequestMessage` signatures (which are account-bound and
8/// have a 7-felt layout). A signature crafted under one shape cannot validate
9/// under the other in either direction.
10///
11/// Future incompatible layout changes MUST bump the version segment
12/// (e.g. `guardian.lookup.v2`) rather than mutate this constant.
13const DOMAIN_TAG_BYTES: &[u8] = b"guardian.lookup.v1";
14
15/// Cached 4-felt domain-tag word, computed once on first use.
16fn domain_tag() -> Word {
17    static TAG: OnceLock<Word> = OnceLock::new();
18    *TAG.get_or_init(|| {
19        let mut elements = Vec::with_capacity(DOMAIN_TAG_BYTES.len().div_ceil(8));
20        for chunk in DOMAIN_TAG_BYTES.chunks(8) {
21            let mut chunk_bytes = [0u8; 8];
22            chunk_bytes[..chunk.len()].copy_from_slice(chunk);
23            elements.push(crate::felt::felt_from_u64_reduced(u64::from_le_bytes(
24                chunk_bytes,
25            )));
26        }
27        Rpo256::hash_elements(&elements)
28    })
29}
30
31/// Account-less, replay-protected message format used to sign requests against
32/// the `/state/lookup` endpoint and the `GetAccountByKeyCommitment` gRPC method.
33///
34/// Unlike [`crate::auth_request_message::AuthRequestMessage`], this message does
35/// not bind to an `account_id` — that is the value the caller is trying to
36/// discover. Replay protection comes from a server-clock skew window enforced
37/// against `timestamp_ms`. Cross-domain replay protection comes from the
38/// fixed 4-felt domain tag at the head of the digest input.
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct LookupAuthMessage {
41    timestamp_ms: i64,
42    key_commitment: Word,
43}
44
45impl LookupAuthMessage {
46    pub fn new(timestamp_ms: i64, key_commitment: Word) -> Self {
47        Self {
48            timestamp_ms,
49            key_commitment,
50        }
51    }
52
53    pub fn timestamp_ms(&self) -> i64 {
54        self.timestamp_ms
55    }
56
57    pub fn key_commitment(&self) -> Word {
58        self.key_commitment
59    }
60
61    /// Compute the message digest used for signing.
62    ///
63    /// Layout:
64    /// ```text
65    /// RPO256_hash([
66    ///   DOMAIN_TAG_W0, DOMAIN_TAG_W1, DOMAIN_TAG_W2, DOMAIN_TAG_W3,
67    ///   timestamp_ms_felt,
68    ///   key_commitment_W0, key_commitment_W1,
69    ///   key_commitment_W2, key_commitment_W3,
70    /// ])
71    /// ```
72    pub fn to_word(&self) -> Word {
73        let tag = domain_tag();
74        let tag_elements = tag.as_elements();
75        let kc_elements = self.key_commitment.as_elements();
76        let timestamp_felt = crate::felt::felt_from_u64_reduced(self.timestamp_ms as u64);
77        let message_elements: [Felt; 9] = [
78            tag_elements[0],
79            tag_elements[1],
80            tag_elements[2],
81            tag_elements[3],
82            timestamp_felt,
83            kc_elements[0],
84            kc_elements[1],
85            kc_elements[2],
86            kc_elements[3],
87        ];
88        Rpo256::hash_elements(&message_elements)
89    }
90}
91
92/// Returns the cached domain-tag word so server, client, and parity-fixture code
93/// can all assert on the same constant.
94pub fn lookup_domain_tag() -> Word {
95    domain_tag()
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::auth_request_message::AuthRequestMessage;
102    use crate::auth_request_payload::AuthRequestPayload;
103    use miden_protocol::account::{AccountId, AccountIdVersion, AccountType};
104
105    fn sample_commitment(seed: u32) -> Word {
106        Word::from([
107            seed,
108            seed.wrapping_add(1),
109            seed.wrapping_add(2),
110            seed.wrapping_add(3),
111        ])
112    }
113
114    #[test]
115    fn domain_tag_is_stable_across_calls() {
116        let a = lookup_domain_tag();
117        let b = lookup_domain_tag();
118        assert_eq!(a, b);
119    }
120
121    #[test]
122    fn domain_tag_is_not_zero_word() {
123        let tag = lookup_domain_tag();
124        assert_ne!(tag, Word::from([Felt::ZERO; 4]));
125    }
126
127    #[test]
128    fn digest_changes_with_commitment() {
129        let timestamp = 1_700_000_000_000i64;
130        let left = LookupAuthMessage::new(timestamp, sample_commitment(1)).to_word();
131        let right = LookupAuthMessage::new(timestamp, sample_commitment(2)).to_word();
132        assert_ne!(left, right);
133    }
134
135    #[test]
136    fn digest_changes_with_timestamp() {
137        let commitment = sample_commitment(7);
138        let left = LookupAuthMessage::new(1_700_000_000_000, commitment).to_word();
139        let right = LookupAuthMessage::new(1_700_000_000_001, commitment).to_word();
140        assert_ne!(left, right);
141    }
142
143    #[test]
144    fn digest_is_deterministic() {
145        let msg = LookupAuthMessage::new(1_700_000_000_000, sample_commitment(42));
146        assert_eq!(msg.to_word(), msg.to_word());
147    }
148
149    #[test]
150    fn digest_handles_extreme_timestamps() {
151        let commitment = sample_commitment(99);
152        let zero = LookupAuthMessage::new(0, commitment).to_word();
153        let large = LookupAuthMessage::new(i64::MAX, commitment).to_word();
154        assert_ne!(zero, large);
155        // Negative timestamps are accepted by the type but rejected by the
156        // server skew check; the digest itself is just a deterministic mapping.
157        let negative = LookupAuthMessage::new(-1, commitment).to_word();
158        assert_ne!(negative, zero);
159    }
160
161    #[test]
162    fn lookup_digest_is_distinct_from_auth_request_digest() {
163        // A LookupAuthMessage digest must not collide with any AuthRequestMessage
164        // digest, even when timestamp and the 4-felt payload are aligned to the
165        // commitment, so a signature for one cannot be replayed against the other.
166        let timestamp = 1_700_000_000_000i64;
167        let commitment = sample_commitment(123);
168
169        let lookup_digest = LookupAuthMessage::new(timestamp, commitment).to_word();
170
171        let account_id =
172            AccountId::dummy([0x8a; 15], AccountIdVersion::Version1, AccountType::Private);
173        let payload = AuthRequestPayload::from_bytes(&commitment.as_bytes());
174        let request_digest = AuthRequestMessage::new(account_id, timestamp, payload).to_word();
175
176        assert_ne!(lookup_digest, request_digest);
177    }
178
179    #[test]
180    fn domain_tag_is_known_constant() {
181        // Pin the on-the-wire domain-separator. If this assertion ever needs to
182        // change, every signer in the field must be updated in lockstep.
183        // The expected value is computed from b"guardian.lookup.v1" via RPO256.
184        let tag = lookup_domain_tag();
185        // Recompute the expected value here (rather than hard-coding) so the
186        // assertion fails clearly if the chunking convention or hash function
187        // ever changes.
188        let mut elements: Vec<Felt> = Vec::new();
189        for chunk in DOMAIN_TAG_BYTES.chunks(8) {
190            let mut bytes = [0u8; 8];
191            bytes[..chunk.len()].copy_from_slice(chunk);
192            elements.push(crate::felt::felt_from_u64_reduced(u64::from_le_bytes(
193                bytes,
194            )));
195        }
196        let expected = Rpo256::hash_elements(&elements);
197        assert_eq!(tag, expected);
198    }
199}