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 #[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 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}