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