Skip to main content

heddle_thread_api/
credentials.rs

1//! Caller-supplied request credentials, independent of repository storage.
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use api::{
5    heddle::api::common::{CallContext, RequestProof},
6    v2::MethodDescriptor,
7};
8use crypto::{Ed25519Signer, Signer};
9#[cfg(any(feature = "native", feature = "root-attachment"))]
10use prost::Message;
11
12use crate::transport::{Authorize, Error};
13
14/// Caller-selected authority. No variant discovers credentials or mints a key.
15/// Bearer-only service and anonymous tiers stay distinct from signed callers.
16/// Public-readable methods still carry proof for Signed callers. Only the host
17/// may classify a verified bearer as anonymous; this client never strips an
18/// account credential or retries it as public after authorization fails.
19#[derive(Clone)]
20pub enum Credentials {
21    Public,
22    #[cfg(any(feature = "native", feature = "root-attachment"))]
23    OwnedDevice(OwnedDeviceCredentials),
24    Bearer {
25        /// Raw serialized Biscuit, directly from IssuedCredential.biscuit.
26        biscuit: Vec<u8>,
27        grant_envelope: Vec<u8>,
28    },
29    Signed {
30        signer: std::sync::Arc<Ed25519Signer>,
31        /// Empty during a public, key-proved registration ceremony.
32        biscuit: Vec<u8>,
33        grant_envelope: Vec<u8>,
34    },
35}
36
37/// Prepared owned-device credential; the public proof supplies the exact sealed
38/// Biscuit and mint selector. The receiver independently pins account authority.
39#[cfg(any(feature = "native", feature = "root-attachment"))]
40#[derive(Clone)]
41pub struct OwnedDeviceCredentials {
42    signer: std::sync::Arc<Ed25519Signer>,
43    biscuit: Vec<u8>,
44    mint_root: Vec<u8>,
45    authority: Vec<u8>,
46}
47impl Credentials {
48    #[cfg(any(feature = "native", feature = "root-attachment"))]
49    pub fn owned_device(
50        signer: std::sync::Arc<Ed25519Signer>,
51        authority: &[u8],
52    ) -> Result<Self, Error> {
53        if authority.is_empty() || authority.len() > 64 * 1024 {
54            return Err(Error::Protocol("owned-device authority proof bound"));
55        }
56        let proof =
57            api::mint_root_association::decode_thread_control_authority_for_verification(authority)
58                .map_err(|error| Error::Io(error.to_string()))?;
59        if proof.format != 1 || proof.encode_to_vec() != authority {
60            return Err(Error::Protocol("canonical owned-device authority required"));
61        }
62        if proof.mint_root_public_key.len() != 32
63            || proof.sealed_biscuit.is_empty()
64            || proof.sealed_biscuit.len() > 64 * 1024
65        {
66            return Err(Error::Protocol("owned-device mint root or Biscuit bound"));
67        }
68        use base64::Engine as _;
69        let biscuit = base64::engine::general_purpose::URL_SAFE
70            .encode(&proof.sealed_biscuit)
71            .into_bytes();
72        if biscuit.len() > 64 * 1024 {
73            return Err(Error::Protocol("encoded owned-device Biscuit bound"));
74        }
75        Ok(Self::OwnedDevice(OwnedDeviceCredentials {
76            signer,
77            biscuit,
78            mint_root: proof.mint_root_public_key,
79            authority: authority.to_vec(),
80        }))
81    }
82}
83
84impl Authorize for Credentials {
85    async fn context(
86        &self,
87        method: &'static MethodDescriptor,
88        body: &[u8],
89    ) -> Result<CallContext, Error> {
90        let operation = method.client_operation_id(body)?.unwrap_or_default();
91        if method.client_operation_id_required && operation.is_empty() {
92            return Err(Error::Protocol("request requires an operation ID"));
93        }
94        let (biscuit, grant_envelope, signer) = match self {
95            Self::Public => (&[][..], &[][..], None),
96            #[cfg(any(feature = "native", feature = "root-attachment"))]
97            Self::OwnedDevice(value) => (value.biscuit.as_slice(), &[][..], Some(&value.signer)),
98            Self::Bearer {
99                biscuit,
100                grant_envelope,
101            } => {
102                if biscuit.is_empty() {
103                    return Err(Error::Protocol("bearer credential cannot be empty"));
104                }
105                (biscuit.as_slice(), grant_envelope.as_slice(), None)
106            }
107            Self::Signed {
108                signer,
109                biscuit,
110                grant_envelope,
111            } => (biscuit.as_slice(), grant_envelope.as_slice(), Some(signer)),
112        };
113        let mut context = CallContext {
114            client_operation_id: operation.into(),
115            bearer_capability: biscuit.to_vec(),
116            bearer_grant_envelope: grant_envelope.to_vec(),
117            ..Default::default()
118        };
119        #[cfg(any(feature = "native", feature = "root-attachment"))]
120        if let Self::OwnedDevice(value) = self {
121            context.bearer_authority_key_selector = value.mint_root.clone();
122            context.bearer_authority_proof = value.authority.clone();
123        }
124        let Some(signer) = signer else {
125            return Ok(context);
126        };
127        let timestamp = SystemTime::now()
128            .duration_since(UNIX_EPOCH)
129            .map_err(|e| Error::Io(e.to_string()))?
130            .as_millis();
131        let timestamp = i64::try_from(timestamp).map_err(|_| Error::Protocol("invalid clock"))?;
132        let identity = format!("principal:device-key:{}", hex::encode(signer.public_key()));
133        // UUID v4 is an OS-random nonce; there is no authority generation here.
134        let nonce = uuid::Uuid::new_v4().as_bytes().to_vec();
135        let signature = signer
136            .sign(&api::signing::unary_bytes(
137                &identity,
138                method.path,
139                timestamp,
140                &nonce,
141                body,
142            ))
143            .map_err(|e| Error::Io(e.to_string()))?;
144        context.request_proof = Some(RequestProof {
145            algorithm: "ed25519".into(),
146            signing_identity: identity,
147            timestamp_millis: timestamp,
148            nonce,
149            signature,
150        });
151        Ok(context)
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use std::{
158        future::Future,
159        pin::pin,
160        task::{Context, Poll, Waker},
161    };
162
163    use api::v2::client::Rpc;
164    use prost::Message;
165
166    use super::*;
167
168    #[test]
169    fn request_context_verifies_without_a_transport_rewriting_its_identity() {
170        let signer = Ed25519Signer::from_seed(&[17; 32]).expect("fixture signer");
171        let key: [u8; 32] = signer.public_key().try_into().expect("key");
172        let credentials = Credentials::Signed {
173            signer: std::sync::Arc::new(signer),
174            biscuit: vec![0, 255, 7, 128],
175            grant_envelope: b"grant fixture".to_vec(),
176        };
177        let body = crate::contract::RenameThreadRequest {
178            client_operation_id: "same-durable-operation".into(),
179            name: "renamed".into(),
180            ..Default::default()
181        }
182        .encode_to_vec();
183        let method = crate::rpc::ThreadServiceRenameThread::METHOD;
184        let context = ready(credentials.context(method, &body)).expect("context");
185        assert_eq!(
186            context.bearer_capability,
187            [0, 255, 7, 128],
188            "Biscuit stays raw binary"
189        );
190        assert_eq!(context.bearer_grant_envelope, b"grant fixture");
191        let time = context
192            .request_proof
193            .as_ref()
194            .expect("proof")
195            .timestamp_millis;
196        crate::request_proof::verify(&context, method, &body, &key, time)
197            .expect("context independently binds the request operation identity");
198        let again = ready(credentials.context(method, &body)).expect("fresh attempt");
199        assert_eq!(again.client_operation_id, context.client_operation_id);
200        assert_ne!(
201            again.request_proof.as_ref().expect("retry proof").nonce,
202            context
203                .request_proof
204                .as_ref()
205                .expect("original proof")
206                .nonce
207        );
208        assert!(
209            crate::request_proof::verify(
210                &context,
211                crate::rpc::ThreadServiceChangeLifecycle::METHOD,
212                &body,
213                &key,
214                time
215            )
216            .is_err()
217        );
218        let changed = crate::contract::RenameThreadRequest {
219            client_operation_id: "same-durable-operation".into(),
220            name: "different intent".into(),
221            ..Default::default()
222        }
223        .encode_to_vec();
224        assert!(crate::request_proof::verify(&context, method, &changed, &key, time).is_err());
225        assert!(crate::request_proof::verify(&context, method, &body, &[99; 32], time).is_err());
226    }
227
228    #[test]
229    fn public_and_bearer_tiers_preserve_operation_identity_without_inventing_proof() {
230        let method = crate::rpc::IdentityServiceCompleteEmailVerification::METHOD;
231        let body = crate::contract::CompleteEmailVerificationRequest {
232            client_operation_id: "mailbox-proof".into(),
233            ..Default::default()
234        }
235        .encode_to_vec();
236        for credentials in [
237            Credentials::Public,
238            Credentials::Bearer {
239                biscuit: vec![0, 128, 255],
240                grant_envelope: vec![17],
241            },
242        ] {
243            let context = ready(credentials.context(method, &body)).expect("context");
244            assert_eq!(context.client_operation_id, "mailbox-proof");
245            assert!(context.request_proof.is_none());
246            assert!(context.bearer_proof.is_none());
247            if matches!(credentials, Credentials::Bearer { .. }) {
248                assert_eq!(context.bearer_capability, [0, 128, 255]);
249                assert_eq!(context.bearer_grant_envelope, [17]);
250            } else {
251                assert!(context.bearer_capability.is_empty());
252            }
253        }
254        let empty = Credentials::Bearer {
255            biscuit: vec![],
256            grant_envelope: vec![],
257        };
258        assert!(matches!(
259            ready(empty.context(method, &body)),
260            Err(Error::Protocol("bearer credential cannot be empty"))
261        ));
262        assert!(matches!(
263            ready(Credentials::Public.context(method, &[])),
264            Err(Error::Protocol("request requires an operation ID"))
265        ));
266    }
267
268    #[test]
269    fn public_catalog_preserves_unsigned_and_account_proof_boundaries() {
270        let method = crate::rpc::WorkspaceServiceObserveCatalog::METHOD;
271        assert_eq!(
272            method.signing_tier,
273            api::heddle::api::common::SigningTier::ProofIfAuthenticated
274        );
275        let mut request = crate::contract::ObserveCatalogRequest::default();
276        crate::observation::ObservationRequest::options_mut(&mut request).mode =
277            crate::contract::ObservationMode::Once as i32;
278        let body = request.encode_to_vec();
279        let public = ready(Credentials::Public.context(method, &body)).expect("public context");
280        assert!(public.request_proof.is_none());
281        assert!(public.bearer_capability.is_empty());
282        let bearer = ready(
283            Credentials::Bearer {
284                biscuit: vec![7],
285                grant_envelope: vec![],
286            }
287            .context(method, &body),
288        )
289        .expect("opaque bearer context");
290        assert_eq!(bearer.bearer_capability, [7]);
291        assert!(
292            bearer.request_proof.is_none(),
293            "host classifies bearer authority"
294        );
295        let signer = Ed25519Signer::from_seed(&[18; 32]).expect("fixture signer");
296        let key = signer.public_key().try_into().expect("public key");
297        let account = ready(
298            Credentials::Signed {
299                signer: std::sync::Arc::new(signer),
300                biscuit: vec![8],
301                grant_envelope: vec![],
302            }
303            .context(method, &body),
304        )
305        .expect("signed public read");
306        assert_eq!(account.bearer_capability, [8]);
307        let now = account
308            .request_proof
309            .as_ref()
310            .expect("account still proves key")
311            .timestamp_millis;
312        crate::request_proof::verify(&account, method, &body, &key, now)
313            .expect("valid account proof");
314        let mut missing = account;
315        missing.request_proof = None;
316        assert!(
317            matches!(
318                crate::request_proof::verify(&missing, method, &body, &key, now),
319                Err(Error::Protocol("invalid or expired request PoP"))
320            ),
321            "strict verifier never downgrades account reads"
322        );
323        let event = crate::contract::CatalogEvent {
324            frame: None,
325            payload: Some(crate::contract::catalog_event::Payload::Removal(
326                Default::default(),
327            )),
328        };
329        assert!(crate::observation::ObservedEvent::is_removal(&event));
330    }
331
332    fn ready<T>(future: impl Future<Output = T>) -> T {
333        match pin!(future).poll(&mut Context::from_waker(Waker::noop())) {
334            Poll::Ready(result) => result,
335            Poll::Pending => panic!("in-memory credential construction cannot wait on I/O"),
336        }
337    }
338}