heddle_thread_api/
root_attachment.rs1use base64::{Engine as _, engine::general_purpose::URL_SAFE};
6use biscuit_verifier::PublicKey;
7use chrono::{DateTime, Utc};
8use crypto::{Ed25519Signer, Signer};
9use prost::Message;
10
11use crate::{contract::*, transport::Error};
12
13pub const FORMAT: &str = "heddle.root-attachment.v2";
14const DOMAIN: &[u8] = b"heddle.root-attachment.v2\0";
15const MAX_CREDENTIAL: usize = 64 * 1024;
16
17pub struct VerifiedAttachment {
21 binding: RootAttachmentBinding,
22 credential_revocation_ids: Vec<String>,
23}
24impl VerifiedAttachment {
25 pub fn credential_revocation_ids(&self) -> &[String] {
26 &self.credential_revocation_ids
27 }
28 pub fn root_public_key(&self) -> &[u8] {
29 &self.binding.root_public_key
30 }
31 pub fn subject_public_key(&self) -> &[u8] {
32 &self.binding.subject_public_key
33 }
34 pub fn expires_at_unix_seconds(&self) -> i64 {
35 self.binding.expires_at_unix_seconds
36 }
37}
38
39pub fn sign_binding(
43 subject: &impl Signer,
44 endpoint: &impl Signer,
45 binding: RootAttachmentBinding,
46) -> Result<RootAttachment, Error> {
47 validate_binding(&binding)?;
48 if binding.subject_public_key != subject.public_key()
49 || binding
50 .device
51 .as_ref()
52 .is_none_or(|device| device.public_key != endpoint.public_key())
53 {
54 return Err(Error::Protocol(
55 "attachment signing keys do not match approval",
56 ));
57 }
58 let canonical_record = binding.encode_to_vec();
59 let payload = statement(&canonical_record);
60 let mut signatures = vec![RecordSignature {
61 public_key: subject.public_key().to_vec(),
62 signature: subject.sign(&payload).map_err(io)?,
63 }];
64 if endpoint.public_key() != subject.public_key() {
65 signatures.push(RecordSignature {
66 public_key: endpoint.public_key().to_vec(),
67 signature: endpoint.sign(&payload).map_err(io)?,
68 });
69 }
70 Ok(RootAttachment {
71 root_public_key: binding.root_public_key,
72 subject_public_key: binding.subject_public_key,
73 device: binding.device,
74 attachment: Some(SignedRecord {
75 format: FORMAT.into(),
76 canonical_record,
77 signatures,
78 }),
79 })
80}
81
82pub fn verify_possession(
85 attachment: &RootAttachment,
86 expected: &RootAttachmentBinding,
87) -> Result<(), Error> {
88 validate_binding(expected)?;
89 let record = attachment
90 .attachment
91 .as_ref()
92 .ok_or(Error::Protocol("missing endpoint attachment proof"))?;
93 if record.format != FORMAT
94 || record.canonical_record != expected.encode_to_vec()
95 || attachment.root_public_key != expected.root_public_key
96 || attachment.subject_public_key != expected.subject_public_key
97 || attachment.device != expected.device
98 {
99 return Err(Error::Protocol("attachment differs from approved binding"));
100 }
101 let endpoint = expected
102 .device
103 .as_ref()
104 .ok_or(Error::Protocol("missing endpoint"))?;
105 let keys = if endpoint.public_key == expected.subject_public_key {
106 vec![expected.subject_public_key.as_slice()]
107 } else {
108 vec![
109 expected.subject_public_key.as_slice(),
110 endpoint.public_key.as_slice(),
111 ]
112 };
113 if record.signatures.len() != keys.len() {
114 return Err(Error::Protocol(
115 "attachment requires exact endpoint and subject signatures",
116 ));
117 }
118 for (signature, key) in record.signatures.iter().zip(keys) {
119 if signature.public_key != key {
120 return Err(Error::Protocol(
121 "attachment signer differs from expected key",
122 ));
123 }
124 Ed25519Signer::verify_with_public_key(
125 &statement(&record.canonical_record),
126 key,
127 &signature.signature,
128 )
129 .map_err(|_| Error::Protocol("invalid endpoint attachment possession signature"))?;
130 }
131 Ok(())
132}
133
134pub fn verify(
137 attachment: &RootAttachment,
138 original_credential: &[u8],
139 trusted_roots: &[PublicKey],
140 expected_account_id: &str,
141 expected_device: &EndpointRef,
142 now: DateTime<Utc>,
143) -> Result<VerifiedAttachment, Error> {
144 let record = attachment
145 .attachment
146 .as_ref()
147 .ok_or(Error::Protocol("missing endpoint attachment proof"))?;
148 if record.format != FORMAT || record.canonical_record.len() > 1024 {
149 return Err(Error::Protocol(
150 "unsupported or oversized endpoint attachment",
151 ));
152 }
153 let binding = RootAttachmentBinding::decode(record.canonical_record.as_slice())?;
154 validate(&binding, original_credential)?;
155 if binding.encode_to_vec() != record.canonical_record
156 || binding.root_public_key != attachment.root_public_key
157 || binding.subject_public_key != attachment.subject_public_key
158 || binding.device != attachment.device
159 || binding.account_id != expected_account_id
160 || binding.device.as_ref() != Some(expected_device)
161 || now.timestamp() < binding.not_before_unix_seconds
162 || now.timestamp() >= binding.expires_at_unix_seconds
163 {
164 return Err(Error::Protocol(
165 "endpoint attachment binding mismatch or expired",
166 ));
167 }
168 let root = trusted_roots
169 .iter()
170 .find(|root| root.to_bytes() == binding.root_public_key)
171 .ok_or(Error::Protocol("endpoint attachment root is not trusted"))?;
172 let facts = biscuit_verifier::verify_any_at_with_resource(
173 &URL_SAFE.encode(original_credential),
174 None,
175 &[*root],
176 &[],
177 "ObserveIdentity",
178 None,
179 now,
180 )
181 .map_err(|_| Error::Protocol("endpoint attachment credential is not valid"))?;
182 if facts
183 .subject_user_id()
184 .is_some_and(|account| account.to_string() != expected_account_id)
185 || facts.cnf.as_deref() != Some(hex::encode(&binding.subject_public_key).as_str())
186 || (facts.exp != 0 && binding.expires_at_unix_seconds as u64 > facts.exp)
187 {
188 return Err(Error::Protocol(
189 "endpoint attachment exceeds credential subject or lifetime",
190 ));
191 }
192 let last = DateTime::from_timestamp(binding.expires_at_unix_seconds - 1, 0)
196 .ok_or(Error::Protocol("invalid endpoint attachment lifetime"))?;
197 biscuit_verifier::verify_any_at_with_resource(
198 &URL_SAFE.encode(original_credential),
199 None,
200 &[*root],
201 &[],
202 "ObserveIdentity",
203 None,
204 last,
205 )
206 .map_err(|_| Error::Protocol("endpoint attachment outlives credential attenuation"))?;
207 verify_possession(attachment, &binding)?;
208 Ok(VerifiedAttachment {
209 binding,
210 credential_revocation_ids: facts.revocation_identities().map(str::to_owned).collect(),
211 })
212}
213
214fn validate_binding(binding: &RootAttachmentBinding) -> Result<(), Error> {
215 if binding.format_version != 2
216 || uuid::Uuid::parse_str(&binding.account_id).map_or(true, |id| id.is_nil())
217 || binding.pairing_challenge.len() != 32
218 || binding.root_public_key.len() != 32
219 || binding.subject_public_key.len() != 32
220 || binding.device.as_ref().is_none_or(|device| {
221 device.kind != EndpointKind::Device as i32 || device.public_key.len() != 32
222 })
223 || binding.credential_digest.len() != 32
224 || binding.not_before_unix_seconds <= 0
225 || binding.expires_at_unix_seconds <= binding.not_before_unix_seconds
226 {
227 return Err(Error::Protocol("invalid endpoint attachment binding"));
228 }
229 Ok(())
230}
231fn validate(binding: &RootAttachmentBinding, credential: &[u8]) -> Result<(), Error> {
232 validate_binding(binding)?;
233 if credential.is_empty()
234 || credential.len() > MAX_CREDENTIAL
235 || binding.credential_digest != blake3::hash(credential).as_bytes()
236 {
237 return Err(Error::Protocol("attachment credential digest mismatch"));
238 }
239 Ok(())
240}
241fn statement(canonical: &[u8]) -> Vec<u8> {
242 [DOMAIN, canonical].concat()
243}
244fn io(error: impl std::fmt::Display) -> Error {
245 Error::Io(error.to_string())
246}