Skip to main content

loonfs_core/storage/
content_admission.rs

1//! Producer-only content preparation proofs and short-lived wire tokens.
2//!
3//! A content token is an upload's receipt: it is minted only from a durable
4//! upload session already observed in its terminal completed state, and it
5//! says that the content it names is durable and verified so a later commit
6//! does not have to look. A serving session verifies token expiry once when
7//! turning a signed wire token into an opaque [`PreparedContent`]. Its
8//! internal admission then remains valid for the process lifetime: it records
9//! accepted authoritative evidence that the identified content is durable,
10//! rather than carrying another clock.
11
12use 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/// Everything a receipt attests, read out of a durable upload session that
24/// was observed in its terminal completed state.
25///
26/// The type has no public constructor on purpose. The only way to hold one
27/// is for the upload protocol to have loaded a completed session from the
28/// object store, so a receipt can never be minted from an in-memory
29/// expectation, a provider response, or a completion still in flight.
30#[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    /// Returns the completed session's verified content reference.
51    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/// Opaque evidence that a content reference was prepared for publication.
83#[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    /// Returns the prepared content reference.
94    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    /// Where the content lives. Namespaces may share a content store, so
108    /// binding both ends keeps a receipt from admitting anything outside the
109    /// exact pairing the completed session was for.
110    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
135/// Mints one receipt for a completed upload.
136///
137/// The receipt is short-lived because it does not have to be durable: the
138/// completed session is, so reading the session's status mints another one.
139/// That is what makes a lost publish response cost a request instead of a
140/// retransfer.
141pub 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    /// RFC 4231 test vectors pin the HMAC-SHA256 construction across
235    /// implementation changes.
236    #[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        // The prepared proof carries no clock: expiry was the token's
329        // concern, checked exactly once by the verification above.
330        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}