1use 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
11const LINK_TOKEN_HASH_DOMAIN: &[u8] = b"gonzalo:link-token:v1";
14
15#[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 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 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 let _ = write!(out, "{byte:02x}");
56 }
57 out
58 }
59
60 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#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct LinkToken {
98 pub token_hash: String,
100 pub role: FleetRole,
101 pub scope: GrantScope,
102 pub person: Option<String>,
104 pub minted_by: FleetActor,
105 pub minted_at: i64,
106 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 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 pub fn key(&self) -> RecordKey {
139 RecordKey::new(
140 FLEET_NAMESPACE,
141 LINK_TOKENS_COLLECTION,
142 self.token_hash.clone(),
143 )
144 }
145
146 pub fn key_for_secret(secret: &LinkSecret) -> RecordKey {
148 RecordKey::new(FLEET_NAMESPACE, LINK_TOKENS_COLLECTION, secret.hash())
149 }
150
151 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum RedeemError {
190 WrongSecret,
191 Expired,
192 AlreadyConsumed,
193 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}