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        // Validate creator bindings before request signing. Semantic support is
120        // negotiated against the endpoint description, never per call.
121        crate::hybrid::native_start_thread(method.path, body).map_err(Error::Protocol)?;
122        #[cfg(any(feature = "native", feature = "root-attachment"))]
123        if let Self::OwnedDevice(value) = self {
124            context.bearer_authority_key_selector = value.mint_root.clone();
125            context.bearer_authority_proof = value.authority.clone();
126        }
127        let Some(signer) = signer else {
128            return Ok(context);
129        };
130        let timestamp = SystemTime::now()
131            .duration_since(UNIX_EPOCH)
132            .map_err(|e| Error::Io(e.to_string()))?
133            .as_millis();
134        let timestamp = i64::try_from(timestamp).map_err(|_| Error::Protocol("invalid clock"))?;
135        let identity = format!("principal:device-key:{}", hex::encode(signer.public_key()));
136        // UUID v4 is an OS-random nonce; there is no authority generation here.
137        let nonce = uuid::Uuid::new_v4().as_bytes().to_vec();
138        let signature = signer
139            .sign(&api::signing::unary_bytes(
140                &identity,
141                method.path,
142                timestamp,
143                &nonce,
144                body,
145            ))
146            .map_err(|e| Error::Io(e.to_string()))?;
147        context.request_proof = Some(RequestProof {
148            algorithm: "ed25519".into(),
149            signing_identity: identity,
150            timestamp_millis: timestamp,
151            nonce,
152            signature,
153        });
154        Ok(context)
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use std::{
161        future::Future,
162        pin::pin,
163        task::{Context, Poll, Waker},
164    };
165
166    use api::v2::client::Rpc;
167    use prost::Message;
168
169    use super::*;
170
171    #[test]
172    fn request_context_verifies_without_a_transport_rewriting_its_identity() {
173        let signer = Ed25519Signer::from_seed(&[17; 32]).expect("fixture signer");
174        let key: [u8; 32] = signer.public_key().try_into().expect("key");
175        let credentials = Credentials::Signed {
176            signer: std::sync::Arc::new(signer),
177            biscuit: vec![0, 255, 7, 128],
178            grant_envelope: b"grant fixture".to_vec(),
179        };
180        let body = crate::contract::RenameThreadRequest {
181            client_operation_id: "same-durable-operation".into(),
182            name: "renamed".into(),
183            ..Default::default()
184        }
185        .encode_to_vec();
186        let method = crate::rpc::ThreadServiceRenameThread::METHOD;
187        let context = ready(credentials.context(method, &body)).expect("context");
188        assert_eq!(
189            context.bearer_capability,
190            [0, 255, 7, 128],
191            "Biscuit stays raw binary"
192        );
193        assert_eq!(context.bearer_grant_envelope, b"grant fixture");
194        let time = context
195            .request_proof
196            .as_ref()
197            .expect("proof")
198            .timestamp_millis;
199        crate::request_proof::verify(&context, method, &body, &key, time)
200            .expect("context independently binds the request operation identity");
201        let again = ready(credentials.context(method, &body)).expect("fresh attempt");
202        assert_eq!(again.client_operation_id, context.client_operation_id);
203        assert_ne!(
204            again.request_proof.as_ref().expect("retry proof").nonce,
205            context
206                .request_proof
207                .as_ref()
208                .expect("original proof")
209                .nonce
210        );
211        assert!(
212            crate::request_proof::verify(
213                &context,
214                crate::rpc::ThreadServiceChangeLifecycle::METHOD,
215                &body,
216                &key,
217                time
218            )
219            .is_err()
220        );
221        let changed = crate::contract::RenameThreadRequest {
222            client_operation_id: "same-durable-operation".into(),
223            name: "different intent".into(),
224            ..Default::default()
225        }
226        .encode_to_vec();
227        assert!(crate::request_proof::verify(&context, method, &changed, &key, time).is_err());
228        assert!(crate::request_proof::verify(&context, method, &body, &[99; 32], time).is_err());
229    }
230
231    #[test]
232    fn public_and_bearer_tiers_preserve_operation_identity_without_inventing_proof() {
233        let method = crate::rpc::IdentityServiceCompleteEmailVerification::METHOD;
234        let body = crate::contract::CompleteEmailVerificationRequest {
235            client_operation_id: "mailbox-proof".into(),
236            ..Default::default()
237        }
238        .encode_to_vec();
239        for credentials in [
240            Credentials::Public,
241            Credentials::Bearer {
242                biscuit: vec![0, 128, 255],
243                grant_envelope: vec![17],
244            },
245        ] {
246            let context = ready(credentials.context(method, &body)).expect("context");
247            assert_eq!(context.client_operation_id, "mailbox-proof");
248            assert!(context.request_proof.is_none());
249            assert!(context.bearer_proof.is_none());
250            if matches!(credentials, Credentials::Bearer { .. }) {
251                assert_eq!(context.bearer_capability, [0, 128, 255]);
252                assert_eq!(context.bearer_grant_envelope, [17]);
253            } else {
254                assert!(context.bearer_capability.is_empty());
255            }
256        }
257        let empty = Credentials::Bearer {
258            biscuit: vec![],
259            grant_envelope: vec![],
260        };
261        assert!(matches!(
262            ready(empty.context(method, &body)),
263            Err(Error::Protocol("bearer credential cannot be empty"))
264        ));
265        assert!(matches!(
266            ready(Credentials::Public.context(method, &[])),
267            Err(Error::Protocol("request requires an operation ID"))
268        ));
269    }
270
271    #[test]
272    fn public_catalog_preserves_unsigned_and_account_proof_boundaries() {
273        let method = crate::rpc::WorkspaceServiceObserveCatalog::METHOD;
274        assert_eq!(
275            method.signing_tier,
276            api::heddle::api::common::SigningTier::ProofIfAuthenticated
277        );
278        let mut request = crate::contract::ObserveCatalogRequest::default();
279        crate::observation::ObservationRequest::options_mut(&mut request).mode =
280            crate::contract::ObservationMode::Once as i32;
281        let body = request.encode_to_vec();
282        let public = ready(Credentials::Public.context(method, &body)).expect("public context");
283        assert!(public.request_proof.is_none());
284        assert!(public.bearer_capability.is_empty());
285        let bearer = ready(
286            Credentials::Bearer {
287                biscuit: vec![7],
288                grant_envelope: vec![],
289            }
290            .context(method, &body),
291        )
292        .expect("opaque bearer context");
293        assert_eq!(bearer.bearer_capability, [7]);
294        assert!(
295            bearer.request_proof.is_none(),
296            "host classifies bearer authority"
297        );
298        let signer = Ed25519Signer::from_seed(&[18; 32]).expect("fixture signer");
299        let key = signer.public_key().try_into().expect("public key");
300        let account = ready(
301            Credentials::Signed {
302                signer: std::sync::Arc::new(signer),
303                biscuit: vec![8],
304                grant_envelope: vec![],
305            }
306            .context(method, &body),
307        )
308        .expect("signed public read");
309        assert_eq!(account.bearer_capability, [8]);
310        let now = account
311            .request_proof
312            .as_ref()
313            .expect("account still proves key")
314            .timestamp_millis;
315        crate::request_proof::verify(&account, method, &body, &key, now)
316            .expect("valid account proof");
317        let mut missing = account;
318        missing.request_proof = None;
319        assert!(
320            matches!(
321                crate::request_proof::verify(&missing, method, &body, &key, now),
322                Err(Error::Protocol("invalid or expired request PoP"))
323            ),
324            "strict verifier never downgrades account reads"
325        );
326        let event = crate::contract::CatalogEvent {
327            frame: None,
328            payload: Some(crate::contract::catalog_event::Payload::Removal(
329                Default::default(),
330            )),
331        };
332        assert!(crate::observation::ObservedEvent::is_removal(&event));
333    }
334
335    fn ready<T>(future: impl Future<Output = T>) -> T {
336        match pin!(future).poll(&mut Context::from_waker(Waker::noop())) {
337            Poll::Ready(result) => result,
338            Poll::Pending => panic!("in-memory credential construction cannot wait on I/O"),
339        }
340    }
341}