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