Skip to main content

gonzalo_domain/fleet/
link.rs

1//! One-time link tokens. The record stores only a hash of the secret, and
2//! redemption marks the token consumed instead of deleting it (ADR 0022).
3
4use super::{FLEET_NAMESPACE, FleetActor, FleetRole, GrantScope, LINK_TOKENS_COLLECTION};
5use crate::codec::RecordCodec;
6use gonzalo_core::{ContentHash, RecordKey, RecordKind};
7use serde::{Deserialize, Serialize};
8use std::fmt;
9use std::fmt::Write as _;
10
11/// Domain separation for link-token hashes, so a token hash can never equal a
12/// record body's content hash.
13const LINK_TOKEN_HASH_DOMAIN: &[u8] = b"gonzalo:link-token:v1";
14
15/// The secret half of a link token: 32 random bytes the caller generates.
16///
17/// Shown to the operator once, as [`to_hex`](Self::to_hex). It is not
18/// `Serialize`, and its `Debug` output is redacted, so it can't end up in a
19/// record body or a log by accident.
20#[derive(Clone, PartialEq, Eq)]
21pub struct LinkSecret([u8; 32]);
22
23impl LinkSecret {
24    pub fn from_bytes(bytes: [u8; 32]) -> Self {
25        Self(bytes)
26    }
27
28    /// Parse the 64 lowercase hex characters produced by [`to_hex`](Self::to_hex).
29    pub fn parse(hex: &str) -> Result<Self, LinkSecretError> {
30        let digits = hex.as_bytes();
31        if digits.len() != 64
32            || !digits
33                .iter()
34                .all(|&b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
35        {
36            return Err(LinkSecretError);
37        }
38        let mut bytes = [0u8; 32];
39        // `digits.len() == 64` is checked above, so every pair is complete and
40        // the remainder is empty. `as_chunks` gives `&[u8; 2]` rather than a
41        // slice, so the pair's length is in the type (clippy 1.99's
42        // `chunks_exact_to_as_chunks`).
43        let (pairs, _remainder) = digits.as_chunks::<2>();
44        for (byte, pair) in bytes.iter_mut().zip(pairs) {
45            let pair = std::str::from_utf8(pair).map_err(|_| LinkSecretError)?;
46            *byte = u8::from_str_radix(pair, 16).map_err(|_| LinkSecretError)?;
47        }
48        Ok(Self(bytes))
49    }
50
51    pub fn to_hex(&self) -> String {
52        let mut out = String::with_capacity(64);
53        for byte in &self.0 {
54            // Writing to a `String` cannot fail.
55            let _ = write!(out, "{byte:02x}");
56        }
57        out
58    }
59
60    /// The hash stored in a [`LinkToken`] and used as its key id.
61    pub fn hash(&self) -> String {
62        let mut input = Vec::with_capacity(LINK_TOKEN_HASH_DOMAIN.len() + self.0.len());
63        input.extend_from_slice(LINK_TOKEN_HASH_DOMAIN);
64        input.extend_from_slice(&self.0);
65        ContentHash::of(&input).0
66    }
67}
68
69impl fmt::Debug for LinkSecret {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        f.write_str("LinkSecret(<redacted>)")
72    }
73}
74
75/// A link secret that isn't 64 lowercase hex characters.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct LinkSecretError;
78
79impl fmt::Display for LinkSecretError {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        f.write_str("a link secret must be 64 lowercase hex characters")
82    }
83}
84
85impl std::error::Error for LinkSecretError {}
86
87/// Who redeemed a token, into which binding, and when.
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct Consumption {
90    pub person: String,
91    pub binding: RecordKey,
92    pub at: i64,
93}
94
95/// A one-time, expiring token that lets a person claim an account.
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct LinkToken {
98    /// [`LinkSecret::hash`] of the secret. The secret itself is never stored.
99    pub token_hash: String,
100    pub role: FleetRole,
101    pub scope: GrantScope,
102    /// `Some` when the token adds an account to an existing person.
103    pub person: Option<String>,
104    pub minted_by: FleetActor,
105    pub minted_at: i64,
106    /// Redemption fails at or after this time.
107    pub expires_at: i64,
108    pub consumed: Option<Consumption>,
109}
110impl RecordCodec for LinkToken {}
111
112impl LinkToken {
113    pub const KIND: RecordKind = RecordKind::LinkToken;
114
115    /// An unconsumed token for `secret`, storing only the secret's hash.
116    pub fn new(
117        secret: &LinkSecret,
118        role: FleetRole,
119        scope: GrantScope,
120        person: Option<String>,
121        minted_by: FleetActor,
122        minted_at: i64,
123        expires_at: i64,
124    ) -> Self {
125        Self {
126            token_hash: secret.hash(),
127            role,
128            scope,
129            person,
130            minted_by,
131            minted_at,
132            expires_at,
133            consumed: None,
134        }
135    }
136
137    /// `fleet/link-tokens/<token_hash>`.
138    pub fn key(&self) -> RecordKey {
139        RecordKey::new(
140            FLEET_NAMESPACE,
141            LINK_TOKENS_COLLECTION,
142            self.token_hash.clone(),
143        )
144    }
145
146    /// The key a presented secret's token lives at.
147    pub fn key_for_secret(secret: &LinkSecret) -> RecordKey {
148        RecordKey::new(FLEET_NAMESPACE, LINK_TOKENS_COLLECTION, secret.hash())
149    }
150
151    /// This token marked consumed by `person` into `binding` at `now_ms`.
152    ///
153    /// Write the result with `Store::put(record, Some(read_revision))`, so a
154    /// concurrent redemption of the same revision is a `Conflict`. Checks run
155    /// in order: wrong secret, expired, already consumed, person mismatch.
156    pub fn redeem(
157        &self,
158        secret: &LinkSecret,
159        person: &str,
160        binding: RecordKey,
161        now_ms: i64,
162    ) -> Result<LinkToken, RedeemError> {
163        if secret.hash() != self.token_hash {
164            return Err(RedeemError::WrongSecret);
165        }
166        if now_ms >= self.expires_at {
167            return Err(RedeemError::Expired);
168        }
169        if self.consumed.is_some() {
170            return Err(RedeemError::AlreadyConsumed);
171        }
172        if let Some(intended) = &self.person
173            && intended != person
174        {
175            return Err(RedeemError::PersonMismatch);
176        }
177        let mut redeemed = self.clone();
178        redeemed.consumed = Some(Consumption {
179            person: person.to_string(),
180            binding,
181            at: now_ms,
182        });
183        Ok(redeemed)
184    }
185}
186
187/// Why a link token could not be redeemed.
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum RedeemError {
190    WrongSecret,
191    Expired,
192    AlreadyConsumed,
193    /// The token was minted for a different person.
194    PersonMismatch,
195}
196
197impl fmt::Display for RedeemError {
198    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199        f.write_str(match self {
200            Self::WrongSecret => "the secret does not match this link token",
201            Self::Expired => "the link token has expired",
202            Self::AlreadyConsumed => "the link token has already been redeemed",
203            Self::PersonMismatch => "the link token was minted for a different person",
204        })
205    }
206}
207
208impl std::error::Error for RedeemError {}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    const NOW: i64 = 1_700_000_000_000;
215
216    fn secret() -> LinkSecret {
217        LinkSecret::from_bytes([0xab; 32])
218    }
219
220    fn token(person: Option<&str>) -> LinkToken {
221        LinkToken::new(
222            &secret(),
223            FleetRole::Operator,
224            GrantScope::Fleet,
225            person.map(str::to_string),
226            FleetActor::Service("ariel-cli".into()),
227            NOW,
228            NOW + 600_000,
229        )
230    }
231
232    fn binding() -> RecordKey {
233        RecordKey::new("fleet", "identity-bindings", "discord:1234")
234    }
235
236    #[test]
237    fn secret_hex_roundtrips_and_rejects_bad_input() {
238        let s = LinkSecret::from_bytes(std::array::from_fn(|i| i as u8));
239        let hex = s.to_hex();
240        assert_eq!(hex.len(), 64);
241        assert_eq!(LinkSecret::parse(&hex).unwrap(), s);
242        assert_eq!(LinkSecret::parse(&hex[..63]), Err(LinkSecretError));
243        assert_eq!(LinkSecret::parse(&hex.to_uppercase()), Err(LinkSecretError));
244        assert_eq!(LinkSecret::parse(&"g".repeat(64)), Err(LinkSecretError));
245    }
246
247    #[test]
248    fn hash_is_domain_separated() {
249        let s = secret();
250        let mut input = b"gonzalo:link-token:v1".to_vec();
251        input.extend_from_slice(&[0xab; 32]);
252        assert_eq!(s.hash(), ContentHash::of(&input).0);
253        assert_ne!(s.hash(), ContentHash::of(&[0xab; 32]).0);
254        assert_ne!(s.hash(), LinkSecret::from_bytes([0xac; 32]).hash());
255        assert_eq!(s.hash().len(), 64);
256    }
257
258    #[test]
259    fn secret_debug_is_redacted() {
260        let s = secret();
261        assert!(!format!("{s:?}").contains(&s.to_hex()));
262    }
263
264    #[test]
265    fn token_body_never_contains_the_secret() {
266        let t = token(None);
267        let body = t.to_body().unwrap();
268        let text = String::from_utf8(body.bytes().to_vec()).unwrap();
269        assert!(!text.contains(&secret().to_hex()));
270        assert!(text.contains(&secret().hash()));
271        assert_eq!(LinkToken::from_body(&body).unwrap(), t);
272        assert_eq!(LinkToken::KIND, RecordKind::LinkToken);
273    }
274
275    #[test]
276    fn token_is_keyed_by_hash() {
277        let t = token(None);
278        assert_eq!(
279            t.key(),
280            RecordKey::new("fleet", "link-tokens", secret().hash())
281        );
282        assert_eq!(LinkToken::key_for_secret(&secret()), t.key());
283    }
284
285    #[test]
286    fn redeem_marks_consumed() {
287        let redeemed = token(None)
288            .redeem(&secret(), "p1", binding(), NOW + 1)
289            .unwrap();
290        assert_eq!(
291            redeemed.consumed,
292            Some(Consumption {
293                person: "p1".into(),
294                binding: binding(),
295                at: NOW + 1,
296            })
297        );
298        let mut expected = token(None);
299        expected.consumed = redeemed.consumed.clone();
300        assert_eq!(redeemed, expected, "redeem changes only `consumed`");
301    }
302
303    #[test]
304    fn redeem_rejects_wrong_secret_expired_consumed_and_mismatch() {
305        let other = LinkSecret::from_bytes([1; 32]);
306        assert_eq!(
307            token(None).redeem(&other, "p1", binding(), NOW),
308            Err(RedeemError::WrongSecret)
309        );
310        assert_eq!(
311            token(None).redeem(&secret(), "p1", binding(), NOW + 600_000),
312            Err(RedeemError::Expired),
313            "expiry is exclusive"
314        );
315        let consumed = token(None).redeem(&secret(), "p1", binding(), NOW).unwrap();
316        assert_eq!(
317            consumed.redeem(&secret(), "p2", binding(), NOW + 1),
318            Err(RedeemError::AlreadyConsumed)
319        );
320        assert_eq!(
321            token(Some("p1")).redeem(&secret(), "p2", binding(), NOW),
322            Err(RedeemError::PersonMismatch)
323        );
324        assert!(
325            token(Some("p1"))
326                .redeem(&secret(), "p1", binding(), NOW)
327                .is_ok()
328        );
329    }
330
331    #[test]
332    fn redeem_checks_run_in_order() {
333        let consumed = token(Some("p1"))
334            .redeem(&secret(), "p1", binding(), NOW)
335            .unwrap();
336        let other = LinkSecret::from_bytes([1; 32]);
337        assert_eq!(
338            consumed.redeem(&other, "p2", binding(), NOW + 600_000),
339            Err(RedeemError::WrongSecret)
340        );
341        assert_eq!(
342            consumed.redeem(&secret(), "p2", binding(), NOW + 600_000),
343            Err(RedeemError::Expired)
344        );
345        assert_eq!(
346            consumed.redeem(&secret(), "p2", binding(), NOW + 1),
347            Err(RedeemError::AlreadyConsumed)
348        );
349    }
350}