1use crate::limits::CONTENT_RECEIPT_TTL_MS;
13use crate::namespace::catalog::VerifiedNamespaceCatalogEntry;
14use base64::Engine as _;
15use loonfs_api::v0::ValidatedContentToken;
16use loonfs_api::{ContentRef, ContentStoreId, NamespaceId};
17use serde::{Deserialize, Serialize};
18use sha2::Sha256;
19use thiserror::Error;
20
21const TOKEN_VERSION: &str = "vct0";
22
23#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct CompletedUploadReceipt {
32 namespace_id: NamespaceId,
33 content_store_id: ContentStoreId,
34 content_ref: ContentRef,
35}
36
37impl CompletedUploadReceipt {
38 pub(crate) fn for_completed_session(
39 namespace_id: NamespaceId,
40 content_store_id: ContentStoreId,
41 content_ref: ContentRef,
42 ) -> Self {
43 Self {
44 namespace_id,
45 content_store_id,
46 content_ref,
47 }
48 }
49
50 pub fn content_ref(&self) -> &ContentRef {
52 &self.content_ref
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct ContentAdmission {
58 content_store_id: ContentStoreId,
59 content_ref: ContentRef,
60}
61
62impl ContentAdmission {
63 pub(crate) fn for_durable_content_write(
64 content_store_id: ContentStoreId,
65 content_ref: ContentRef,
66 ) -> Self {
67 Self {
68 content_store_id,
69 content_ref,
70 }
71 }
72
73 pub(crate) fn admits(
74 &self,
75 content_store_id: &ContentStoreId,
76 content_ref: &ContentRef,
77 ) -> bool {
78 self.content_store_id == *content_store_id && self.content_ref == *content_ref
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct PreparedContent {
85 admission: ContentAdmission,
86}
87
88impl PreparedContent {
89 pub(crate) fn from_admission(admission: ContentAdmission) -> Self {
90 Self { admission }
91 }
92
93 pub fn content_ref(&self) -> &ContentRef {
95 &self.admission.content_ref
96 }
97
98 pub(crate) fn into_admission(self) -> ContentAdmission {
99 self.admission
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104struct ContentTokenPayload {
105 version: String,
106 namespace_id: NamespaceId,
107 content_store_id: ContentStoreId,
111 content_ref: ContentRef,
112 expires_at_ms: u64,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Error)]
116pub enum ContentTokenError {
117 #[error("content token is malformed")]
118 Malformed,
119 #[error("content token signature mismatch")]
120 BadSignature,
121 #[error("content token namespace mismatch")]
122 NamespaceMismatch,
123 #[error("content token content ref mismatch")]
124 ContentRefMismatch,
125 #[error("content token content store mismatch")]
126 ContentStoreMismatch,
127 #[error("content token has expired")]
128 Expired,
129 #[error("content token codec error: {0}")]
130 Codec(String),
131 #[error("content token timestamp overflow")]
132 TimeOverflow,
133}
134
135pub fn mint_content_token(
142 secret: &str,
143 receipt: &CompletedUploadReceipt,
144 now_ms: u64,
145) -> Result<String, ContentTokenError> {
146 let expires_at_ms = now_ms
147 .checked_add(CONTENT_RECEIPT_TTL_MS)
148 .ok_or(ContentTokenError::TimeOverflow)?;
149 let payload = ContentTokenPayload {
150 version: TOKEN_VERSION.to_owned(),
151 namespace_id: receipt.namespace_id.clone(),
152 content_store_id: receipt.content_store_id.clone(),
153 content_ref: receipt.content_ref.clone(),
154 expires_at_ms,
155 };
156 let payload_json = serde_json::to_vec(&payload)
157 .map_err(|error| ContentTokenError::Codec(error.to_string()))?;
158 let payload_part = base64_url(&payload_json);
159 let signature_part = base64_url(&hmac_sha256(secret.as_bytes(), payload_part.as_bytes()));
160 Ok(format!("{payload_part}.{signature_part}"))
161}
162
163pub fn verify_content_token(
164 secret: &str,
165 catalog: &VerifiedNamespaceCatalogEntry,
166 token: &ValidatedContentToken,
167 now_ms: u64,
168) -> Result<PreparedContent, ContentTokenError> {
169 let (payload_part, signature_part) = token
170 .token
171 .split_once('.')
172 .ok_or(ContentTokenError::Malformed)?;
173 let actual_signature = base64::engine::general_purpose::URL_SAFE_NO_PAD
174 .decode(signature_part)
175 .map_err(|_| ContentTokenError::Malformed)?;
176 let expected_signature = hmac_sha256(secret.as_bytes(), payload_part.as_bytes());
177 if !constant_time_eq(&actual_signature, &expected_signature) {
178 return Err(ContentTokenError::BadSignature);
179 }
180
181 let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
182 .decode(payload_part)
183 .map_err(|_| ContentTokenError::Malformed)?;
184 let payload: ContentTokenPayload = serde_json::from_slice(&payload_bytes)
185 .map_err(|error| ContentTokenError::Codec(error.to_string()))?;
186 if payload.version != TOKEN_VERSION {
187 return Err(ContentTokenError::Malformed);
188 }
189 if payload.namespace_id != *catalog.namespace_id() {
190 return Err(ContentTokenError::NamespaceMismatch);
191 }
192 if payload.content_store_id != *catalog.content_store_id() {
193 return Err(ContentTokenError::ContentStoreMismatch);
194 }
195 if payload.content_ref != token.content_ref {
196 return Err(ContentTokenError::ContentRefMismatch);
197 }
198 if payload.expires_at_ms < now_ms {
199 return Err(ContentTokenError::Expired);
200 }
201
202 let admission =
203 ContentAdmission::for_durable_content_write(payload.content_store_id, payload.content_ref);
204 Ok(PreparedContent::from_admission(admission))
205}
206
207fn base64_url(bytes: &[u8]) -> String {
208 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
209}
210
211fn hmac_sha256(key: &[u8], value: &[u8]) -> Vec<u8> {
212 use hmac::{Hmac, Mac};
213 let mut mac =
214 <Hmac<Sha256>>::new_from_slice(key).expect("HMAC should accept keys of any length");
215 mac.update(value);
216 mac.finalize().into_bytes().to_vec()
217}
218
219fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
220 if left.len() != right.len() {
221 return false;
222 }
223 let diff = left
224 .iter()
225 .zip(right)
226 .fold(0_u8, |acc, (left, right)| acc | (*left ^ *right));
227 diff == 0
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
237 fn hmac_sha256_matches_rfc_4231_vectors() {
238 let case_one = hmac_sha256(&[0x0b; 20], b"Hi There");
239 assert_eq!(
240 loonfs_api::wire::hex::hex_encode_bytes(&case_one),
241 "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
242 );
243 let case_two = hmac_sha256(b"Jefe", b"what do ya want for nothing?");
244 assert_eq!(
245 loonfs_api::wire::hex::hex_encode_bytes(&case_two),
246 "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
247 );
248 }
249 use super::{mint_content_token, verify_content_token, CompletedUploadReceipt};
250 use crate::namespace::catalog::VerifiedNamespaceCatalogEntry;
251 use loonfs_api::v0::ValidatedContentToken;
252 use loonfs_api::wire::control::HeadState;
253 use loonfs_api::{ContentId, ContentRef, ContentStoreId, NamespaceId};
254
255 const CONTENT_STORE: &str = "cs_00000000000000000000000000000001";
256
257 fn catalog_entry(
258 namespace_id: NamespaceId,
259 content_store: &str,
260 ) -> VerifiedNamespaceCatalogEntry {
261 VerifiedNamespaceCatalogEntry::from_head(&HeadState::initial(
262 namespace_id,
263 ContentStoreId::parse(content_store).expect("content store id"),
264 ))
265 }
266
267 fn receipt(
268 namespace_id: &NamespaceId,
269 content_store: &str,
270 content_ref: &ContentRef,
271 ) -> CompletedUploadReceipt {
272 CompletedUploadReceipt::for_completed_session(
273 namespace_id.clone(),
274 ContentStoreId::parse(content_store).expect("content store id"),
275 content_ref.clone(),
276 )
277 }
278
279 #[test]
280 fn token_round_trips_and_admits_matching_content() {
281 let namespace = NamespaceId::parse("demo").expect("namespace");
282 let content = ContentRef::blob_v1(ContentId::generate(), b"hello");
283 let token = mint_content_token(
284 "secret",
285 &receipt(&namespace, CONTENT_STORE, &content),
286 1_000,
287 )
288 .expect("mint");
289 let token = ValidatedContentToken {
290 content_ref: content.clone(),
291 token,
292 };
293 let catalog = catalog_entry(namespace, CONTENT_STORE);
294
295 let prepared =
296 verify_content_token("secret", &catalog, &token, 1_000).expect("verify token");
297
298 assert_eq!(prepared.content_ref(), &content);
299 assert!(prepared
300 .into_admission()
301 .admits(catalog.content_store_id(), &content));
302 }
303
304 #[test]
305 fn verified_token_admission_does_not_decay_after_token_expiry() {
306 let namespace = NamespaceId::parse("demo").expect("namespace");
307 let content = ContentRef::blob_v1(ContentId::generate(), b"hello");
308 let issued_at_ms = 1_000;
309 let token = mint_content_token(
310 "secret",
311 &receipt(&namespace, CONTENT_STORE, &content),
312 issued_at_ms,
313 )
314 .expect("mint");
315 let token = ValidatedContentToken {
316 content_ref: content.clone(),
317 token,
318 };
319 let catalog = catalog_entry(namespace, CONTENT_STORE);
320 let prepared = verify_content_token(
321 "secret",
322 &catalog,
323 &token,
324 issued_at_ms + CONTENT_RECEIPT_TTL_MS,
325 )
326 .expect("verify token before expiry");
327
328 assert!(prepared
331 .into_admission()
332 .admits(catalog.content_store_id(), &content));
333 }
334
335 #[test]
336 fn token_rejects_wrong_secret_namespace_store_content_and_expiry() {
337 let namespace = NamespaceId::parse("demo").expect("namespace");
338 let other_namespace = NamespaceId::parse("other").expect("namespace");
339 let other_store = "cs_00000000000000000000000000000002";
340 let content = ContentRef::blob_v1(ContentId::generate(), b"hello");
341 let other_content = ContentRef::blob_v1(ContentId::generate(), b"other");
342 let issued_at_ms = 1_000;
343 let token = mint_content_token(
344 "secret",
345 &receipt(&namespace, CONTENT_STORE, &content),
346 issued_at_ms,
347 )
348 .expect("mint");
349 let token = ValidatedContentToken {
350 content_ref: content.clone(),
351 token,
352 };
353 let catalog = catalog_entry(namespace.clone(), CONTENT_STORE);
354 let other_catalog = catalog_entry(other_namespace, CONTENT_STORE);
355
356 assert!(verify_content_token("other", &catalog, &token, 1_000).is_err());
357 assert_eq!(
358 verify_content_token("secret", &other_catalog, &token, 1_000),
359 Err(ContentTokenError::NamespaceMismatch),
360 "sharing a content store must not share token authorization"
361 );
362 assert_eq!(
363 verify_content_token(
364 "secret",
365 &catalog_entry(namespace, other_store),
366 &token,
367 1_000
368 ),
369 Err(ContentTokenError::ContentStoreMismatch),
370 "a receipt names the store its content is durable in"
371 );
372 assert!(verify_content_token(
373 "secret",
374 &catalog,
375 &ValidatedContentToken {
376 content_ref: other_content,
377 token: token.token.clone(),
378 },
379 1_000,
380 )
381 .is_err());
382 assert!(verify_content_token(
383 "secret",
384 &catalog,
385 &token,
386 issued_at_ms + CONTENT_RECEIPT_TTL_MS + 1
387 )
388 .is_err());
389 }
390}