1use 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#[derive(Clone)]
20pub enum Credentials {
21 Public,
22 #[cfg(any(feature = "native", feature = "root-attachment"))]
23 OwnedDevice(OwnedDeviceCredentials),
24 Bearer {
25 biscuit: Vec<u8>,
27 grant_envelope: Vec<u8>,
28 },
29 Signed {
30 signer: std::sync::Arc<Ed25519Signer>,
31 biscuit: Vec<u8>,
33 grant_envelope: Vec<u8>,
34 },
35}
36
37#[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 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 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}