Skip to main content

mkit_server/scanner_retrieval/
token.rs

1use core::time::Duration;
2use mkit_core::hash::{Hash, from_hex, to_hex, to_hex_bytes};
3use serde::{Deserialize, Serialize};
4use subtle::ConstantTimeEq;
5
6use super::{MARGIN_MS, MAX_LIFETIME_MS, MAX_REQUEST_BYTES, PATH, RetrievalConfig};
7use crate::ServerError;
8
9const DOMAIN: &[u8] = b"mkit-scanner-retrieval:v1\n";
10
11/// Raw pack assignment from existing, verified upload-ticket metadata.
12#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct PackGrant {
15    /// Raw pack commitment.
16    pub id: Hash,
17    /// Exact stored byte count.
18    pub length: u64,
19    /// Every upload ticket bound to this pack, in request order.
20    pub tickets: Vec<Hash>,
21}
22
23/// Repository and ticket bindings retained in an authenticated capability.
24#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct Assignment {
27    /// Internal namespace routing key.
28    pub namespace: String,
29    /// Internal repository name.
30    pub repo_name: String,
31    /// Exact wire repository identity.
32    pub repository: String,
33    /// Ticket ref; selects the strongly consistent ref shard.
34    pub ref_name: String,
35    /// Writer that owns the tickets, independent of the scanner signer.
36    pub signer: Hash,
37    /// Exact ordered raw added pack ids, lengths and bound tickets.
38    pub packs: Vec<PackGrant>,
39}
40
41#[derive(Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub(crate) struct Claims {
44    pub audience: String,
45    pub inspection_id: String,
46    pub issued_at_ms: u64,
47    pub expires_at_ms: u64,
48    pub nonce: Hash,
49    pub assignment: Assignment,
50}
51
52fn mac(secret: &Hash, bytes: &[u8]) -> Hash {
53    let mut hash = blake3::Hasher::new_keyed(secret);
54    hash.update(DOMAIN);
55    hash.update(bytes);
56    *hash.finalize().as_bytes()
57}
58
59fn decode_hex(text: &str) -> Result<Vec<u8>, ServerError> {
60    if !text.len().is_multiple_of(2)
61        || !text
62            .bytes()
63            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
64    {
65        return Err(super::service::missing());
66    }
67    text.as_bytes()
68        .chunks_exact(2)
69        .map(|pair| {
70            let digit = |b: u8| if b <= b'9' { b - b'0' } else { b - b'a' + 10 };
71            Ok((digit(pair[0]) << 4) | digit(pair[1]))
72        })
73        .collect()
74}
75
76impl RetrievalConfig {
77    /// Mint a fresh capability for one Inspect attempt. Its stable inspection
78    /// id survives retry; the nonce and capability are always freshly minted.
79    ///
80    /// # Errors
81    /// Invalid assignment/timeout, unavailable randomness or encoding.
82    pub fn mint(
83        &self,
84        audience: &str,
85        inspection_id: &str,
86        assignment: &Assignment,
87        timeout: Duration,
88        now_ms: u64,
89    ) -> Result<mkit_rpc::hooks::InspectRetrieval, ServerError> {
90        let timeout_ms =
91            u64::try_from(timeout.as_millis()).map_err(|_| super::service::missing())?;
92        if timeout_ms == 0
93            || timeout_ms > MAX_LIFETIME_MS - MARGIN_MS
94            || inspection_id.is_empty()
95            || !valid_assignment(assignment)
96        {
97            return Err(super::service::missing());
98        }
99        let mut nonce = [0; 32];
100        getrandom::fill(&mut nonce)
101            .map_err(|_| ServerError::unavailable("retrieval unavailable"))?;
102        let expires_at_ms = now_ms
103            .checked_add(timeout_ms + MARGIN_MS)
104            .ok_or_else(super::service::missing)?;
105        let claims = Claims {
106            audience: audience.to_owned(),
107            inspection_id: inspection_id.to_owned(),
108            issued_at_ms: now_ms,
109            expires_at_ms,
110            nonce,
111            assignment: assignment.clone(),
112        };
113        let bytes = serde_json::to_vec(&claims).map_err(|_| super::service::missing())?;
114        let key = self.keys.first().ok_or_else(super::service::missing)?;
115        let capability = format!(
116            "r1.{}.{}.{}",
117            key.id,
118            to_hex_bytes(&bytes),
119            to_hex(&mac(&key.secret, &bytes))
120        );
121        // Leave room for request JSON, the pack id and range fields.
122        if capability.len() > MAX_REQUEST_BYTES - 256 {
123            return Err(super::service::missing());
124        }
125        Ok(mkit_rpc::hooks::InspectRetrieval {
126            endpoint_path: Some(PATH.to_owned()),
127            capability: Some(capability),
128            expires_at_ms: Some(expires_at_ms),
129            packs: assignment
130                .packs
131                .iter()
132                .map(|p| mkit_rpc::hooks::InspectPack {
133                    id: Some(p.id.to_vec()),
134                    length: Some(p.length),
135                    ..Default::default()
136                })
137                .collect(),
138            ..Default::default()
139        })
140    }
141
142    pub(crate) fn verify(
143        &self,
144        token: &str,
145        audience: &str,
146        now: u64,
147    ) -> Result<Claims, ServerError> {
148        if token.len() > MAX_REQUEST_BYTES - 256 {
149            return Err(super::service::missing());
150        }
151        let rest = token
152            .strip_prefix("r1.")
153            .ok_or_else(super::service::missing)?;
154        let (prefix, tag) = rest.rsplit_once('.').ok_or_else(super::service::missing)?;
155        let (id, body) = prefix
156            .rsplit_once('.')
157            .ok_or_else(super::service::missing)?;
158        let key = self
159            .keys
160            .iter()
161            .find(|key| {
162                key.id == id
163                    && key
164                        .retired_at_ms
165                        .is_none_or(|at| now < at.saturating_add(MAX_LIFETIME_MS))
166            })
167            .ok_or_else(super::service::missing)?;
168        let bytes = decode_hex(body)?;
169        let tag_text = tag;
170        let tag = from_hex(tag).map_err(|_| super::service::missing())?;
171        if to_hex(&tag) != tag_text || !bool::from(mac(&key.secret, &bytes).ct_eq(&tag)) {
172            return Err(super::service::missing());
173        }
174        let claims: Claims =
175            serde_json::from_slice(&bytes).map_err(|_| super::service::missing())?;
176        if claims.audience != audience
177            || claims.inspection_id.is_empty()
178            || now < claims.issued_at_ms
179            || now >= claims.expires_at_ms
180            || claims
181                .expires_at_ms
182                .checked_sub(claims.issued_at_ms)
183                .is_none_or(|ttl| ttl == 0 || ttl > MAX_LIFETIME_MS)
184            || !valid_assignment(&claims.assignment)
185        {
186            return Err(super::service::missing());
187        }
188        Ok(claims)
189    }
190}
191
192fn valid_assignment(a: &Assignment) -> bool {
193    // Seven consumed tickets is the existing advance contract. Packs without
194    // file entries are still raw additions and remain in the assignment.
195    a.packs.len() <= 7
196        && a.packs
197            .iter()
198            .all(|p| p.length > 0 && !p.tickets.is_empty())
199        && a.packs.iter().map(|p| p.tickets.len()).sum::<usize>() <= 7
200        && a.packs
201            .iter()
202            .enumerate()
203            .all(|(i, p)| !a.packs[..i].iter().any(|old| old.id == p.id))
204        && crate::refs::validate_ref_name(&a.ref_name)
205}