Skip to main content

heddle_thread_api/
root_attachment.rs

1//! Portable evidence that a root-derived credential holder owns an Iroh endpoint.
2//! The credential is private verification input, never part of the public record.
3//! An attachment establishes a locator binding, not permission for future work:
4//! verify each current request's complete Biscuit chain and revocations separately.
5use 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
17/// Evidence verified at the caller's supplied time. Keep the original credential
18/// private when retaining this binding, and reverify it when using the evidence
19/// again. This value is not a reusable authorization permit.
20pub 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
39/// Sign a public approval commitment before receiving the private credential.
40/// Both physical endpoint and credential subject prove possession; neither
41/// signature establishes root authority. Hosts verify the delegated Biscuit too.
42pub 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
82/// Check possession against the exact stored public approval. This alone is not
83/// authority: the host separately verifies the private credential and account.
84pub 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
134/// `trusted_roots` must be independently attached to `expected_account_id`.
135/// The binding never supplies its own trust list or authorizes a future call.
136pub 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    // Check the final admitted second against every ancestor time caveat, not
193    // only the authority's expiry fact. The evidence is also checked at `now`;
194    // this never replaces time/revocation checks on subsequent actual calls.
195    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}