mkit_server/scanner_retrieval/
token.rs1use 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct PackGrant {
15 pub id: Hash,
17 pub length: u64,
19 pub tickets: Vec<Hash>,
21}
22
23#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct Assignment {
27 pub namespace: String,
29 pub repo_name: String,
31 pub repository: String,
33 pub ref_name: String,
35 pub signer: Hash,
37 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 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 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 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}