Skip to main content

heddle_thread_api/
authority_admission.rs

1#[path = "authority_batches.rs"]
2mod batching;
3pub use batching::{AuthorityBatches, batches};
4// Hosted original-author receipts require receiver-owned executor trust.
5pub use crypto::thread_authority_admission::SignedAuthorityAdmission;
6use crypto::{Signer, thread_operation::SignedOperation};
7use heddle_object_model::object::thread_authority_admission::FORMAT;
8pub use heddle_object_model::object::{
9    thread_authority_admission::ThreadAuthorityAdmission,
10    thread_replication::integration::TrustedHostedExecutor,
11};
12
13use crate::{contract as wire, transport::Error};
14
15/// Issuers persist this exact statement with original bytes at first admission.
16pub fn sign(
17    value: &ThreadAuthorityAdmission,
18    signer: &impl Signer,
19) -> Result<wire::SignedRecord, Error> {
20    encode(
21        &SignedAuthorityAdmission::sign(value, signer)
22            .map_err(|_| Error::Protocol("authority admission signing failed"))?,
23    )
24}
25/// Verify original and executor signatures against independently admitted trust.
26pub fn verify(
27    receipt: &wire::SignedRecord,
28    original: &SignedOperation,
29    trust: &TrustedHostedExecutor,
30) -> Result<ThreadAuthorityAdmission, Error> {
31    decode(receipt)?.verify(original, trust).map_err(|_| {
32        Error::Protocol("authority receipt differs from original operation or pinned executor")
33    })
34}
35/// Signature-only decoding for sidecar routing; this does not admit anything.
36pub fn verify_signature(receipt: &wire::SignedRecord) -> Result<ThreadAuthorityAdmission, Error> {
37    decode(receipt)?
38        .verify_signature()
39        .map_err(|_| Error::Protocol("invalid authority admission signature"))
40}
41pub fn decode(receipt: &wire::SignedRecord) -> Result<SignedAuthorityAdmission, Error> {
42    if receipt.format != FORMAT {
43        return Err(Error::Protocol("invalid authority admission format"));
44    }
45    let [signature] = receipt.signatures.as_slice() else {
46        return Err(Error::Protocol(
47            "authority admission requires one executor signature",
48        ));
49    };
50    let value = ThreadAuthorityAdmission::decode(&receipt.canonical_record)
51        .map_err(|_| Error::Protocol("invalid canonical authority admission"))?;
52    if signature.public_key != value.executor {
53        return Err(Error::Protocol(
54            "authority admission signature key differs from executor",
55        ));
56    }
57    let signed = SignedAuthorityAdmission {
58        boundary_acceptance: None,
59        canonical: receipt.canonical_record.clone(),
60        signature: signature.signature.clone(),
61    };
62    signed
63        .verify_signature()
64        .map_err(|_| Error::Protocol("invalid authority admission signature"))?;
65    Ok(signed)
66}
67pub fn encode(receipt: &SignedAuthorityAdmission) -> Result<wire::SignedRecord, Error> {
68    let value = receipt
69        .verify_signature()
70        .map_err(|_| Error::Protocol("invalid authority admission signature"))?;
71    Ok(wire::SignedRecord {
72        format: FORMAT.into(),
73        canonical_record: receipt.canonical.clone(),
74        signatures: vec![wire::RecordSignature {
75            public_key: value.executor.to_vec(),
76            signature: receipt.signature.clone(),
77        }],
78    })
79}
80
81/// Decode a bounded batch and match every sidecar to an exact original in that
82/// batch. This verifies signatures and immutable bindings, not issuer trust.
83/// Callers still authorize current disclosure and independently pin receipts.
84pub fn match_batch(
85    batch: &wire::ReplicationOperations,
86) -> Result<Vec<crate::replication::store::ReceivedOperation>, Error> {
87    use std::collections::{BTreeMap, BTreeSet};
88
89    use prost::Message;
90    if batch.operations.is_empty()
91        || batch.operations.len() > 128
92        || batch.authority_admissions.len() > 128
93        || batch.encoded_len() > 1024 * 1024
94    {
95        return Err(Error::Protocol("original authority batch exceeds bounds"));
96    }
97    let mut evidence = crate::boundary_acceptance::Evidence::default();
98    evidence.add(&batch.boundary_acceptances)?;
99    let mut receipts = BTreeMap::new();
100    for record in &batch.authority_admissions {
101        let mut signed = decode(record)?;
102        let statement = signed
103            .verify_signature()
104            .map_err(|_| Error::Protocol("invalid authority admission signature"))?;
105        signed.boundary_acceptance = evidence.matched(&statement.basis)?;
106        let operation_id = statement.subject.operation_id().ok_or(Error::Protocol(
107            "operation batch cannot carry ownership claim admission",
108        ))?;
109        if receipts.insert(operation_id, (signed, statement)).is_some() {
110            return Err(Error::Protocol("duplicate authority admission sidecar"));
111        }
112    }
113    let mut ids = BTreeSet::new();
114    let mut output = Vec::new();
115    for record in &batch.operations {
116        let original = crate::replication::decode_record(record.clone())
117            .map_err(|_| Error::Protocol("invalid original operation signature"))?;
118        let operation = original
119            .verify()
120            .map_err(|_| Error::Protocol("invalid original operation signature"))?;
121        let id = operation
122            .id()
123            .map_err(|_| Error::Protocol("invalid original operation identity"))?;
124        if !ids.insert(id) {
125            return Err(Error::Protocol("duplicate original operation"));
126        }
127        let receipt = if let Some((receipt, statement)) = receipts.remove(&id) {
128            receipt
129                .verify(
130                    &original,
131                    &TrustedHostedExecutor {
132                        spool: statement.spool,
133                        spool_genesis: statement.spool_genesis,
134                        executor: statement.executor,
135                    },
136                )
137                .map_err(|_| {
138                    Error::Protocol("authority receipt differs from original operation")
139                })?;
140            Some(receipt)
141        } else {
142            None
143        };
144        output.push(crate::replication::store::ReceivedOperation {
145            original,
146            authority_admission: receipt,
147        });
148    }
149    if !receipts.is_empty() {
150        return Err(Error::Protocol("unmatched authority admission sidecar"));
151    }
152    evidence.finish()?;
153    Ok(output)
154}
155
156#[cfg(test)]
157mod tests {
158    use crypto::Ed25519Signer;
159    use heddle_object_model::object::{
160        CollaborationActor, ContentHash,
161        thread_authority_admission::MAX_BYTES,
162        thread_replication::{
163            ThreadOperation, ThreadOperationBody,
164            metadata::{AUTHORITY_FORMAT, Control, ThreadControl},
165        },
166    };
167    use uuid::Uuid;
168
169    use super::*;
170
171    fn fixture() -> (
172        ThreadAuthorityAdmission,
173        SignedOperation,
174        TrustedHostedExecutor,
175        Ed25519Signer,
176    ) {
177        let author = Ed25519Signer::from_seed(&[41; 32]).expect("author");
178        let executor = Ed25519Signer::from_seed(&[42; 32]).expect("executor");
179        let envelope = b"original authority independently checked at first admission".to_vec();
180        let control = ThreadControl {
181            version: 1,
182            spool: Uuid::from_u128(100),
183            actor: CollaborationActor {
184                principal_id: Uuid::from_u128(101),
185                agent_id: Some("original-agent".into()),
186            },
187            authority_digest: ContentHash::compute_typed(AUTHORITY_FORMAT, &envelope),
188            authority_envelope: envelope,
189            client_operation_id: Uuid::from_u128(102),
190            occurred_at_ms: 1,
191            control: Control::Name("original agent work".into()),
192        };
193        let operation = ThreadOperation {
194            version: 1,
195            thread: ContentHash::from_bytes([43; 32]),
196            parents: Default::default(),
197            publisher: author.public_key().try_into().expect("key"),
198            body: ThreadOperationBody::Metadata(control.encode().expect("control")),
199        };
200        let trust = TrustedHostedExecutor {
201            spool: control.spool,
202            spool_genesis: ContentHash::from_bytes([44; 32]),
203            executor: executor.public_key().try_into().expect("key"),
204        };
205        let receipt = ThreadAuthorityAdmission {
206            version: 3,
207            basis: heddle_object_model::object::original_boundary_acceptance::AdmissionBasis::OriginalAuthority,
208            spool: trust.spool,
209            spool_genesis: trust.spool_genesis,
210            thread: operation.thread,
211            subject: heddle_object_model::object::thread_authority_admission::OriginalAuthoritySubject::Operation(operation.id().expect("ID")),
212            actor: control.actor,
213            publisher: operation.publisher,
214            authority_digest: control.authority_digest,
215            executor: trust.executor,
216            admitted_at_ms: 2000,
217        };
218        (
219            receipt,
220            SignedOperation::sign(&operation, &author).expect("original signature"),
221            trust,
222            executor,
223        )
224    }
225    #[test]
226    fn admission_requires_independent_executor_and_exact_original_identity() {
227        let (value, original, trust, executor) = fixture();
228        let signed = sign(&value, &executor).expect("receipt");
229        assert_eq!(
230            verify(&signed, &original, &trust).expect("independent original admission"),
231            value
232        );
233        for wrong in [
234            TrustedHostedExecutor {
235                executor: [45; 32],
236                ..trust.clone()
237            },
238            TrustedHostedExecutor {
239                spool: Uuid::from_u128(999),
240                ..trust.clone()
241            },
242            TrustedHostedExecutor {
243                spool_genesis: ContentHash::from_bytes([46; 32]),
244                ..trust.clone()
245            },
246        ] {
247            assert!(
248                verify(&signed, &original, &wrong).is_err(),
249                "incoming proof never enrolls its own trust"
250            );
251        }
252        let mut mutations = Vec::new();
253        let mut changed = value.clone();
254        changed.subject =
255            heddle_object_model::object::thread_authority_admission::OriginalAuthoritySubject::Operation(
256                ContentHash::from_bytes([47; 32]),
257            );
258        mutations.push(changed);
259        let mut changed = value.clone();
260        changed.thread = ContentHash::from_bytes([48; 32]);
261        mutations.push(changed);
262        let mut changed = value.clone();
263        changed.publisher = [49; 32];
264        mutations.push(changed);
265        let mut changed = value.clone();
266        changed.actor.agent_id = None;
267        mutations.push(changed);
268        let mut changed = value.clone();
269        changed.actor.principal_id = Uuid::from_u128(888);
270        mutations.push(changed);
271        let mut changed = value;
272        changed.authority_digest = ContentHash::from_bytes([50; 32]);
273        mutations.push(changed);
274        for changed in mutations {
275            assert!(
276                verify(
277                    &sign(&changed, &executor).expect("valid executor signature"),
278                    &original,
279                    &trust
280                )
281                .is_err(),
282                "receipt cannot substitute original scope or authorship"
283            );
284        }
285    }
286    #[test]
287    fn admission_verifies_both_signatures_and_canonical_bounds() {
288        let (value, original, trust, executor) = fixture();
289        let signed = sign(&value, &executor).expect("receipt");
290        let mut changed = signed.clone();
291        let mut new_value = value;
292        new_value.admitted_at_ms += 1;
293        changed.canonical_record = new_value.encode().expect("different timestamp");
294        assert!(
295            verify(&changed, &original, &trust).is_err(),
296            "executor signature binds first admission time"
297        );
298        let mut changed = original.clone();
299        changed.signature[0] ^= 1;
300        assert!(
301            verify(&signed, &changed, &trust).is_err(),
302            "receipt never replaces original signature"
303        );
304        let mut changed = signed.clone();
305        changed.signatures.push(changed.signatures[0].clone());
306        assert!(
307            verify(&changed, &original, &trust).is_err(),
308            "exact one executor signature"
309        );
310        let mut changed = signed;
311        changed.canonical_record.resize(MAX_BYTES + 1, 0);
312        assert!(
313            verify(&changed, &original, &trust).is_err(),
314            "bounded before decoding"
315        );
316        assert!(
317            ThreadAuthorityAdmission::decode(&changed.canonical_record)
318                .expect_err("oversized canonical bytes rejected before parsing")
319                .to_string()
320                .contains("byte bound")
321        );
322    }
323}