1use crate::heddle::api::v1alpha2::*;
6pub use crate::hybrid_codec::{Reject, strict_decode};
7use crate::hybrid_codec::{canonical, field, hash, key_id, record, signing_digest, verify, width};
8
9pub const PERMISSION_DOMAIN: &str = "heddle-import-member-permission-v1";
10pub const GENESIS_DOMAIN: &str = "heddle-import-genesis-authority-v1";
11pub const DELEGATION_DOMAIN: &str = "heddle-import-job-delegation-v1";
12pub const RENEWAL_DOMAIN: &str = "heddle-import-job-renewal-v1";
13pub const OPERATION_DOMAIN: &str = "heddle-delegated-import-operation-v1";
14pub const MANIFEST_DOMAIN: &str = "heddle-import-result-manifest-v1";
15pub const PUBLICATION_DOMAIN: &str = "heddle-import-publication-payload-v1";
16pub const MAX_BRANCHES: usize = 256;
17pub const MAX_RECORD_BYTES: usize = 64 * 1024;
18pub const MAX_BUNDLE_BYTES: usize = 1024 * 1024;
19pub const MAX_RESULT_BYTES: u64 = 1 << 30;
20pub const CANCELLATION_NAMESPACE: &str = "heddle-import-cancel-v1";
21
22record!(AuthorizationSignature, signer_key_id:b, signature:b);
23record!(RecordSignature, public_key:b, signature:b);
24record!(SignedRecord, format:s, canonical_record:b, signatures:l);
25record!(ImportFrontierV1, format_version:u, thread_id:b, operation_ids:h);
26record!(ImportContentV1, format_version:u, canonical_capture:b);
27record!(ImportBoundaryAcceptanceV1, binding:m, signed_acceptance:m, originals_manifest:b, publication_intent:b, original_receipts:l);
28record!(ImportGenesisWitnessV1, format_version:u, binding:m, original_genesis:m, creator_authority_envelope:b, boundary_acceptance:o);
29record!(ImportAuthorityWitnessV1, format_version:u, kind:e, original:m, dependencies:l, authority_envelope:b, boundary_acceptances:l);
30record!(HostedLandingRequestProofV1, format_version:u, signing_identity:s, method_path:s, timestamp_millis:u, nonce:b, request_body:b, signature:m);
31record!(HostedLandingWitnessV1, format_version:u, execution:m, request:m, source_operation:m, review_evidence:l, authority_envelope:b);
32record!(ImportJobCasStateV1, format_version:u, logical_job_id:b, retry_lineage_id:b, active_predecessor:m, authority_epoch:u, committed_manifest:m);
33record!(ImportIdentityV1, spool_uuid:b, spool_genesis_digest:b, owner_id:b,
34 owner_account_uuid:b, owner_state_hash:b, ownership_transfer_sequence:u);
35record!(ImportOwnerChainV1, spool_genesis_digest:b, owner_state_hashes:q, transfer_audit_hashes:q);
36record!(ImportBranchLimitV1, ref_name:s, hash_algorithm:e, ref_mode:e, pinned_commit_oid:b,
37 genesis_digest:b, target_thread_id:b, expected_frontier_digest:b, slot_id:u, max_result_bytes:u, ref_disclosure:e);
38record!(ImportPermissionScopeV1, provider:s, source_url:s, branches:l, destination_version:b,
39 options_digest:b, converter_version:s, max_operations:u, max_result_bytes:u);
40record!(ImportMemberPermissionV1, format_version:u, identity:m, logical_job_id:b,
41 retry_lineage_id:b, subject_public_key:b, purpose:e, scope:m, not_before_unix_seconds:u,
42 expires_at_unix_seconds:u, cancellation_id:b, owner_chain_digest:b, nonce:b);
43record!(SignedImportMemberPermissionV1, body:m, owner_signature:m);
44record!(ImportGenesisAuthorityV1, format_version:u, identity:m, genesis_digest:b,
45 original_creator_signature:b, creator_public_key:b, creator_authority_envelope_digest:b,
46 parent_permission_digest:b, owner_chain_digest:b);
47record!(SignedImportGenesisAuthorityV1, body:m, creator_signature:m);
48record!(ImportBranchManifestV1, limit:m, genesis_authority_digest:b);
49record!(ImportJobDelegationV1, format_version:u, identity:m, delegation_id:b, logical_job_id:b,
50 retry_lineage_id:b, job_public_key:b, job_key_id:b, delegating_public_key:b,
51 parent_permission_digest:b, owner_chain_digest:b, purpose:e, scope:m, branch_manifest:l,
52 not_before_unix_seconds:u, expires_at_unix_seconds:u, cancellation_id:b, predecessor_delegation_digest:b);
53record!(ImportJobPreparationV1, format_version:u, identity:m, delegation_id:b, logical_job_id:b,
54 retry_lineage_id:b, job_public_key:b, job_key_id:b, owner_chain_digest:b, purpose:e,
55 scope:m, cancellation_id:b, predecessor_delegation_digest:b);
56record!(SignedImportJobDelegationV1, body:m, delegating_signature:m);
57record!(ImportCommittedSlotV1, ref_name:s, slot_id:u, signed_operation_digest:b,
58 resulting_frontier_digest:b, result_bytes:u);
59record!(ImportResultManifestV1, format_version:u, logical_job_id:b, retry_lineage_id:b, slots:l);
60record!(ImportJobRenewalV1, format_version:u, predecessor_delegation_digest:b,
61 expected_authority_epoch:u, committed_manifest_digest:b, replacement:m);
62record!(SignedImportJobRenewalV1, body:m, delegating_signature:m);
63record!(DelegatedImportOperationV1, format_version:u, spool_uuid:b, spool_genesis_digest:b,
64 logical_job_id:b, retry_lineage_id:b, physical_operation_id:b, delegation_digest:b,
65 ref_name:s, slot_id:u, hash_algorithm:e, observed_commit_oid:b, genesis_digest:b,
66 target_thread_id:b, expected_frontier_digest:b, resulting_frontier_digest:b,
67 resulting_content_digest:b, result_bytes:u, options_digest:b, converter_version:s);
68record!(SignedDelegatedImportOperationV1, body:m, job_signature:m);
69record!(ImportPublicationWitnessV1, format_version:u, signed_operation_digest:b, delegation_digest:b,
70 logical_job_id:b, retry_lineage_id:b, physical_operation_id:b, ref_name:s, slot_id:u,
71 hash_algorithm:e, observed_commit_oid:b, expected_frontier_digest:b, resulting_frontier_digest:b,
72 terminal_manifest_digest:b);
73
74pub fn signed_permission_digest(v: &SignedImportMemberPermissionV1) -> Result<Vec<u8>, Reject> {
75 signing_digest("heddle-signed-import-member-permission-v1", v)
76}
77pub fn owner_chain_digest(v: &ImportOwnerChainV1) -> Result<Vec<u8>, Reject> {
78 width(&v.spool_genesis_digest, 32)?;
79 if v.owner_state_hashes.is_empty()
80 || v.owner_state_hashes.len() > 64
81 || v.transfer_audit_hashes.len() > 64
82 {
83 return Err(Reject::Bounds);
84 }
85 for h in v.owner_state_hashes.iter().chain(&v.transfer_audit_hashes) {
86 width(h, 32)?;
87 }
88 if v.owner_state_hashes.windows(2).any(|w| w[0] >= w[1]) {
89 return Err(Reject::Canonical);
90 }
91 signing_digest("heddle-import-owner-chain-v1", v)
92}
93pub fn signed_genesis_digest(v: &SignedImportGenesisAuthorityV1) -> Result<Vec<u8>, Reject> {
94 signing_digest("heddle-signed-import-genesis-authority-v1", v)
95}
96pub fn signed_delegation_digest(v: &SignedImportJobDelegationV1) -> Result<Vec<u8>, Reject> {
97 signing_digest("heddle-signed-import-job-delegation-v1", v)
98}
99pub fn signed_operation_digest(v: &SignedDelegatedImportOperationV1) -> Result<Vec<u8>, Reject> {
100 signing_digest("heddle-signed-delegated-import-operation-v1", v)
101}
102pub fn manifest_digest(v: &ImportResultManifestV1) -> Result<Vec<u8>, Reject> {
103 signing_digest(MANIFEST_DOMAIN, v)
104}
105pub fn verify_authorization_signature(
106 key: &[u8],
107 domain: &str,
108 body: &impl crate::hybrid_codec::Canonical,
109 signature: &AuthorizationSignature,
110) -> Result<(), Reject> {
111 if signature.signer_key_id != key_id(key) {
112 return Err(Reject::Signature);
113 }
114 let bytes = canonical(body)?;
115 if bytes.len() > MAX_RECORD_BYTES {
116 return Err(Reject::Bounds);
117 }
118 verify(
119 key,
120 &hash(&[domain.as_bytes(), &bytes]),
121 &signature.signature,
122 )
123}
124
125pub fn canonical_https(value: &str, origin: bool) -> Result<(), Reject> {
129 if value.len() > 2048 {
130 return Err(Reject::Bounds);
131 }
132 let rest = value.strip_prefix("https://").ok_or(Reject::Canonical)?;
133 let (host, path) = rest.split_once('/').unwrap_or((rest, ""));
134 if host.is_empty()
135 || host.len() > 253
136 || host.split('.').any(|part| {
137 part.is_empty()
138 || part.len() > 63
139 || part.starts_with('-')
140 || part.ends_with('-')
141 || !part
142 .bytes()
143 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
144 })
145 || (origin && rest != host)
146 || (!origin && path.is_empty())
147 || path.split('/').any(|p| {
148 p.is_empty() && !origin
149 || p == "."
150 || p == ".."
151 || !p
152 .bytes()
153 .all(|b| b.is_ascii_alphanumeric() || b"-._~".contains(&b))
154 })
155 {
156 return Err(Reject::Canonical);
157 }
158 Ok(())
159}
160fn identity(value: &ImportIdentityV1) -> Result<(), Reject> {
161 for v in [&value.spool_uuid, &value.owner_account_uuid] {
162 width(v, 16)?;
163 if v.iter().all(|b| *b == 0) {
164 return Err(Reject::Canonical);
165 }
166 }
167 for v in [
168 &value.spool_genesis_digest,
169 &value.owner_id,
170 &value.owner_state_hash,
171 ] {
172 width(v, 32)?;
173 }
174 Ok(())
175}
176fn interval(start: i64, end: i64, now: i64) -> Result<(), Reject> {
177 if start < 0 || end <= start {
178 return Err(Reject::Semantic);
179 }
180 if now < start || now >= end {
181 return Err(Reject::Expired);
182 }
183 Ok(())
184}
185fn validity(start: i64, end: i64, now: i64, current: bool) -> Result<(), Reject> {
187 if start < 0 || end <= start {
188 return Err(Reject::Semantic);
189 }
190 if current {
191 interval(start, end, now)?;
192 }
193 Ok(())
194}
195fn branch(value: &ImportBranchLimitV1) -> Result<(), Reject> {
196 if !value.ref_name.starts_with("refs/heads/")
197 || value.ref_name.len() > 1024
198 || value.ref_name.ends_with('/')
199 || value.ref_name.ends_with('.')
200 || value.ref_name.contains("..")
201 || value.ref_name.contains("//")
202 || value.ref_name.contains("@{")
203 || value
204 .ref_name
205 .split('/')
206 .any(|p| p.starts_with('.') || p.ends_with(".lock"))
207 || !value
208 .ref_name
209 .bytes()
210 .all(|b| b.is_ascii_alphanumeric() || b"/_-.".contains(&b))
211 {
212 return Err(Reject::Canonical);
213 }
214 let size = match value.hash_algorithm {
215 1 => 20,
216 2 => 32,
217 _ => return Err(Reject::Version),
218 };
219 match value.ref_mode {
220 1 => {
221 width(&value.pinned_commit_oid, size)?;
222 if value.ref_disclosure != 0 {
223 return Err(Reject::RefDisclosure);
224 }
225 }
226 2 if value.pinned_commit_oid.is_empty() => {
227 if value.ref_disclosure != 1 {
228 return Err(Reject::RefDisclosure);
229 }
230 }
231 _ => return Err(Reject::Semantic),
232 }
233 for v in [
234 &value.genesis_digest,
235 &value.target_thread_id,
236 &value.expected_frontier_digest,
237 ] {
238 width(v, 32)?;
239 }
240 if value.max_result_bytes == 0 || value.max_result_bytes > MAX_RESULT_BYTES {
241 return Err(Reject::Bounds);
242 }
243 Ok(())
244}
245
246pub fn validate_ref_selection(
249 value: &ImportBranchLimitV1,
250 known_commit_oid: Option<&[u8]>,
251) -> Result<(), Reject> {
252 branch(value)?;
253 if let Some(oid) = known_commit_oid {
254 width(oid, if value.hash_algorithm == 1 { 20 } else { 32 })?;
255 if value.ref_mode != 1 || value.pinned_commit_oid != oid {
256 return Err(Reject::RefPinning);
257 }
258 }
259 Ok(())
260}
261
262pub fn conversion_options_digest(version: &str, options: &[u8]) -> Result<Vec<u8>, Reject> {
264 if version.is_empty() || version.len() > 128 || !version.is_ascii() {
265 return Err(Reject::Canonical);
266 }
267 if options.len() > 4096 {
268 return Err(Reject::Bounds);
269 }
270 let mut bytes = Vec::new();
271 crate::hybrid_codec::counted(&mut bytes, version.as_bytes())?;
272 crate::hybrid_codec::counted(&mut bytes, options)?;
273 Ok(hash(&[b"heddle-import-conversion-options-v1", &bytes]))
274}
275
276pub fn resolve_import_provider(
279 source: &ProviderRepository,
280 connection_provider: Option<&str>,
281) -> Result<&'static str, Reject> {
282 canonical_https(&source.clone_url, false)?;
283 if source.provider_repository_id.len() > 4096 || source.name.len() > 4096 {
284 return Err(Reject::Bounds);
285 }
286 if let Some(connection) = &source.connection {
287 let positive = |v: &str| {
288 !v.is_empty()
289 && v.bytes().all(|b| b.is_ascii_digit())
290 && v.parse::<u64>().is_ok_and(|id| id > 0)
291 };
292 if connection_provider != Some("github")
293 || connection.spool.is_some()
294 || connection.id.is_empty()
295 || !positive(&source.provider_repository_id)
296 || !positive(&source.installation_id)
297 {
298 return Err(Reject::SourceSelection);
299 }
300 let path = source
301 .clone_url
302 .strip_prefix("https://github.com/")
303 .ok_or(Reject::SourceSelection)?;
304 let parts: Vec<_> = path.split('/').collect();
305 if parts.len() != 2
306 || parts[0].is_empty()
307 || parts[1].strip_suffix(".git").is_none_or(str::is_empty)
308 {
309 return Err(Reject::SourceSelection);
310 }
311 Ok("github")
312 } else {
313 if connection_provider.is_some()
314 || source.private
315 || !source.installation_id.is_empty()
316 || (!source.provider_repository_id.is_empty()
317 && source.provider_repository_id != source.clone_url)
318 {
319 return Err(Reject::SourceSelection);
320 }
321 Ok("public-git")
322 }
323}
324
325pub fn validate_repository_hash_algorithm(
327 source: &ProviderRepository,
328 known: bool,
329) -> Result<(), Reject> {
330 let size = match source.hash_algorithm {
331 1 => Some(40),
332 2 => Some(64),
333 0 if !known => None,
334 _ => return Err(Reject::Version),
335 };
336 if source.refs.len() > 512 {
337 return Err(Reject::Bounds);
338 }
339 for (i, r) in source.refs.iter().enumerate() {
340 if r.hash_algorithm != source.hash_algorithm {
341 return Err(Reject::SourceSelection);
342 }
343 if i > 0 && source.refs[i - 1].name >= r.name {
344 return Err(Reject::Canonical);
345 }
346 if !r.head_oid.is_empty() {
347 let size = size.ok_or(Reject::Version)?;
348 if r.head_oid.len() != size {
349 return Err(Reject::SourceSelection);
350 }
351 if !r
352 .head_oid
353 .bytes()
354 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
355 {
356 return Err(Reject::Canonical);
357 }
358 }
359 }
360 Ok(())
361}
362
363pub fn validate_discovered_import_scope(
365 scope: &ImportPermissionScopeV1,
366 source: &ProviderRepository,
367) -> Result<(), Reject> {
368 validate_discovered_import_scope_inner(scope, source, false)
369}
370
371fn validate_discovered_import_scope_inner(
372 scope: &ImportPermissionScopeV1,
373 source: &ProviderRepository,
374 retained: bool,
375) -> Result<(), Reject> {
376 validate_repository_hash_algorithm(source, true)?;
377 if scope.source_url != source.clone_url {
378 return Err(Reject::SourceSelection);
379 }
380 for b in &scope.branches {
381 if b.hash_algorithm != source.hash_algorithm {
382 return Err(Reject::SourceSelection);
383 }
384 let oid = source
385 .refs
386 .iter()
387 .find(|r| r.name == b.ref_name)
388 .filter(|r| !r.head_oid.is_empty())
389 .map(|r| hex::decode(&r.head_oid).map_err(|_| Reject::Canonical))
390 .transpose()?;
391 validate_ref_selection(
393 b,
394 if retained && b.ref_mode == 1 {
395 None
396 } else {
397 oid.as_deref()
398 },
399 )?;
400 }
401 Ok(())
402}
403
404pub fn prepare_import_source_scope(
409 request: &PrepareImportJobRequest,
410 current_source: &ProviderRepository,
411 connection_provider: Option<&str>,
412 configuration: &GetImportConfigurationResponse,
413 current_destination_version: &[u8],
414 retained: Option<(&VerifiedImportRenewalPredecessor, &ImportJobCasStateV1)>,
415) -> Result<ImportPermissionScopeV1, Reject> {
416 let selector = request.source.as_ref().ok_or(Reject::SourceSelection)?;
417 if selector.connection != current_source.connection
418 || (selector.provider_repository_id != current_source.provider_repository_id
419 && (selector.connection.is_some() || !selector.provider_repository_id.is_empty()))
420 || selector.installation_id != current_source.installation_id
421 || selector.private != current_source.private
422 {
423 return Err(Reject::SourceSelection);
424 }
425 let scope = request.proposed_scope.as_ref().ok_or(Reject::Canonical)?;
426 let provider = resolve_import_provider(current_source, connection_provider)?;
427 if scope.provider != provider {
428 return Err(Reject::SourceSelection);
429 }
430 if let Some((predecessor, state)) = retained {
431 validate_cas_state(state)?;
432 let previous = &predecessor.previous.body;
433 let identity = previous.identity.as_ref().ok_or(Reject::Canonical)?;
434 if signing_digest("heddle-import-job-cas-state-v1", state)? != predecessor.state_digest
435 || request.renew_logical_job_id != previous.logical_job_id
436 || request.retry_lineage_id != previous.retry_lineage_id
437 || request.destination.as_ref().is_none_or(|s| {
438 initial_operation_id(&identity.spool_uuid, false).map_or(true, |id| s.id != id)
439 })
440 {
441 return Err(Reject::StaleContext);
442 }
443 let mut selected = scope.clone();
444 if selected.destination_version.is_empty() {
445 selected.destination_version = current_destination_version.to_vec();
446 }
447 remaining_scope(
448 &selected,
449 previous.scope.as_ref().ok_or(Reject::Canonical)?,
450 state.committed_manifest.as_ref().ok_or(Reject::Canonical)?,
451 )?;
452 } else if !request.renew_logical_job_id.is_empty() {
453 return Err(Reject::StaleContext);
454 }
455 validate_discovered_import_scope_inner(scope, current_source, retained.is_some())?;
456 prepare_scope(scope, configuration, current_destination_version)
457}
458
459fn validate_provider_support(
460 provider: &str,
461 configuration: &GetImportConfigurationResponse,
462) -> Result<(), Reject> {
463 if !configuration
464 .providers
465 .iter()
466 .any(|p| p.provider == provider)
467 {
468 return Err(Reject::SourceSelection);
469 }
470 Ok(())
471}
472
473pub fn validate_import_configuration(v: &GetImportConfigurationResponse) -> Result<(), Reject> {
474 use prost::Message;
475 if v.encoded_len() > MAX_BUNDLE_BYTES || v.converters.is_empty() || v.converters.len() > 32 {
476 return Err(Reject::Bounds);
477 }
478 for (i, c) in v.converters.iter().enumerate() {
479 for text in [&c.converter_version, &c.options_encoding] {
480 if text.is_empty() || text.len() > 128 || !text.is_ascii() {
481 return Err(Reject::Canonical);
482 }
483 }
484 if i > 0 && v.converters[i - 1].converter_version >= c.converter_version {
485 return Err(Reject::Canonical);
486 }
487 if c.canonical_options.is_empty()
488 || c.canonical_options.len() > 64
489 || c.canonical_options.iter().any(|o| o.len() > 4096)
490 {
491 return Err(Reject::Bounds);
492 }
493 if c.canonical_options.windows(2).any(|w| w[0] >= w[1])
494 || !c.canonical_options.contains(&c.default_options)
495 {
496 return Err(Reject::Canonical);
497 }
498 }
499 if v.providers.is_empty() || v.providers.len() > 2 {
500 return Err(Reject::Bounds);
501 }
502 for (i, p) in v.providers.iter().enumerate() {
503 if i > 0 && v.providers[i - 1].provider >= p.provider {
504 return Err(Reject::Canonical);
505 }
506 let mode = match p.provider.as_str() {
507 "github" => 1,
508 "public-git" => 2,
509 _ => return Err(Reject::SourceSelection),
510 };
511 if p.source_modes != [mode] {
512 return Err(Reject::SourceSelection);
513 }
514 }
515 if let Some(default) = &v.default_converter_version
516 && !v.converters.iter().any(|c| &c.converter_version == default)
517 {
518 return Err(Reject::Canonical);
519 }
520 let l = v.limits.as_ref().ok_or(Reject::Canonical)?;
521 if l.max_branches == 0
522 || l.max_branches as usize > MAX_BRANCHES
523 || l.max_operations == 0
524 || l.max_operations as usize > MAX_BRANCHES
525 || l.max_result_bytes == 0
526 || l.max_result_bytes > MAX_RESULT_BYTES
527 || l.max_branch_result_bytes == 0
528 || l.max_branch_result_bytes > l.max_result_bytes
529 {
530 return Err(Reject::Bounds);
531 }
532 Ok(())
533}
534
535pub fn prepare_scope(
538 proposed: &ImportPermissionScopeV1,
539 configuration: &GetImportConfigurationResponse,
540 current_destination_version: &[u8],
541) -> Result<ImportPermissionScopeV1, Reject> {
542 use ImportPreparationRefusalReason as Reason;
543 validate_import_configuration(configuration)?;
544 width(current_destination_version, 32)?;
545 if !proposed.destination_version.is_empty() && proposed.destination_version.len() != 32 {
546 return Err(Reject::PreparationRefused(Reason::InvalidScope));
547 }
548 if !proposed.destination_version.is_empty()
549 && proposed.destination_version != current_destination_version
550 {
551 return Err(Reject::PreparationRefused(Reason::DestinationConflict));
552 }
553 let mut selected = proposed.clone();
554 selected.destination_version = current_destination_version.to_vec();
555 validate_scope(&selected).map_err(|_| Reject::PreparationRefused(Reason::InvalidScope))?;
556 validate_provider_support(&selected.provider, configuration)
557 .map_err(|_| Reject::PreparationRefused(Reason::InvalidScope))?;
558 let converter = configuration
559 .converters
560 .iter()
561 .find(|c| c.converter_version == selected.converter_version)
562 .ok_or(Reject::PreparationRefused(Reason::UnsupportedConverter))?;
563 let supported = converter
564 .canonical_options
565 .iter()
566 .try_fold(false, |found, o| {
567 Ok::<_, Reject>(
568 found
569 || conversion_options_digest(&converter.converter_version, o)?
570 == selected.options_digest,
571 )
572 })?;
573 if !supported {
574 return Err(Reject::PreparationRefused(Reason::UnsupportedOptions));
575 }
576 let limits = configuration.limits.as_ref().ok_or(Reject::Canonical)?;
577 if selected.branches.len() > limits.max_branches as usize
578 || selected.max_operations > limits.max_operations
579 || selected.max_result_bytes > limits.max_result_bytes
580 || selected
581 .branches
582 .iter()
583 .any(|b| b.max_result_bytes > limits.max_branch_result_bytes)
584 {
585 return Err(Reject::PreparationRefused(Reason::BudgetExceeded));
586 }
587 Ok(selected)
588}
589
590pub fn validate_preparation_response(
592 request: &PrepareImportJobRequest,
593 response: &PrepareImportJobResponse,
594) -> Result<(), Reject> {
595 if let Some(refusal) = &response.refusal {
596 let reason = ImportPreparationRefusalReason::try_from(refusal.reason)
597 .map_err(|_| Reject::Version)?;
598 if reason == ImportPreparationRefusalReason::Unspecified
599 || refusal.field.len() > 256
600 || !refusal.field.is_ascii()
601 || response.proposal.is_some()
602 || response.renewal_state.is_some()
603 || response.reservation_expires_at_unix_seconds != 0
604 || response.prepared_at_unix_seconds != 0
605 || response.max_validity_duration_seconds != 0
606 || response.clock_skew_allowance_seconds != 0
607 {
608 return Err(Reject::Canonical);
609 }
610 return Err(Reject::PreparationRefused(reason));
611 }
612 let p = response.proposal.as_ref().ok_or(Reject::Canonical)?;
613 let returned = p.scope.as_ref().ok_or(Reject::Canonical)?;
614 let mut requested = request.proposed_scope.clone().ok_or(Reject::Canonical)?;
615 if requested.destination_version.is_empty() {
616 requested.destination_version = returned.destination_version.clone();
617 }
618 if canonical(&requested)? != canonical(returned)?
619 || p.identity != request.identity
620 || p.retry_lineage_id != request.retry_lineage_id
621 {
622 return Err(Reject::PreparedFields);
623 }
624 validate_scope(returned)?;
625 if request.renew_logical_job_id.is_empty() {
626 if response.renewal_state.is_some()
627 || p.predecessor_delegation_digest.iter().any(|b| *b != 0)
628 {
629 return Err(Reject::PreparedFields);
630 }
631 } else {
632 if p.logical_job_id != request.renew_logical_job_id {
633 return Err(Reject::PreparedFields);
634 }
635 validate_renewal_preparation(response)?;
636 }
637 Ok(())
638}
639
640pub fn validate_commit_request(
644 request: &CommitImportJobRequest,
645 resolved_provider: &str,
646 current_source: &ProviderRepository,
647 configuration: &GetImportConfigurationResponse,
648) -> Result<(), Reject> {
649 use prost::Message;
650 let source = request.source.as_ref().ok_or(Reject::SourceSelection)?;
651 let proof = request.proof.as_ref().ok_or(Reject::Canonical)?;
652 if request.client_operation_id.is_empty()
653 || request.client_operation_id.len() > 128
654 || request.destination.as_ref().is_none_or(|d| d.id.is_empty())
655 {
656 return Err(Reject::Canonical);
657 }
658 if request.initial_base_state.len() > 4096 || proof.encoded_len() > MAX_BUNDLE_BYTES {
659 return Err(Reject::Bounds);
660 }
661 if proof.format_version != 1
662 || proof.delegations.len() != 1
663 || !proof.renewals.is_empty()
664 || !proof.operations.is_empty()
665 || proof.terminal_manifest.is_some()
666 || !proof.manifests.is_empty()
667 {
668 return Err(Reject::Canonical);
669 }
670 let d = proof.delegations[0]
671 .body
672 .as_ref()
673 .ok_or(Reject::Canonical)?;
674 let scope = d.scope.as_ref().ok_or(Reject::Canonical)?;
675 validate_scope(scope)?;
676 let id = d.identity.as_ref().ok_or(Reject::Canonical)?;
677 identity(id)?;
678 let uuid = hex::encode(&id.spool_uuid);
679 let destination_id = format!(
680 "{}-{}-{}-{}-{}",
681 &uuid[..8],
682 &uuid[8..12],
683 &uuid[12..16],
684 &uuid[16..20],
685 &uuid[20..]
686 );
687 if request
688 .destination
689 .as_ref()
690 .is_none_or(|s| s.id != destination_id)
691 {
692 return Err(Reject::Scope);
693 }
694 if d.predecessor_delegation_digest != [0; 32] {
695 return Err(Reject::Canonical);
696 }
697 if source.clone_url != scope.source_url
698 || resolved_provider != scope.provider
699 || source.provider_repository_id.len() > 4096
700 || source.name.len() > 4096
701 {
702 return Err(Reject::SourceSelection);
703 }
704 if source.connection != current_source.connection
705 || (source.provider_repository_id != current_source.provider_repository_id
706 && (source.connection.is_some() || !source.provider_repository_id.is_empty()))
707 || source.clone_url != current_source.clone_url
708 || source.installation_id != current_source.installation_id
709 || source.private != current_source.private
710 {
711 return Err(Reject::SourceSelection);
712 }
713 let provider = resolve_import_provider(
714 current_source,
715 current_source
716 .connection
717 .as_ref()
718 .map(|_| resolved_provider),
719 )?;
720 if provider != resolved_provider {
721 return Err(Reject::SourceSelection);
722 }
723 validate_import_configuration(configuration)?;
724 validate_provider_support(provider, configuration)?;
725 validate_repository_hash_algorithm(current_source, true)?;
726 if source.hash_algorithm != current_source.hash_algorithm {
727 return Err(Reject::SourceSelection);
728 }
729 for b in &scope.branches {
732 if b.hash_algorithm != current_source.hash_algorithm {
733 return Err(Reject::SourceSelection);
734 }
735 if b.ref_mode == 2
736 && current_source
737 .refs
738 .iter()
739 .any(|r| r.name == b.ref_name && !r.head_oid.is_empty())
740 {
741 return Err(Reject::RefPinning);
742 }
743 }
744 prepare_scope(scope, configuration, &scope.destination_version)?;
746 if proof.original_geneses.len() != scope.branches.len()
747 || proof.creator_authority_envelopes.len() != scope.branches.len()
748 || proof.genesis_authorities.len() != scope.branches.len()
749 || d.branch_manifest.len() != scope.branches.len()
750 {
751 return Err(Reject::GenesisBinding);
752 }
753 for (i, b) in scope.branches.iter().enumerate() {
756 let original = &proof.original_geneses[i];
757 let binding = &proof.genesis_authorities[i];
758 let g = binding.body.as_ref().ok_or(Reject::GenesisBinding)?;
759 let m = &d.branch_manifest[i];
760 if native_id(original) != b.genesis_digest
761 || g.genesis_digest != b.genesis_digest
762 || m.limit.as_ref() != Some(b)
763 || m.genesis_authority_digest != signed_genesis_digest(binding)?
764 || g.creator_authority_envelope_digest != hash(&[&proof.creator_authority_envelopes[i]])
765 || proof.creator_authority_envelopes[i].is_empty()
766 || proof.creator_authority_envelopes[i].len() > MAX_RECORD_BYTES
767 || original.signatures.len() != 1
768 || original.signatures[0].public_key != g.creator_public_key
769 || original.signatures[0].signature != g.original_creator_signature
770 {
771 return Err(Reject::GenesisBinding);
772 }
773 verify_native(original, "heddle-thread-genesis-v1")?;
774 }
775 Ok(())
776}
777
778pub fn validate_import_source(_: &ImportSourceRequest) -> Result<(), Reject> {
780 Err(Reject::ImportSourceRequiresCommit)
781}
782
783pub fn verify_commit_submission(
786 request: &CommitImportJobRequest,
787 prepared: &PrepareImportJobResponse,
788 resolved_provider: &str,
789 current_source: &ProviderRepository,
790 configuration: &GetImportConfigurationResponse,
791 expected: &ImportOwnerExpectation<'_>,
792) -> Result<VerifiedImportDelegation, Reject> {
793 validate_commit_request(request, resolved_provider, current_source, configuration)?;
794 let proof = request.proof.as_ref().ok_or(Reject::Canonical)?;
795 let member = proof
796 .member_permission
797 .as_ref()
798 .or(proof.member_permissions.first());
799 if proof.member_permissions.len() > 1
800 || proof
801 .member_permissions
802 .first()
803 .is_some_and(|p| Some(p) != member)
804 {
805 return Err(Reject::ImportPermission);
806 }
807 verify_prepared_delegation(
808 prepared,
809 &proof.delegations[0],
810 member,
811 &proof.genesis_authorities,
812 expected,
813 )
814}
815
816pub fn validate_commit_response(
817 request: &CommitImportJobRequest,
818 response: &MutationResponse,
819) -> Result<(), Reject> {
820 let receipt = response.receipt.as_ref().ok_or(Reject::PendingOperation)?;
821 let Some(mutation_receipt::Outcome::PendingOperation(operation)) = &receipt.outcome else {
822 return Err(Reject::PendingOperation);
823 };
824 if request.client_operation_id.is_empty()
825 || receipt.client_operation_id != request.client_operation_id
826 || request.destination.is_none()
827 || operation.spool != request.destination
828 || operation.id
829 != initial_operation_id(
830 &request
831 .proof
832 .as_ref()
833 .ok_or(Reject::PendingOperation)?
834 .delegations
835 .first()
836 .and_then(|d| d.body.as_ref())
837 .ok_or(Reject::PendingOperation)?
838 .retry_lineage_id,
839 false,
840 )?
841 {
842 return Err(Reject::PendingOperation);
843 }
844 Ok(())
845}
846
847pub fn check_commit_replay(
850 request: &CommitImportJobRequest,
851 stored: &CommitImportJobRequest,
852 response: &MutationResponse,
853) -> Result<(), Reject> {
854 if request != stored {
855 return Err(Reject::OperationIdReused);
856 }
857 validate_commit_response(request, response)
858}
859pub fn validate_scope(value: &ImportPermissionScopeV1) -> Result<(), Reject> {
860 canonical_https(&value.source_url, false)?;
861 if value.provider.is_empty()
862 || value.provider.len() > 64
863 || !value
864 .provider
865 .bytes()
866 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
867 || value.converter_version.is_empty()
868 || value.converter_version.len() > 128
869 || !value.converter_version.is_ascii()
870 {
871 return Err(Reject::Canonical);
872 }
873 width(&value.destination_version, 32)?;
874 width(&value.options_digest, 32)?;
875 if value.branches.is_empty()
876 || value.branches.len() > MAX_BRANCHES
877 || value.max_operations == 0
878 || value.max_operations as usize > MAX_BRANCHES
879 || value.max_result_bytes == 0
880 || value.max_result_bytes > MAX_RESULT_BYTES
881 {
882 return Err(Reject::Bounds);
883 }
884 if (value.max_operations as usize) < value.branches.len() {
885 return Err(Reject::Scope);
886 }
887 let mut total = 0_u64;
888 for (i, b) in value.branches.iter().enumerate() {
889 branch(b)?;
890 if i > 0 && value.branches[i - 1].ref_name.as_bytes() >= b.ref_name.as_bytes() {
891 return Err(Reject::Canonical);
892 }
893 total = total
894 .checked_add(b.max_result_bytes)
895 .ok_or(Reject::Bounds)?;
896 }
897 if total > value.max_result_bytes {
898 return Err(Reject::Scope);
899 }
900 Ok(())
901}
902fn scope_subset(child: &ImportPermissionScopeV1, parent: &ImportPermissionScopeV1) -> bool {
903 child.provider == parent.provider
904 && child.source_url == parent.source_url
905 && child.destination_version == parent.destination_version
906 && child.options_digest == parent.options_digest
907 && child.converter_version == parent.converter_version
908 && child.max_operations <= parent.max_operations
909 && child.max_result_bytes <= parent.max_result_bytes
910 && child.branches.iter().all(|c| {
911 parent.branches.iter().any(|p| {
912 let mut limit = c.clone();
913 limit.max_result_bytes = p.max_result_bytes;
914 limit == *p && c.max_result_bytes <= p.max_result_bytes
915 })
916 })
917}
918
919pub struct ImportOwnerExpectation<'a> {
923 pub identity: &'a ImportIdentityV1,
924 pub owner_public_key: &'a [u8],
925 pub owner_chain_digest: &'a [u8],
926 pub authority_expires_at_seconds: i64,
927 pub now_unix_seconds: i64,
928 pub forbidden_job_keys: &'a [Vec<u8>], pub known_job_associations: &'a [(Vec<u8>, Vec<u8>)], }
931pub fn verify_member_permission(
932 signed: &SignedImportMemberPermissionV1,
933 expected: &ImportOwnerExpectation<'_>,
934) -> Result<(), Reject> {
935 verify_member_permission_inner(signed, expected, true)
936}
937fn verify_member_permission_inner(
938 signed: &SignedImportMemberPermissionV1,
939 expected: &ImportOwnerExpectation<'_>,
940 current: bool,
941) -> Result<(), Reject> {
942 let p = signed.body.as_ref().ok_or(Reject::ImportPermission)?;
943 if p.format_version != 1 || p.purpose != 1 {
944 return Err(Reject::ImportPermission);
945 }
946 identity(p.identity.as_ref().ok_or(Reject::Canonical)?)?;
947 if p.identity.as_ref() != Some(expected.identity)
948 || p.owner_chain_digest != expected.owner_chain_digest
949 {
950 return Err(Reject::Root);
951 }
952 width(&p.logical_job_id, 16)?;
953 width(&p.retry_lineage_id, 16)?;
954 width(&p.subject_public_key, 32)?;
955 width(&p.cancellation_id, 32)?;
956 width(&p.nonce, 32)?;
957 width(&p.owner_chain_digest, 32)?;
958 validate_scope(p.scope.as_ref().ok_or(Reject::Canonical)?)?;
959 validity(
960 p.not_before_unix_seconds,
961 p.expires_at_unix_seconds,
962 expected.now_unix_seconds,
963 current,
964 )?;
965 if p.expires_at_unix_seconds > expected.authority_expires_at_seconds {
966 return Err(Reject::Scope);
967 }
968 verify_authorization_signature(
969 expected.owner_public_key,
970 PERMISSION_DOMAIN,
971 p,
972 signed.owner_signature.as_ref().ok_or(Reject::Signature)?,
973 )
974}
975
976#[derive(Debug, Clone, PartialEq)]
977pub struct VerifiedImportDelegation {
978 body: ImportJobDelegationV1,
979 digest: Vec<u8>,
980 member: Option<SignedImportMemberPermissionV1>,
981}
982impl VerifiedImportDelegation {
983 pub fn body(&self) -> &ImportJobDelegationV1 {
984 &self.body
985 }
986 pub fn digest(&self) -> &[u8] {
987 &self.digest
988 }
989}
990pub fn verify_delegation(
991 signed: &SignedImportJobDelegationV1,
992 member: Option<&SignedImportMemberPermissionV1>,
993 expected: &ImportOwnerExpectation<'_>,
994) -> Result<VerifiedImportDelegation, Reject> {
995 verify_delegation_inner(signed, member, expected, true)
996}
997fn verify_delegation_inner(
998 signed: &SignedImportJobDelegationV1,
999 member: Option<&SignedImportMemberPermissionV1>,
1000 expected: &ImportOwnerExpectation<'_>,
1001 current: bool,
1002) -> Result<VerifiedImportDelegation, Reject> {
1003 let d = signed.body.as_ref().ok_or(Reject::Canonical)?;
1004 if d.format_version != 1 || d.purpose != 1 {
1005 return Err(Reject::Version);
1006 }
1007 identity(d.identity.as_ref().ok_or(Reject::Canonical)?)?;
1008 if d.identity.as_ref() != Some(expected.identity)
1009 || d.owner_chain_digest != expected.owner_chain_digest
1010 {
1011 return Err(Reject::Root);
1012 }
1013 for v in [&d.delegation_id, &d.logical_job_id, &d.retry_lineage_id] {
1014 width(v, 16)?;
1015 if v.iter().all(|b| *b == 0) {
1016 return Err(Reject::Canonical);
1017 }
1018 }
1019 for v in [
1020 &d.job_public_key,
1021 &d.job_key_id,
1022 &d.delegating_public_key,
1023 &d.parent_permission_digest,
1024 &d.owner_chain_digest,
1025 &d.cancellation_id,
1026 &d.predecessor_delegation_digest,
1027 ] {
1028 width(v, 32)?;
1029 }
1030 if d.job_key_id != key_id(&d.job_public_key) {
1031 return Err(Reject::Canonical);
1032 }
1033 if d.job_public_key == d.delegating_public_key
1034 || d.job_public_key == expected.owner_public_key
1035 || expected.forbidden_job_keys.contains(&d.job_public_key)
1036 {
1037 return Err(Reject::KeyRole);
1038 }
1039 if expected
1040 .known_job_associations
1041 .iter()
1042 .any(|(k, j)| k == &d.job_public_key && j != &d.logical_job_id)
1043 {
1044 return Err(Reject::Scope);
1045 }
1046 let scope = d.scope.as_ref().ok_or(Reject::Canonical)?;
1047 validate_scope(scope)?;
1048 if d.branch_manifest.len() != scope.branches.len() {
1049 return Err(Reject::Scope);
1050 }
1051 for (m, b) in d.branch_manifest.iter().zip(&scope.branches) {
1052 width(&m.genesis_authority_digest, 32)?;
1053 if m.limit.as_ref() != Some(b) {
1054 return Err(Reject::Scope);
1055 }
1056 }
1057 validity(
1058 d.not_before_unix_seconds,
1059 d.expires_at_unix_seconds,
1060 expected.now_unix_seconds,
1061 current,
1062 )?;
1063 if d.expires_at_unix_seconds > expected.authority_expires_at_seconds {
1064 return Err(Reject::Scope);
1065 }
1066 if d.delegating_public_key == expected.owner_public_key {
1067 if member.is_some() || d.parent_permission_digest != vec![0; 32] {
1068 return Err(Reject::ImportPermission);
1069 }
1070 } else {
1071 let member = member.ok_or(Reject::ImportPermission)?;
1072 verify_member_permission_inner(member, expected, current)?;
1073 let p = member.body.as_ref().ok_or(Reject::ImportPermission)?;
1074 if d.parent_permission_digest != signed_permission_digest(member)?
1075 || d.delegating_public_key != p.subject_public_key
1076 || d.logical_job_id != p.logical_job_id
1077 || d.retry_lineage_id != p.retry_lineage_id
1078 || d.not_before_unix_seconds < p.not_before_unix_seconds
1079 || d.expires_at_unix_seconds > p.expires_at_unix_seconds
1080 || !scope_subset(scope, p.scope.as_ref().ok_or(Reject::Canonical)?)
1081 {
1082 return Err(Reject::Scope);
1083 }
1084 }
1085 verify_authorization_signature(
1086 &d.delegating_public_key,
1087 DELEGATION_DOMAIN,
1088 d,
1089 signed
1090 .delegating_signature
1091 .as_ref()
1092 .ok_or(Reject::Signature)?,
1093 )?;
1094 Ok(VerifiedImportDelegation {
1095 body: d.clone(),
1096 digest: signed_delegation_digest(signed)?,
1097 member: member.cloned(),
1098 })
1099}
1100pub fn delegation_preparation(d: &ImportJobDelegationV1) -> ImportJobPreparationV1 {
1103 ImportJobPreparationV1 {
1104 format_version: d.format_version,
1105 identity: d.identity.clone(),
1106 delegation_id: d.delegation_id.clone(),
1107 logical_job_id: d.logical_job_id.clone(),
1108 retry_lineage_id: d.retry_lineage_id.clone(),
1109 job_public_key: d.job_public_key.clone(),
1110 job_key_id: d.job_key_id.clone(),
1111 owner_chain_digest: d.owner_chain_digest.clone(),
1112 purpose: d.purpose,
1113 scope: d.scope.clone(),
1114 cancellation_id: d.cancellation_id.clone(),
1115 predecessor_delegation_digest: d.predecessor_delegation_digest.clone(),
1116 }
1117}
1118
1119pub fn verify_prepared_delegation(
1124 prepared: &PrepareImportJobResponse,
1125 signed: &SignedImportJobDelegationV1,
1126 member: Option<&SignedImportMemberPermissionV1>,
1127 geneses: &[SignedImportGenesisAuthorityV1],
1128 expected: &ImportOwnerExpectation<'_>,
1129) -> Result<VerifiedImportDelegation, Reject> {
1130 verify_prepared_inner(prepared, signed, member, geneses, expected, false)
1131}
1132pub fn preflight_prepared_delegation(
1134 prepared: &PrepareImportJobResponse,
1135 signed: &SignedImportJobDelegationV1,
1136 member: Option<&SignedImportMemberPermissionV1>,
1137 geneses: &[SignedImportGenesisAuthorityV1],
1138 expected: &ImportOwnerExpectation<'_>,
1139) -> Result<(), Reject> {
1140 verify_prepared_inner(prepared, signed, member, geneses, expected, true).map(|_| ())
1141}
1142fn verify_prepared_inner(
1143 prepared: &PrepareImportJobResponse,
1144 signed: &SignedImportJobDelegationV1,
1145 member: Option<&SignedImportMemberPermissionV1>,
1146 geneses: &[SignedImportGenesisAuthorityV1],
1147 expected: &ImportOwnerExpectation<'_>,
1148 browser: bool,
1149) -> Result<VerifiedImportDelegation, Reject> {
1150 let proposal = prepared.proposal.as_ref().ok_or(Reject::Canonical)?;
1151 let d = signed.body.as_ref().ok_or(Reject::Canonical)?;
1152 if canonical(proposal)? != canonical(&delegation_preparation(d))? {
1153 return Err(Reject::PreparedFields);
1154 }
1155 let scope = proposal.scope.as_ref().ok_or(Reject::Canonical)?;
1156 if d.branch_manifest.len() != scope.branches.len() {
1157 return Err(Reject::PreparedFields);
1158 }
1159 for (m, b) in d.branch_manifest.iter().zip(&scope.branches) {
1160 let limit = m.limit.as_ref().ok_or(Reject::PreparedFields)?;
1161 if canonical(limit)? != canonical(b)? {
1162 return Err(Reject::PreparedFields);
1163 }
1164 }
1165 let now = i128::from(expected.now_unix_seconds);
1168 let start = i128::from(d.not_before_unix_seconds);
1169 let end = i128::from(d.expires_at_unix_seconds);
1170 let at = i128::from(prepared.prepared_at_unix_seconds);
1171 let skew = i128::from(prepared.clock_skew_allowance_seconds);
1172 if at < 0
1173 || now < 0
1174 || if browser { now + skew < at } else { now < at }
1175 || i128::from(prepared.reservation_expires_at_unix_seconds) != at + 3600
1176 || now >= i128::from(prepared.reservation_expires_at_unix_seconds)
1177 {
1178 return Err(Reject::Expired);
1179 }
1180 if prepared.max_validity_duration_seconds == 0
1181 || start < 0
1182 || start < at - skew
1183 || start > now + skew
1184 || end <= start
1185 || end <= now
1186 || end - start > i128::from(prepared.max_validity_duration_seconds)
1187 {
1188 return Err(Reject::ValidityBounds);
1189 }
1190 if let Some(parent) = member {
1191 if browser {
1192 let p = parent.body.as_ref().ok_or(Reject::ImportPermission)?;
1193 verify_member_permission_inner(parent, expected, false)?;
1194 if i128::from(p.not_before_unix_seconds) > now + skew
1195 || i128::from(p.expires_at_unix_seconds) <= now
1196 {
1197 return Err(Reject::Expired);
1198 }
1199 } else {
1200 verify_member_permission(parent, expected)?;
1201 }
1202 }
1203 let at_start = ImportOwnerExpectation {
1207 now_unix_seconds: expected.now_unix_seconds.max(d.not_before_unix_seconds),
1208 ..*expected
1209 };
1210 let verified = verify_delegation_inner(
1211 signed,
1212 member,
1213 if browser { expected } else { &at_start },
1214 !browser,
1215 )?;
1216 if geneses.len() != d.branch_manifest.len() {
1217 return Err(Reject::GenesisBinding);
1218 }
1219 for m in &d.branch_manifest {
1220 let branch = m.limit.as_ref().ok_or(Reject::Canonical)?;
1221 let g = geneses
1222 .iter()
1223 .find(|g| signed_genesis_digest(g).is_ok_and(|h| h == m.genesis_authority_digest))
1224 .ok_or(Reject::GenesisBinding)?;
1225 let body = g.body.as_ref().ok_or(Reject::GenesisBinding)?;
1226 if body.genesis_digest != branch.genesis_digest {
1227 return Err(Reject::GenesisBinding);
1228 }
1229 if d.predecessor_delegation_digest.iter().all(|b| *b == 0) {
1230 verify_genesis_authority(
1231 g,
1232 &verified,
1233 &branch.genesis_digest,
1234 &body.original_creator_signature,
1235 &body.creator_authority_envelope_digest,
1236 )?;
1237 }
1238 }
1239 Ok(verified)
1240}
1241
1242pub fn verify_genesis_authority(
1243 signed: &SignedImportGenesisAuthorityV1,
1244 delegation: &VerifiedImportDelegation,
1245 original_genesis_digest: &[u8],
1246 original_signature: &[u8],
1247 envelope_digest: &[u8],
1248) -> Result<(), Reject> {
1249 let g = signed.body.as_ref().ok_or(Reject::Canonical)?;
1250 let d = &delegation.body;
1251 if g.format_version != 1 {
1252 return Err(Reject::Version);
1253 }
1254 width(&g.original_creator_signature, 64)?;
1255 for v in [
1256 &g.genesis_digest,
1257 &g.creator_public_key,
1258 &g.creator_authority_envelope_digest,
1259 &g.parent_permission_digest,
1260 &g.owner_chain_digest,
1261 ] {
1262 width(v, 32)?;
1263 }
1264 if g.identity != d.identity
1265 || g.creator_public_key != d.delegating_public_key
1266 || g.parent_permission_digest != d.parent_permission_digest
1267 || g.owner_chain_digest != d.owner_chain_digest
1268 || g.genesis_digest != original_genesis_digest
1269 || g.original_creator_signature != original_signature
1270 || g.creator_authority_envelope_digest != envelope_digest
1271 || !d.branch_manifest.iter().any(|m| {
1272 m.limit
1273 .as_ref()
1274 .is_some_and(|b| b.genesis_digest == g.genesis_digest)
1275 && signed_genesis_digest(signed).is_ok_and(|h| h == m.genesis_authority_digest)
1276 })
1277 {
1278 return Err(Reject::Scope);
1279 }
1280 verify_authorization_signature(
1281 &g.creator_public_key,
1282 GENESIS_DOMAIN,
1283 g,
1284 signed.creator_signature.as_ref().ok_or(Reject::Signature)?,
1285 )
1286}
1287pub fn verify_operation(
1290 signed: &SignedDelegatedImportOperationV1,
1291 delegation: &VerifiedImportDelegation,
1292) -> Result<(), Reject> {
1293 let o = signed.body.as_ref().ok_or(Reject::Canonical)?;
1294 let d = &delegation.body;
1295 if o.format_version != 1 {
1296 return Err(Reject::Version);
1297 }
1298 let id = d.identity.as_ref().ok_or(Reject::Canonical)?;
1299 width(&o.physical_operation_id, 16)?;
1300 for v in [
1301 &o.spool_genesis_digest,
1302 &o.delegation_digest,
1303 &o.genesis_digest,
1304 &o.target_thread_id,
1305 &o.expected_frontier_digest,
1306 &o.resulting_frontier_digest,
1307 &o.resulting_content_digest,
1308 &o.options_digest,
1309 ] {
1310 width(v, 32)?;
1311 }
1312 width(&o.spool_uuid, 16)?;
1313 width(&o.logical_job_id, 16)?;
1314 width(&o.retry_lineage_id, 16)?;
1315 let scope = d.scope.as_ref().ok_or(Reject::Canonical)?;
1316 let b = scope
1317 .branches
1318 .iter()
1319 .find(|b| b.ref_name == o.ref_name && b.slot_id == o.slot_id)
1320 .ok_or(Reject::Scope)?;
1321 let oid_len = match o.hash_algorithm {
1322 1 => 20,
1323 2 => 32,
1324 _ => return Err(Reject::Version),
1325 };
1326 width(&o.observed_commit_oid, oid_len)?;
1327 if o.spool_uuid != id.spool_uuid
1328 || o.spool_genesis_digest != id.spool_genesis_digest
1329 || o.logical_job_id != d.logical_job_id
1330 || o.retry_lineage_id != d.retry_lineage_id
1331 || o.delegation_digest != delegation.digest
1332 || o.hash_algorithm != b.hash_algorithm
1333 || (b.ref_mode == 1 && o.observed_commit_oid != b.pinned_commit_oid)
1334 || o.genesis_digest != b.genesis_digest
1335 || o.target_thread_id != b.target_thread_id
1336 || o.expected_frontier_digest != b.expected_frontier_digest
1337 || o.result_bytes > b.max_result_bytes
1338 || o.result_bytes == 0
1339 || o.options_digest != scope.options_digest
1340 || o.converter_version != scope.converter_version
1341 {
1342 return Err(Reject::Scope);
1343 }
1344 verify_authorization_signature(
1345 &d.job_public_key,
1346 OPERATION_DOMAIN,
1347 o,
1348 signed.job_signature.as_ref().ok_or(Reject::Signature)?,
1349 )
1350}
1351pub fn verify_new_operation(
1352 signed: &SignedDelegatedImportOperationV1,
1353 delegation: &VerifiedImportDelegation,
1354 now_seconds: i64,
1355) -> Result<(), Reject> {
1356 interval(
1357 delegation.body.not_before_unix_seconds,
1358 delegation.body.expires_at_unix_seconds,
1359 now_seconds,
1360 )?;
1361 verify_operation(signed, delegation)
1362}
1363pub fn validate_manifest(m: &ImportResultManifestV1) -> Result<(), Reject> {
1364 if m.format_version != 1 {
1365 return Err(Reject::Version);
1366 }
1367 width(&m.logical_job_id, 16)?;
1368 width(&m.retry_lineage_id, 16)?;
1369 if m.slots.len() > MAX_BRANCHES {
1370 return Err(Reject::Bounds);
1371 }
1372 for (i, s) in m.slots.iter().enumerate() {
1373 width(&s.signed_operation_digest, 32)?;
1374 width(&s.resulting_frontier_digest, 32)?;
1375 if !s.ref_name.starts_with("refs/heads/") || !s.ref_name.is_ascii() {
1376 return Err(Reject::Canonical);
1377 }
1378 if s.ref_name.len() > 1024 || s.result_bytes == 0 || s.result_bytes > MAX_RESULT_BYTES {
1379 return Err(Reject::Bounds);
1380 }
1381 if i > 0 && (&m.slots[i - 1].ref_name, m.slots[i - 1].slot_id) >= (&s.ref_name, s.slot_id) {
1382 return Err(Reject::Canonical);
1383 }
1384 }
1385 Ok(())
1386}
1387pub fn verify_renewal(
1390 signed: &SignedImportJobRenewalV1,
1391 previous: &VerifiedImportDelegation,
1392 committed: &ImportResultManifestV1,
1393 authority_epoch: u64,
1394 member: Option<&SignedImportMemberPermissionV1>,
1395 expected: &ImportOwnerExpectation<'_>,
1396) -> Result<VerifiedImportDelegation, Reject> {
1397 let r = signed.body.as_ref().ok_or(Reject::Canonical)?;
1398 if r.format_version != 1 {
1399 return Err(Reject::Version);
1400 }
1401 validate_manifest(committed)?;
1402 if r.expected_authority_epoch != authority_epoch {
1403 return Err(Reject::StaleContext);
1404 }
1405 if r.predecessor_delegation_digest != previous.digest {
1406 return Err(Reject::RenewalFork);
1407 }
1408 if r.committed_manifest_digest != manifest_digest(committed)? {
1409 return Err(Reject::StaleManifest);
1410 }
1411 let signed_next = r.replacement.as_ref().ok_or(Reject::Canonical)?;
1412 let next = verify_delegation(signed_next, member, expected)?;
1413 let before = &previous.body;
1414 let after = &next.body;
1415 let before_id = before.identity.as_ref().ok_or(Reject::Canonical)?;
1416 let after_id = after.identity.as_ref().ok_or(Reject::Canonical)?;
1417 if after.logical_job_id != before.logical_job_id
1418 || after.retry_lineage_id != before.retry_lineage_id
1419 || committed.logical_job_id != before.logical_job_id
1420 || committed.retry_lineage_id != before.retry_lineage_id
1421 || after_id.spool_uuid != before_id.spool_uuid
1422 || after_id.spool_genesis_digest != before_id.spool_genesis_digest
1423 || after.predecessor_delegation_digest != previous.digest
1424 || after.job_public_key == before.job_public_key
1425 || after.delegation_id == before.delegation_id
1426 {
1427 return Err(Reject::RenewalFork);
1428 }
1429 let old_scope = before.scope.as_ref().ok_or(Reject::Canonical)?;
1430 let new_scope = after.scope.as_ref().ok_or(Reject::Canonical)?;
1431 if !scope_subset(new_scope, old_scope) {
1432 return Err(Reject::RenewalFork);
1433 }
1434 let old_slots = &old_scope.branches;
1435 let mut consumed = 0_u64;
1436 for slot in &committed.slots {
1437 if let Some(b) = old_slots
1441 .iter()
1442 .find(|b| b.ref_name == slot.ref_name && b.slot_id == slot.slot_id)
1443 {
1444 if slot.result_bytes > b.max_result_bytes {
1445 return Err(Reject::RenewalFork);
1446 }
1447 consumed = consumed
1448 .checked_add(slot.result_bytes)
1449 .ok_or(Reject::Bounds)?;
1450 }
1451 if new_scope
1452 .branches
1453 .iter()
1454 .any(|b| b.ref_name == slot.ref_name && b.slot_id == slot.slot_id)
1455 {
1456 return Err(Reject::CommittedSlot);
1457 }
1458 }
1459 let removed = old_slots
1460 .iter()
1461 .filter(|b| {
1462 committed
1463 .slots
1464 .iter()
1465 .any(|s| s.ref_name == b.ref_name && s.slot_id == b.slot_id)
1466 })
1467 .count();
1468 if new_scope.max_operations as usize > old_scope.max_operations as usize - removed
1469 || new_scope.max_result_bytes
1470 > old_scope
1471 .max_result_bytes
1472 .checked_sub(consumed)
1473 .ok_or(Reject::RenewalFork)?
1474 || after.branch_manifest.iter().any(|m| {
1475 !before.branch_manifest.iter().any(|old| {
1476 old.genesis_authority_digest == m.genesis_authority_digest
1477 && old
1478 .limit
1479 .as_ref()
1480 .zip(m.limit.as_ref())
1481 .is_some_and(|(a, b)| {
1482 a.ref_name == b.ref_name && a.genesis_digest == b.genesis_digest
1483 })
1484 })
1485 })
1486 {
1487 return Err(Reject::RenewalFork);
1488 }
1489 if let Some(parent) = member {
1490 let p = parent.body.as_ref().ok_or(Reject::ImportPermission)?;
1491 remaining_scope(
1492 p.scope.as_ref().ok_or(Reject::Canonical)?,
1493 old_scope,
1494 committed,
1495 )?;
1496 if let Some(old_parent) = &previous.member {
1497 let old = old_parent.body.as_ref().ok_or(Reject::ImportPermission)?;
1498 if p.subject_public_key == old.subject_public_key
1499 && p.logical_job_id == old.logical_job_id
1500 && p.retry_lineage_id == old.retry_lineage_id
1501 && (p.cancellation_id != old.cancellation_id
1502 || (parent != old_parent && p.nonce == old.nonce))
1503 {
1504 return Err(Reject::ImportPermission);
1505 }
1506 }
1507 }
1508 verify_authorization_signature(
1509 &after.delegating_public_key,
1510 RENEWAL_DOMAIN,
1511 r,
1512 signed
1513 .delegating_signature
1514 .as_ref()
1515 .ok_or(Reject::Signature)?,
1516 )?;
1517 Ok(next)
1518}
1519pub fn check_slot_replay(
1522 committed: &ImportResultManifestV1,
1523 signed: &SignedDelegatedImportOperationV1,
1524) -> Result<bool, Reject> {
1525 validate_manifest(committed)?;
1526 let o = signed.body.as_ref().ok_or(Reject::Canonical)?;
1527 if committed.logical_job_id != o.logical_job_id
1528 || committed.retry_lineage_id != o.retry_lineage_id
1529 {
1530 return Err(Reject::Scope);
1531 }
1532 match committed
1533 .slots
1534 .iter()
1535 .find(|s| s.ref_name == o.ref_name && s.slot_id == o.slot_id)
1536 {
1537 Some(s)
1538 if s.signed_operation_digest == signed_operation_digest(signed)?
1539 && s.resulting_frontier_digest == o.resulting_frontier_digest
1540 && s.result_bytes == o.result_bytes =>
1541 {
1542 Ok(true)
1543 }
1544 Some(_) => Err(Reject::SlotConflict),
1545 None => Ok(false),
1546 }
1547}
1548pub fn verify_publication(
1552 operation: &SignedDelegatedImportOperationV1,
1553 delegation: &VerifiedImportDelegation,
1554 manifest: &ImportResultManifestV1,
1555 statement: &crate::heddle::api::common::SignedHostedWitnessStatementV1,
1556 set: &crate::witness_trust::VerifiedWitnessSet,
1557 proof: Option<&crate::heddle::api::common::HostedWitnessHistoryProofV1>,
1558 now_ms: i64,
1559) -> Result<crate::witness_trust::ResolvedWitnessStatement, Reject> {
1560 validate_statement_boundary(statement.body.as_ref().ok_or(Reject::Canonical)?)?;
1561 verify_operation(operation, delegation)?;
1562 if !check_slot_replay(manifest, operation)? {
1563 return Err(Reject::Scope);
1564 }
1565 let o = operation.body.as_ref().ok_or(Reject::Canonical)?;
1566 let d = &delegation.body;
1567 let id = d.identity.as_ref().ok_or(Reject::Canonical)?;
1568 let s = statement.body.as_ref().ok_or(Reject::Canonical)?;
1569 let payload = ImportPublicationWitnessV1 {
1570 format_version: 1,
1571 signed_operation_digest: signed_operation_digest(operation)?,
1572 delegation_digest: delegation.digest.clone(),
1573 logical_job_id: o.logical_job_id.clone(),
1574 retry_lineage_id: o.retry_lineage_id.clone(),
1575 physical_operation_id: o.physical_operation_id.clone(),
1576 ref_name: o.ref_name.clone(),
1577 slot_id: o.slot_id,
1578 hash_algorithm: o.hash_algorithm,
1579 observed_commit_oid: o.observed_commit_oid.clone(),
1580 expected_frontier_digest: o.expected_frontier_digest.clone(),
1581 resulting_frontier_digest: o.resulting_frontier_digest.clone(),
1582 terminal_manifest_digest: manifest_digest(manifest)?,
1583 };
1584 if s.purpose != 3
1585 || s.spool_uuid != id.spool_uuid
1586 || s.spool_genesis_digest != id.spool_genesis_digest
1587 || s.owner_id != id.owner_id
1588 || s.owner_state_hash != id.owner_state_hash
1589 || s.ownership_transfer_sequence != id.ownership_transfer_sequence
1590 || s.authority_digest != delegation.digest
1591 || s.original_signatures_digest
1592 != hash(&[&operation
1593 .job_signature
1594 .as_ref()
1595 .ok_or(Reject::Signature)?
1596 .signature])
1597 || s.canonical_payload != canonical(&payload)?
1598 || s.basis != 1
1599 {
1600 return Err(Reject::Scope);
1601 }
1602 interval(
1603 d.not_before_unix_seconds,
1604 d.expires_at_unix_seconds,
1605 s.observed_at_unix_millis / 1000,
1606 )?;
1607 crate::witness_trust::resolve_statement(set, statement, proof, false, now_ms)
1608}
1609pub fn require_hybrid_peer(
1610 protocol: Option<&crate::heddle::api::common::ProtocolCompatibility>,
1611) -> Result<(), Reject> {
1612 let protocol = protocol.ok_or(Reject::Protocol)?;
1613 if protocol.protocol_version != 2 || protocol.mandatory_features != [1] {
1614 return Err(Reject::Protocol);
1615 }
1616 Ok(())
1617}
1618
1619pub fn check_job_fence(
1622 logical_job_id: &[u8],
1623 active_delegation_digest: &[u8],
1624 expected_epoch: u64,
1625 active: &VerifiedImportDelegation,
1626 durable_epoch: u64,
1627) -> Result<(), Reject> {
1628 if logical_job_id != active.body.logical_job_id {
1629 return Err(Reject::Scope);
1630 }
1631 if expected_epoch != durable_epoch || active_delegation_digest != active.digest {
1632 return Err(Reject::StaleContext);
1633 }
1634 Ok(())
1635}
1636pub fn validate_public_bundle(bundle: &ImportPublicProofBundleV1) -> Result<(), Reject> {
1637 validate_bundle_bounds(bundle)?;
1638 validate_bundle_history(bundle, true)
1639}
1640fn validate_bundle_bounds(bundle: &ImportPublicProofBundleV1) -> Result<(), Reject> {
1641 use prost::Message;
1642 if bundle.format_version != 1 {
1643 return Err(Reject::Version);
1644 }
1645 if bundle.encoded_len() > MAX_BUNDLE_BYTES
1646 || bundle.owner_histories.len() > 64
1647 || bundle.ownership_transfers.len() > 64
1648 || bundle.genesis_authorities.len() > MAX_BRANCHES
1649 || bundle.delegations.len() > 64
1650 || bundle.renewals.len() > 63
1651 || bundle.operations.len() > MAX_BRANCHES
1652 || bundle.statements.len() > 1024
1653 || bundle.history_proofs.len() > 1024
1654 || bundle.policies.len() > 256
1655 || bundle.original_geneses.len() > MAX_BRANCHES
1656 || bundle.creator_authority_envelopes.len() > MAX_BRANCHES
1657 || bundle.member_permissions.len() > 64
1658 || bundle.manifests.len() > 320
1659 || bundle.genesis_witnesses.len() > 256
1660 || bundle.authority_witnesses.len() > 256
1661 || bundle.landing_witnesses.len() > 256
1662 {
1663 return Err(Reject::Bounds);
1664 }
1665 Ok(())
1666}
1667
1668pub enum ImportPermissionEvidence<'a> {
1671 Import(&'a SignedImportMemberPermissionV1),
1672 OwnerCapability(&'a SignedOwnerCapability),
1673 OnlineRole(&'a str),
1674}
1675pub fn select_import_permission(
1676 evidence: ImportPermissionEvidence<'_>,
1677) -> Result<&SignedImportMemberPermissionV1, Reject> {
1678 match evidence {
1679 ImportPermissionEvidence::Import(p) => Ok(p),
1680 ImportPermissionEvidence::OwnerCapability(_) | ImportPermissionEvidence::OnlineRole(_) => {
1681 Err(Reject::ImportPermission)
1682 }
1683 }
1684}
1685pub fn require_import_operation_format(format: &str) -> Result<(), Reject> {
1687 if format != OPERATION_DOMAIN {
1688 return Err(Reject::Protocol);
1689 }
1690 Ok(())
1691}
1692pub fn frontier_digest(frontier: &ImportFrontierV1) -> Result<Vec<u8>, Reject> {
1693 if frontier.format_version != 1 {
1694 return Err(Reject::Version);
1695 }
1696 width(&frontier.thread_id, 32)?;
1697 if frontier.operation_ids.len() > 128 {
1698 return Err(Reject::Bounds);
1699 }
1700 for id in &frontier.operation_ids {
1701 width(id, 32)?;
1702 }
1703 if frontier.operation_ids.windows(2).any(|w| w[0] >= w[1]) {
1704 return Err(Reject::Canonical);
1705 }
1706 signing_digest("heddle-import-frontier-v1", frontier)
1707}
1708pub fn content_digest(content: &ImportContentV1) -> Result<Vec<u8>, Reject> {
1709 if content.format_version != 1 {
1710 return Err(Reject::Version);
1711 }
1712 if content.canonical_capture.is_empty()
1713 || content.canonical_capture.len() > MAX_RESULT_BYTES as usize
1714 {
1715 return Err(Reject::Bounds);
1716 }
1717 signing_digest("heddle-import-content-v1", content)
1718}
1719pub fn signed_native_digest(record: &SignedRecord) -> Result<Vec<u8>, Reject> {
1720 signing_digest("heddle-signed-native-record-v1", record)
1721}
1722fn verify_native(record: &SignedRecord, format: &str) -> Result<(), Reject> {
1723 if record.format != format {
1724 return Err(Reject::Version);
1725 }
1726 if record.canonical_record.is_empty()
1727 || record.canonical_record.len() > MAX_RECORD_BYTES
1728 || record.signatures.is_empty()
1729 || record.signatures.len() > 16
1730 {
1731 return Err(Reject::Bounds);
1732 }
1733 let mut previous: Option<&[u8]> = None;
1734 let input = [format.as_bytes(), b"\0", &record.canonical_record].concat();
1735 for s in &record.signatures {
1736 if previous.is_some_and(|p| p >= s.public_key.as_slice()) {
1737 return Err(Reject::Canonical);
1738 }
1739 verify(&s.public_key, &input, &s.signature)?;
1740 previous = Some(&s.public_key);
1741 }
1742 Ok(())
1743}
1744pub fn verify_boundary_acceptance(e: &ImportBoundaryAcceptanceV1) -> Result<(), Reject> {
1748 let binding = e.binding.as_ref().ok_or(Reject::BoundaryAcceptance)?;
1749 validate_boundary_binding(binding)?;
1750 let acceptance = e
1751 .signed_acceptance
1752 .as_ref()
1753 .ok_or(Reject::BoundaryAcceptance)?;
1754 verify_native(acceptance, "heddle-original-boundary-acceptance-v1")?;
1755 if acceptance.signatures.len() != 1 {
1756 return Err(Reject::Signature);
1757 }
1758 if e.originals_manifest.is_empty()
1759 || e.publication_intent.is_empty()
1760 || e.originals_manifest.len() > MAX_RECORD_BYTES
1761 || e.publication_intent.len() > MAX_RECORD_BYTES
1762 || e.original_receipts.is_empty()
1763 || e.original_receipts.len() > 128
1764 {
1765 return Err(Reject::Bounds);
1766 }
1767 if binding.acceptance_id != native_id(acceptance)
1768 || binding.signed_acceptance_digest != signed_native_digest(acceptance)?
1769 || binding.originals_manifest_digest
1770 != boundary_octets_digest(
1771 "heddle-boundary-originals-manifest-v1",
1772 &e.originals_manifest,
1773 )
1774 || binding.publication_intent_digest
1775 != boundary_octets_digest(
1776 "heddle-boundary-publication-intent-v1",
1777 &e.publication_intent,
1778 )
1779 {
1780 return Err(Reject::BoundaryAcceptance);
1781 }
1782 let native: NativeBoundarySelection =
1783 rmp_serde::from_slice(&acceptance.canonical_record).map_err(|_| Reject::Canonical)?;
1784 if native.originals_manifest.as_slice()
1785 != native_octets_id(
1786 "heddle-original-publication-manifest-v1",
1787 &e.originals_manifest,
1788 )
1789 || native.publication_intent.as_slice()
1790 != native_octets_id(
1791 "heddle-original-publication-intent-v1",
1792 &e.publication_intent,
1793 )
1794 {
1795 return Err(Reject::BoundaryAcceptance);
1796 }
1797 let mut digests = Vec::new();
1798 for receipt in &e.original_receipts {
1799 if ![
1800 "heddle-thread-genesis-admission-v2",
1801 "heddle-thread-authority-admission-v3",
1802 ]
1803 .contains(&receipt.format.as_str())
1804 {
1805 return Err(Reject::Version);
1806 }
1807 verify_native(receipt, &receipt.format)?;
1808 if receipt.signatures.len() != 1 {
1809 return Err(Reject::Signature);
1810 }
1811 let native: NativeBoundaryReceipt =
1812 rmp_serde::from_slice(&receipt.canonical_record).map_err(|_| Reject::Canonical)?;
1813 if native.basis
1814 != (NativeBoundaryBasis::BoundaryAcceptance {
1815 acceptance: binding
1816 .acceptance_id
1817 .as_slice()
1818 .try_into()
1819 .map_err(|_| Reject::Canonical)?,
1820 })
1821 {
1822 return Err(Reject::BoundaryAcceptance);
1823 }
1824 digests.push(signed_native_digest(receipt)?);
1825 }
1826 if digests != binding.original_receipt_digests {
1827 return Err(Reject::BoundaryAcceptance);
1828 }
1829 Ok(())
1830}
1831#[derive(serde::Deserialize)]
1834struct NativeBoundarySelection {
1835 originals_manifest: [u8; 32],
1836 publication_intent: [u8; 32],
1837}
1838#[derive(serde::Deserialize, PartialEq)]
1839enum NativeBoundaryBasis {
1840 OriginalAuthority,
1841 BoundaryAcceptance { acceptance: [u8; 32] },
1842}
1843#[derive(serde::Deserialize)]
1844struct NativeBoundaryReceipt {
1845 basis: NativeBoundaryBasis,
1846 thread: [u8; 32],
1847 subject: Option<NativeBoundarySubject>,
1848}
1849#[derive(serde::Deserialize)]
1850enum NativeBoundarySubject {
1851 Operation([u8; 32]),
1852 OwnershipClaim([u8; 32]),
1853 OwnershipResolution([u8; 32]),
1854}
1855fn native_octets_id(format: &str, bytes: &[u8]) -> Vec<u8> {
1856 let mut h = blake3::Hasher::new();
1857 h.update(format.as_bytes());
1858 h.update(&(bytes.len() as u64).to_le_bytes());
1859 h.update(b"\0");
1860 h.update(bytes);
1861 h.finalize().as_bytes().to_vec()
1862}
1863fn boundary_original(
1864 e: &ImportBoundaryAcceptanceV1,
1865 original: &SignedRecord,
1866) -> Result<(), Reject> {
1867 let id = native_id(original);
1868 for receipt in &e.original_receipts {
1869 let value: NativeBoundaryReceipt =
1870 rmp_serde::from_slice(&receipt.canonical_record).map_err(|_| Reject::Canonical)?;
1871 let (format, subject) = match value.subject {
1872 None if receipt.format == "heddle-thread-genesis-admission-v2" => {
1873 ("heddle-thread-genesis-v1", value.thread)
1874 }
1875 Some(NativeBoundarySubject::Operation(id)) => ("heddle-thread-operation-v1", id),
1876 Some(NativeBoundarySubject::OwnershipClaim(id)) => {
1877 ("heddle-thread-ownership-claim-v1", id)
1878 }
1879 Some(NativeBoundarySubject::OwnershipResolution(id)) => {
1880 ("heddle-thread-ownership-resolution-v1", id)
1881 }
1882 _ => return Err(Reject::BoundaryAcceptance),
1883 };
1884 if original.format == format && id == subject {
1885 return Ok(());
1886 }
1887 }
1888 Err(Reject::BoundaryAcceptance)
1889}
1890pub fn boundary_octets_digest(domain: &str, bytes: &[u8]) -> Vec<u8> {
1891 hash(&[
1892 domain.as_bytes(),
1893 &(bytes.len() as u32).to_be_bytes(),
1894 bytes,
1895 ])
1896}
1897pub fn validate_boundary_binding(
1898 b: &crate::heddle::api::common::HostedWitnessBoundaryAcceptanceV1,
1899) -> Result<(), Reject> {
1900 if b.format_version != 1 {
1901 return Err(Reject::Version);
1902 }
1903 for digest in [
1904 &b.acceptance_id,
1905 &b.signed_acceptance_digest,
1906 &b.originals_manifest_digest,
1907 &b.publication_intent_digest,
1908 ] {
1909 width(digest, 32)?;
1910 }
1911 if b.original_receipt_digests.is_empty() || b.original_receipt_digests.len() > 128 {
1912 return Err(Reject::Bounds);
1913 }
1914 for digest in &b.original_receipt_digests {
1915 width(digest, 32)?;
1916 }
1917 if b.original_receipt_digests.windows(2).any(|w| w[0] >= w[1]) {
1918 return Err(Reject::Canonical);
1919 }
1920 Ok(())
1921}
1922pub fn validate_statement_boundary(
1923 s: &crate::heddle::api::common::HostedWitnessStatementV1,
1924) -> Result<(), Reject> {
1925 match (s.basis, s.boundary_acceptance.as_ref()) {
1926 (1, None) => Ok(()),
1927 (2, Some(b)) if s.purpose == 1 || s.purpose == 2 => validate_boundary_binding(b),
1928 _ => Err(Reject::BoundaryAcceptance),
1929 }
1930}
1931fn match_boundary(
1932 s: &crate::heddle::api::common::HostedWitnessStatementV1,
1933 evidence: &[ImportBoundaryAcceptanceV1],
1934) -> Result<(), Reject> {
1935 validate_statement_boundary(s)?;
1936 let mut previous = None;
1937 for e in evidence {
1938 verify_boundary_acceptance(e)?;
1939 let b = e.binding.as_ref().ok_or(Reject::BoundaryAcceptance)?;
1940 if previous.is_some_and(|p: &[u8]| p >= b.acceptance_id.as_slice()) {
1941 return Err(Reject::Canonical);
1942 }
1943 previous = Some(b.acceptance_id.as_slice());
1944 }
1945 if let Some(binding) = &s.boundary_acceptance
1946 && !evidence.iter().any(|e| e.binding.as_ref() == Some(binding))
1947 {
1948 return Err(Reject::BoundaryAcceptance);
1949 }
1950 Ok(())
1951}
1952fn native_dependencies(
1953 records: &[SignedRecord],
1954 evidence: &[ImportBoundaryAcceptanceV1],
1955) -> Result<(), Reject> {
1956 if records.len() > 128 {
1957 return Err(Reject::Bounds);
1958 }
1959 let mut previous = None;
1960 for record in records {
1961 match record.format.as_str() {
1962 "heddle-thread-genesis-v1"
1963 | "heddle-thread-operation-v1"
1964 | "heddle-thread-ownership-claim-v1"
1965 | "heddle-thread-ownership-resolution-v1" => (),
1966 "heddle-original-boundary-acceptance-v1"
1967 | "heddle-thread-genesis-admission-v2"
1968 | "heddle-thread-authority-admission-v3" => {
1969 if !evidence.iter().any(|e| {
1970 e.signed_acceptance.as_ref() == Some(record)
1971 || e.original_receipts.contains(record)
1972 }) {
1973 return Err(Reject::BoundaryAcceptance);
1974 }
1975 }
1976 _ => return Err(Reject::Version),
1977 }
1978 verify_native(record, &record.format)?;
1979 let digest = signed_native_digest(record)?;
1980 if previous.as_ref().is_some_and(|p| p >= &digest) {
1981 return Err(Reject::Canonical);
1982 }
1983 previous = Some(digest);
1984 }
1985 Ok(())
1986}
1987fn original_signatures(
1988 records: &[&SignedRecord],
1989 extra: &[RecordSignature],
1990) -> Result<Vec<u8>, Reject> {
1991 let signatures = records
1992 .iter()
1993 .flat_map(|r| r.signatures.iter())
1994 .chain(extra.iter())
1995 .collect::<Vec<_>>();
1996 let mut out = (signatures.len() as u32).to_be_bytes().to_vec();
1997 for s in signatures {
1998 s.write(&mut out)?;
1999 }
2000 Ok(hash(&[b"heddle-hosted-original-signatures-v1", &out]))
2001}
2002use crate::hybrid_codec::Canonical;
2003pub enum WitnessPayload<'a> {
2008 Genesis(&'a ImportGenesisWitnessV1),
2009 Authority(&'a ImportAuthorityWitnessV1),
2010 Landing(&'a HostedLandingWitnessV1),
2011}
2012pub fn verify_witness_payload(
2013 statement: &crate::heddle::api::common::HostedWitnessStatementV1,
2014 payload: WitnessPayload<'_>,
2015) -> Result<(), Reject> {
2016 let (purpose, bytes, authority, signatures, publisher) = match payload {
2017 WitnessPayload::Genesis(p) => {
2018 if p.format_version != 1 {
2019 return Err(Reject::Version);
2020 }
2021 let original = p.original_genesis.as_ref().ok_or(Reject::Canonical)?;
2022 let binding = p.binding.as_ref().ok_or(Reject::Canonical)?;
2023 let b = binding.body.as_ref().ok_or(Reject::Canonical)?;
2024 match_boundary(
2025 statement,
2026 &p.boundary_acceptance.iter().cloned().collect::<Vec<_>>(),
2027 )?;
2028 if let Some(e) = &p.boundary_acceptance {
2029 boundary_original(e, original)?;
2030 }
2031 if (statement.basis == 2) != p.boundary_acceptance.is_some() {
2032 return Err(Reject::BoundaryAcceptance);
2033 }
2034 verify_native(original, "heddle-thread-genesis-v1")?;
2035 if native_id(original) != b.genesis_digest {
2036 return Err(Reject::Scope);
2037 }
2038 if let Some(id) = &b.identity {
2039 if statement.spool_uuid != id.spool_uuid
2040 || statement.spool_genesis_digest != id.spool_genesis_digest
2041 || statement.owner_id != id.owner_id
2042 || statement.owner_state_hash != id.owner_state_hash
2043 || statement.ownership_transfer_sequence != id.ownership_transfer_sequence
2044 {
2045 return Err(Reject::Scope);
2046 }
2047 } else {
2048 return Err(Reject::Canonical);
2049 }
2050 let creator = original
2051 .signatures
2052 .iter()
2053 .find(|s| s.public_key == b.creator_public_key)
2054 .ok_or(Reject::Signature)?;
2055 if b.original_creator_signature != creator.signature
2056 || b.creator_authority_envelope_digest != hash(&[&p.creator_authority_envelope])
2057 {
2058 return Err(Reject::Scope);
2059 }
2060 verify_authorization_signature(
2061 &b.creator_public_key,
2062 GENESIS_DOMAIN,
2063 b,
2064 binding
2065 .creator_signature
2066 .as_ref()
2067 .ok_or(Reject::Signature)?,
2068 )?;
2069 (
2070 1,
2071 canonical(p)?,
2072 signed_genesis_digest(binding)?,
2073 original_signatures(&[original], &[])?,
2074 key_id(&b.creator_public_key),
2075 )
2076 }
2077 WitnessPayload::Authority(p) => {
2078 if p.format_version != 1 {
2079 return Err(Reject::Version);
2080 }
2081 let original = p.original.as_ref().ok_or(Reject::Canonical)?;
2082 let format = match p.kind {
2083 1 => "heddle-thread-operation-v1",
2084 2 => "heddle-thread-ownership-claim-v1",
2085 3 => "heddle-thread-ownership-resolution-v1",
2086 _ => return Err(Reject::Version),
2087 };
2088 verify_native(original, format)?;
2089 if (p.kind == 2 || p.kind == 3) && original.signatures.len() != 2 {
2090 return Err(Reject::Signature);
2091 }
2092 if !original
2093 .signatures
2094 .iter()
2095 .any(|s| key_id(&s.public_key) == statement.publisher_key_id)
2096 {
2097 return Err(Reject::Signature);
2098 }
2099 if p.authority_envelope.is_empty() || p.authority_envelope.len() > MAX_RECORD_BYTES {
2100 return Err(Reject::Bounds);
2101 }
2102 match_boundary(statement, &p.boundary_acceptances)?;
2103 if let Some(b) = &statement.boundary_acceptance {
2104 let e = p
2105 .boundary_acceptances
2106 .iter()
2107 .find(|e| e.binding.as_ref() == Some(b))
2108 .ok_or(Reject::BoundaryAcceptance)?;
2109 boundary_original(e, original)?;
2110 }
2111 native_dependencies(&p.dependencies, &p.boundary_acceptances)?;
2112 let records = std::iter::once(original)
2113 .chain(p.dependencies.iter())
2114 .collect::<Vec<_>>();
2115 (
2116 2,
2117 canonical(p)?,
2118 hash(&[
2119 b"heddle-hosted-authority-envelope-v1",
2120 &(p.authority_envelope.len() as u32).to_be_bytes(),
2121 &p.authority_envelope,
2122 ]),
2123 original_signatures(&records, &[])?,
2124 statement.publisher_key_id.clone(),
2125 )
2126 }
2127 WitnessPayload::Landing(p) => {
2128 if p.format_version != 1 {
2129 return Err(Reject::Version);
2130 }
2131 let execution = p.execution.as_ref().ok_or(Reject::Canonical)?;
2132 let source = p.source_operation.as_ref().ok_or(Reject::Canonical)?;
2133 let request = p.request.as_ref().ok_or(Reject::Canonical)?;
2134 if request.format_version != 1
2135 || request.method_path != "/heddle.api.v1alpha2.ThreadService/LandThread"
2136 {
2137 return Err(Reject::Version);
2138 }
2139 verify_native(execution, "heddle-thread-operation-v1")?;
2140 verify_native(source, "heddle-thread-operation-v1")?;
2141 match_boundary(statement, &[])?;
2142 native_dependencies(&p.review_evidence, &[])?;
2143 let signature = request.signature.as_ref().ok_or(Reject::Signature)?;
2144 if request.signing_identity
2145 != format!(
2146 "principal:device-key:{}",
2147 hex::encode(&signature.public_key)
2148 )
2149 {
2150 return Err(Reject::Signature);
2151 }
2152 width(&request.nonce, 16)?;
2153 if request.timestamp_millis <= 0
2154 || request.request_body.is_empty()
2155 || request.request_body.len() > MAX_RECORD_BYTES
2156 || p.authority_envelope.is_empty()
2157 || p.authority_envelope.len() > MAX_RECORD_BYTES
2158 {
2159 return Err(Reject::Bounds);
2160 }
2161 let input = crate::signing::unary_bytes(
2162 &request.signing_identity,
2163 &request.method_path,
2164 request.timestamp_millis,
2165 &request.nonce,
2166 &request.request_body,
2167 );
2168 verify(&signature.public_key, &input, &signature.signature)?;
2169 let records = [execution, source]
2170 .into_iter()
2171 .chain(p.review_evidence.iter())
2172 .collect::<Vec<_>>();
2173 (
2174 4,
2175 canonical(p)?,
2176 hash(&[
2177 b"heddle-hosted-authority-envelope-v1",
2178 &(p.authority_envelope.len() as u32).to_be_bytes(),
2179 &p.authority_envelope,
2180 ]),
2181 original_signatures(&records, std::slice::from_ref(signature))?,
2182 key_id(&signature.public_key),
2183 )
2184 }
2185 };
2186 if bytes.len() > MAX_RECORD_BYTES {
2187 return Err(Reject::Bounds);
2188 }
2189 if statement.purpose != purpose
2190 || statement.canonical_payload != bytes
2191 || statement.authority_digest != authority
2192 || statement.original_signatures_digest != signatures
2193 || statement.publisher_key_id != publisher
2194 {
2195 return Err(Reject::Scope);
2196 }
2197 Ok(())
2198}
2199
2200pub fn resolve_bundle_permission<'a>(
2201 bundle: &'a ImportPublicProofBundleV1,
2202 digest: &[u8],
2203) -> Result<Option<&'a SignedImportMemberPermissionV1>, Reject> {
2204 width(digest, 32)?;
2205 if digest == [0; 32] {
2206 return Ok(None);
2207 }
2208 bundle
2209 .member_permissions
2210 .iter()
2211 .find(|p| signed_permission_digest(p).is_ok_and(|d| d == digest))
2212 .map(Some)
2213 .ok_or(Reject::ImportPermission)
2214}
2215pub fn resolve_bundle_manifest<'a>(
2216 bundle: &'a ImportPublicProofBundleV1,
2217 digest: &[u8],
2218) -> Result<&'a ImportResultManifestV1, Reject> {
2219 width(digest, 32)?;
2220 bundle
2221 .manifests
2222 .iter()
2223 .find(|m| manifest_digest(m).is_ok_and(|d| d == digest))
2224 .ok_or(Reject::StaleManifest)
2225}
2226pub fn publication_payload(
2227 operation: &SignedDelegatedImportOperationV1,
2228 manifest: &ImportResultManifestV1,
2229) -> Result<ImportPublicationWitnessV1, Reject> {
2230 let o = operation.body.as_ref().ok_or(Reject::Canonical)?;
2231 Ok(ImportPublicationWitnessV1 {
2232 format_version: 1,
2233 signed_operation_digest: signed_operation_digest(operation)?,
2234 delegation_digest: o.delegation_digest.clone(),
2235 logical_job_id: o.logical_job_id.clone(),
2236 retry_lineage_id: o.retry_lineage_id.clone(),
2237 physical_operation_id: o.physical_operation_id.clone(),
2238 ref_name: o.ref_name.clone(),
2239 slot_id: o.slot_id,
2240 hash_algorithm: o.hash_algorithm,
2241 observed_commit_oid: o.observed_commit_oid.clone(),
2242 expected_frontier_digest: o.expected_frontier_digest.clone(),
2243 resulting_frontier_digest: o.resulting_frontier_digest.clone(),
2244 terminal_manifest_digest: manifest_digest(manifest)?,
2245 })
2246}
2247fn validate_bundle_history(
2250 bundle: &ImportPublicProofBundleV1,
2251 require_admissions: bool,
2252) -> Result<(), Reject> {
2253 fn sorted<T>(
2254 values: &[T],
2255 digest: impl Fn(&T) -> Result<Vec<u8>, Reject>,
2256 ) -> Result<(), Reject> {
2257 let mut previous = None;
2258 for value in values {
2259 let d = digest(value)?;
2260 if previous.as_ref().is_some_and(|p| p >= &d) {
2261 return Err(Reject::Canonical);
2262 }
2263 previous = Some(d);
2264 }
2265 Ok(())
2266 }
2267 for statement in &bundle.statements {
2268 let s = statement.body.as_ref().ok_or(Reject::Canonical)?;
2269 validate_statement_boundary(s)?;
2270 require_policy_history(
2271 bundle,
2272 &s.spool_uuid,
2273 s.policy_sequence,
2274 &s.policy_state_hash,
2275 )?;
2276 }
2277 sorted(&bundle.member_permissions, signed_permission_digest)?;
2278 sorted(&bundle.manifests, manifest_digest)?;
2279 if let Some(p) = &bundle.member_permission
2280 && resolve_bundle_permission(bundle, &signed_permission_digest(p)?)? != Some(p)
2281 {
2282 return Err(Reject::ImportPermission);
2283 }
2284 let terminal = bundle.terminal_manifest.as_ref().ok_or(Reject::Canonical)?;
2285 if resolve_bundle_manifest(bundle, &manifest_digest(terminal)?)? != terminal {
2286 return Err(Reject::Canonical);
2287 }
2288 if bundle.delegations.is_empty() || bundle.renewals.len() + 1 != bundle.delegations.len() {
2289 return Err(Reject::Canonical);
2290 }
2291 for (i, d) in bundle.delegations.iter().enumerate() {
2292 let body = d.body.as_ref().ok_or(Reject::Canonical)?;
2293 resolve_bundle_permission(bundle, &body.parent_permission_digest)?;
2294 if i == 0 {
2295 if body.predecessor_delegation_digest != [0; 32] {
2296 return Err(Reject::RenewalFork);
2297 }
2298 } else {
2299 let r = bundle.renewals[i - 1]
2300 .body
2301 .as_ref()
2302 .ok_or(Reject::Canonical)?;
2303 if r.replacement.as_ref() != Some(d)
2304 || r.predecessor_delegation_digest
2305 != signed_delegation_digest(&bundle.delegations[i - 1])?
2306 || body.predecessor_delegation_digest != r.predecessor_delegation_digest
2307 || r.expected_authority_epoch != i as u64
2308 {
2309 return Err(Reject::RenewalFork);
2310 }
2311 resolve_bundle_manifest(bundle, &r.committed_manifest_digest)?;
2312 }
2313 for branch in &body.branch_manifest {
2314 let g = bundle
2315 .genesis_authorities
2316 .iter()
2317 .find(|g| {
2318 signed_genesis_digest(g).is_ok_and(|h| h == branch.genesis_authority_digest)
2319 })
2320 .ok_or(Reject::Scope)?;
2321 let b = g.body.as_ref().ok_or(Reject::Canonical)?;
2322 resolve_bundle_permission(bundle, &b.parent_permission_digest)?;
2323 if !bundle
2324 .original_geneses
2325 .iter()
2326 .any(|o| native_id(o) == b.genesis_digest)
2327 || !bundle
2328 .creator_authority_envelopes
2329 .iter()
2330 .any(|e| hash(&[e]) == b.creator_authority_envelope_digest)
2331 {
2332 return Err(Reject::Scope);
2333 }
2334 if require_admissions
2337 && !bundle.genesis_witnesses.iter().any(|payload| {
2338 payload.binding.as_ref() == Some(g)
2339 && payload.original_genesis.as_ref().is_some_and(|o| {
2340 native_id(o) == b.genesis_digest && bundle.original_geneses.contains(o)
2341 })
2342 && hash(&[&payload.creator_authority_envelope])
2343 == b.creator_authority_envelope_digest
2344 && canonical(payload).is_ok_and(|bytes| {
2345 bundle.statements.iter().any(|s| {
2346 s.body
2347 .as_ref()
2348 .is_some_and(|s| s.purpose == 1 && s.canonical_payload == bytes)
2349 })
2350 })
2351 })
2352 {
2353 return Err(Reject::Scope);
2354 }
2355 }
2356 }
2357 for manifest in &bundle.manifests {
2358 validate_manifest(manifest)?;
2359 if manifest.logical_job_id != terminal.logical_job_id
2360 || manifest.retry_lineage_id != terminal.retry_lineage_id
2361 {
2362 return Err(Reject::Scope);
2363 }
2364 for slot in &manifest.slots {
2365 let operation = bundle
2366 .operations
2367 .iter()
2368 .find(|o| {
2369 signed_operation_digest(o).is_ok_and(|d| d == slot.signed_operation_digest)
2370 })
2371 .ok_or(Reject::Scope)?;
2372 if !check_slot_replay(manifest, operation)? || !check_slot_replay(terminal, operation)?
2373 {
2374 return Err(Reject::Scope);
2375 }
2376 }
2377 }
2378 for operation in &bundle.operations {
2379 let o = operation.body.as_ref().ok_or(Reject::Canonical)?;
2380 if !bundle
2381 .delegations
2382 .iter()
2383 .any(|d| signed_delegation_digest(d).is_ok_and(|h| h == o.delegation_digest))
2384 || !check_slot_replay(terminal, operation)?
2385 {
2386 return Err(Reject::Scope);
2387 }
2388 if !bundle.manifests.iter().any(|m| {
2389 check_slot_replay(m, operation) == Ok(true)
2390 && publication_payload(operation, m)
2391 .and_then(|p| canonical(&p))
2392 .is_ok_and(|p| {
2393 bundle.statements.iter().any(|s| {
2394 s.body
2395 .as_ref()
2396 .is_some_and(|s| s.purpose == 3 && s.canonical_payload == p)
2397 })
2398 })
2399 }) {
2400 return Err(Reject::Scope);
2401 }
2402 }
2403 for statement in &bundle.statements {
2404 let s = statement.body.as_ref().ok_or(Reject::Canonical)?;
2405 let found = match s.purpose {
2406 1 => bundle
2407 .genesis_witnesses
2408 .iter()
2409 .any(|p| canonical(p).is_ok_and(|p| p == s.canonical_payload)),
2410 2 => bundle
2411 .authority_witnesses
2412 .iter()
2413 .any(|p| canonical(p).is_ok_and(|p| p == s.canonical_payload)),
2414 3 => bundle.operations.iter().any(|o| {
2415 bundle.manifests.iter().any(|m| {
2416 publication_payload(o, m)
2417 .and_then(|p| canonical(&p))
2418 .is_ok_and(|p| p == s.canonical_payload)
2419 })
2420 }),
2421 4 => bundle
2422 .landing_witnesses
2423 .iter()
2424 .any(|p| canonical(p).is_ok_and(|p| p == s.canonical_payload)),
2425 _ => return Err(Reject::Version),
2426 };
2427 if !found {
2428 return Err(Reject::Scope);
2429 }
2430 }
2431 Ok(())
2432}
2433fn require_policy_history(
2436 bundle: &ImportPublicProofBundleV1,
2437 spool: &[u8],
2438 mut sequence: u64,
2439 state_hash: &[u8],
2440) -> Result<(), Reject> {
2441 let mut state_hash = state_hash.to_vec();
2442 for _ in 0..=bundle.policies.len() {
2443 width(&state_hash, 32)?;
2444 if sequence == 0 {
2445 return if state_hash == [0; 32] {
2446 Ok(())
2447 } else {
2448 Err(Reject::Scope)
2449 };
2450 }
2451 let mut matches = bundle
2452 .policies
2453 .iter()
2454 .filter_map(|p| p.body.as_ref())
2455 .filter(|p| {
2456 p.spool_uuid == spool && p.sequence == sequence && p.policy_state_hash == state_hash
2457 });
2458 let policy = matches.next().ok_or(Reject::Scope)?;
2459 if matches.next().is_some() {
2460 return Err(Reject::Canonical);
2461 }
2462 let head = policy.expected_head.as_ref().ok_or(Reject::Canonical)?;
2463 if head.sequence.checked_add(1) != Some(sequence) {
2464 return Err(Reject::Scope);
2465 }
2466 sequence = head.sequence;
2467 state_hash = head.state_hash.clone();
2468 }
2469 Err(Reject::Scope)
2470}
2471fn native_id(record: &SignedRecord) -> Vec<u8> {
2472 let mut h = blake3::Hasher::new();
2473 h.update(record.format.as_bytes());
2474 h.update(&(record.canonical_record.len() as u64).to_le_bytes());
2475 h.update(b"\0");
2476 h.update(&record.canonical_record);
2477 h.finalize().as_bytes().to_vec()
2478}
2479pub fn validate_renewal_preparation(response: &PrepareImportJobResponse) -> Result<(), Reject> {
2482 let state = response.renewal_state.as_ref().ok_or(Reject::Canonical)?;
2483 let proposal = response.proposal.as_ref().ok_or(Reject::Canonical)?;
2484 let previous = state.active_predecessor.as_ref().ok_or(Reject::Canonical)?;
2485 let p = previous.body.as_ref().ok_or(Reject::Canonical)?;
2486 let manifest = state.committed_manifest.as_ref().ok_or(Reject::Canonical)?;
2487 validate_manifest(manifest)?;
2488 if state.format_version != 1
2489 || state.authority_epoch == 0
2490 || state.logical_job_id != p.logical_job_id
2491 || state.retry_lineage_id != p.retry_lineage_id
2492 || proposal.logical_job_id != state.logical_job_id
2493 || proposal.retry_lineage_id != state.retry_lineage_id
2494 || manifest.logical_job_id != state.logical_job_id
2495 || manifest.retry_lineage_id != state.retry_lineage_id
2496 || proposal.predecessor_delegation_digest != signed_delegation_digest(previous)?
2497 {
2498 return Err(Reject::StaleContext);
2499 }
2500 Ok(())
2501}
2502
2503pub fn initial_operation_id(lineage: &[u8], occupied: bool) -> Result<String, Reject> {
2506 width(lineage, 16)?;
2507 if lineage.iter().all(|b| *b == 0) {
2508 return Err(Reject::Canonical);
2509 }
2510 if occupied {
2511 return Err(Reject::OperationIdReused);
2512 }
2513 let h = hex::encode(lineage);
2514 Ok(format!(
2515 "{}-{}-{}-{}-{}",
2516 &h[..8],
2517 &h[8..12],
2518 &h[12..16],
2519 &h[16..20],
2520 &h[20..]
2521 ))
2522}
2523
2524pub fn check_cancel_request(
2527 request: &CancelImportJobRequest,
2528 active: &SignedImportJobDelegationV1,
2529 durable_epoch: u64,
2530 cancelled: bool,
2531) -> Result<(), Reject> {
2532 let d = active.body.as_ref().ok_or(Reject::Canonical)?;
2533 width(&request.logical_job_id, 16)?;
2534 width(&request.cancellation_id, 32)?;
2535 let id = d.identity.as_ref().ok_or(Reject::Canonical)?;
2536 if request.client_operation_id.is_empty()
2537 || request.destination.as_ref().is_none_or(|s| {
2538 initial_operation_id(&id.spool_uuid, false).map_or(true, |uuid| s.id != uuid)
2539 })
2540 || request.logical_job_id != d.logical_job_id
2541 {
2542 return Err(Reject::Scope);
2543 }
2544 if durable_epoch == 0 || request.expected_authority_epoch != durable_epoch {
2545 return Err(Reject::StaleContext);
2546 }
2547 if request.cancellation_id != d.cancellation_id {
2548 return Err(Reject::Scope);
2549 }
2550 if cancelled {
2551 return Err(Reject::Revoked);
2552 }
2553 Ok(())
2554}
2555pub fn check_cancel_replay(
2557 request: &CancelImportJobRequest,
2558 stored: &CancelImportJobRequest,
2559) -> Result<(), Reject> {
2560 if request != stored {
2561 return Err(Reject::OperationIdReused);
2562 }
2563 Ok(())
2564}
2565pub fn check_import_revocations(
2567 delegation: &SignedImportJobDelegationV1,
2568 member: Option<&SignedImportMemberPermissionV1>,
2569 revoked: &[Vec<u8>],
2570) -> Result<(), Reject> {
2571 let d = delegation.body.as_ref().ok_or(Reject::Canonical)?;
2572 width(&d.cancellation_id, 32)?;
2573 if revoked.contains(&d.cancellation_id) {
2574 return Err(Reject::Revoked);
2575 }
2576 if let Some(parent) = member {
2577 let p = parent.body.as_ref().ok_or(Reject::ImportPermission)?;
2578 width(&p.cancellation_id, 32)?;
2579 if revoked.contains(&p.cancellation_id) {
2580 return Err(Reject::Revoked);
2581 }
2582 }
2583 Ok(())
2584}
2585
2586fn remaining_scope(
2587 scope: &ImportPermissionScopeV1,
2588 old: &ImportPermissionScopeV1,
2589 committed: &ImportResultManifestV1,
2590) -> Result<(), Reject> {
2591 if !scope_subset(scope, old) {
2592 return Err(Reject::RenewalFork);
2593 }
2594 let mut consumed = 0_u64;
2595 let mut removed = 0_u32;
2596 for slot in &committed.slots {
2597 if let Some(branch) = old
2598 .branches
2599 .iter()
2600 .find(|b| b.ref_name == slot.ref_name && b.slot_id == slot.slot_id)
2601 {
2602 if slot.result_bytes > branch.max_result_bytes {
2603 return Err(Reject::RenewalFork);
2604 }
2605 consumed = consumed
2606 .checked_add(slot.result_bytes)
2607 .ok_or(Reject::Bounds)?;
2608 removed += 1;
2609 }
2610 if scope
2611 .branches
2612 .iter()
2613 .any(|b| b.ref_name == slot.ref_name && b.slot_id == slot.slot_id)
2614 {
2615 return Err(Reject::CommittedSlot);
2616 }
2617 }
2618 if scope.max_operations
2619 > old
2620 .max_operations
2621 .checked_sub(removed)
2622 .ok_or(Reject::RenewalFork)?
2623 || scope.max_result_bytes
2624 > old
2625 .max_result_bytes
2626 .checked_sub(consumed)
2627 .ok_or(Reject::RenewalFork)?
2628 {
2629 return Err(Reject::RenewalFork);
2630 }
2631 Ok(())
2632}
2633
2634#[derive(Debug, Clone)]
2645pub struct VerifiedImportRenewalPredecessor {
2646 previous: VerifiedImportDelegation,
2647 state_digest: Vec<u8>,
2648}
2649fn validate_cas_state(state: &ImportJobCasStateV1) -> Result<(), Reject> {
2650 let previous = state.active_predecessor.as_ref().ok_or(Reject::Canonical)?;
2651 let p = previous.body.as_ref().ok_or(Reject::Canonical)?;
2652 let manifest = state.committed_manifest.as_ref().ok_or(Reject::Canonical)?;
2653 validate_manifest(manifest)?;
2654 if state.format_version != 1
2655 || state.authority_epoch == 0
2656 || state.logical_job_id != p.logical_job_id
2657 || state.retry_lineage_id != p.retry_lineage_id
2658 || manifest.logical_job_id != state.logical_job_id
2659 || manifest.retry_lineage_id != state.retry_lineage_id
2660 {
2661 return Err(Reject::StaleContext);
2662 }
2663 Ok(())
2664}
2665pub fn verify_renewal_predecessor(
2668 state: &ImportJobCasStateV1,
2669 member: Option<&SignedImportMemberPermissionV1>,
2670 expected: &ImportOwnerExpectation<'_>,
2671) -> Result<VerifiedImportRenewalPredecessor, Reject> {
2672 validate_cas_state(state)?;
2673 let previous = verify_delegation_inner(
2674 state.active_predecessor.as_ref().ok_or(Reject::Canonical)?,
2675 member,
2676 expected,
2677 false,
2678 )?;
2679 Ok(VerifiedImportRenewalPredecessor {
2680 previous,
2681 state_digest: signing_digest("heddle-import-job-cas-state-v1", state)?,
2682 })
2683}
2684pub fn verify_renewal_from_state(
2687 signed: &SignedImportJobRenewalV1,
2688 previous: &VerifiedImportRenewalPredecessor,
2689 state: &ImportJobCasStateV1,
2690 member: Option<&SignedImportMemberPermissionV1>,
2691 expected: &ImportOwnerExpectation<'_>,
2692) -> Result<VerifiedImportDelegation, Reject> {
2693 validate_cas_state(state)?;
2694 if signing_digest("heddle-import-job-cas-state-v1", state)? != previous.state_digest {
2695 return Err(Reject::StaleContext);
2696 }
2697 verify_renewal(
2698 signed,
2699 &previous.previous,
2700 state.committed_manifest.as_ref().ok_or(Reject::Canonical)?,
2701 state.authority_epoch,
2702 member,
2703 expected,
2704 )
2705}
2706
2707pub fn validate_hybrid_import_job_selector(
2709 selector: &HybridImportJobSelector,
2710) -> Result<(), Reject> {
2711 width(&selector.logical_job_id, 16)?;
2712 if selector.logical_job_id.iter().all(|byte| *byte == 0) {
2713 return Err(Reject::Canonical);
2714 }
2715 Ok(())
2716}
2717
2718pub fn import_job_state_request_from_operation(
2723 operation: &OperationRecord,
2724) -> Result<Option<GetImportJobStateRequest>, Reject> {
2725 let Some(operation_subject::Subject::Import(subject)) = operation
2726 .subject
2727 .as_ref()
2728 .and_then(|subject| subject.subject.as_ref())
2729 else {
2730 return Ok(None);
2731 };
2732 let Some(selector) = subject.hybrid_job.as_ref() else {
2733 return Ok(None);
2734 };
2735 validate_hybrid_import_job_selector(selector)?;
2736 let request = GetImportJobStateRequest {
2737 destination: operation
2738 .r#ref
2739 .as_ref()
2740 .and_then(|record| record.spool.clone()),
2741 logical_job_id: selector.logical_job_id.clone(),
2742 };
2743 validate_job_state_request(&request)?;
2744 Ok(Some(request))
2745}
2746
2747pub fn validate_job_state_request(request: &GetImportJobStateRequest) -> Result<(), Reject> {
2750 use prost::Message;
2751 if request.encoded_len() > 4096 {
2752 return Err(Reject::Bounds);
2753 }
2754 initial_operation_id(&request.logical_job_id, false)?;
2755 let destination = request.destination.as_ref().ok_or(Reject::Scope)?;
2756 let compact = destination.id.replace('-', "");
2757 let raw = hex::decode(&compact).map_err(|_| Reject::Scope)?;
2758 if initial_operation_id(&raw, false).map_or(true, |id| id != destination.id) {
2759 return Err(Reject::Scope);
2760 }
2761 Ok(())
2762}
2763
2764fn bundle_owner_reference(
2765 bundle: &ImportPublicProofBundleV1,
2766 id: &ImportIdentityV1,
2767) -> Result<(), Reject> {
2768 identity(id)?;
2769 if !bundle.owner_histories.iter().any(|h| {
2770 h.state_hash == id.owner_state_hash
2771 && h.root
2772 .as_ref()
2773 .and_then(|r| r.root.as_ref())
2774 .is_some_and(|r| {
2775 r.owner_id == id.owner_id && r.account_uuid == id.owner_account_uuid
2776 })
2777 }) {
2778 return Err(Reject::Root);
2779 }
2780 Ok(())
2781}
2782
2783fn validate_retained_proof(
2784 state: &ImportJobCasStateV1,
2785 proof: &ImportPublicProofBundleV1,
2786) -> Result<(), Reject> {
2787 validate_cas_state(state)?;
2788 validate_bundle_bounds(proof)?;
2789 let manifest = state.committed_manifest.as_ref().ok_or(Reject::Canonical)?;
2790 if proof.delegations.last() != state.active_predecessor.as_ref()
2791 || proof.terminal_manifest.as_ref() != Some(manifest)
2792 {
2793 return Err(Reject::StaleContext);
2794 }
2795 validate_bundle_history(proof, !manifest.slots.is_empty())?;
2798 let active = state
2799 .active_predecessor
2800 .as_ref()
2801 .and_then(|d| d.body.as_ref())
2802 .ok_or(Reject::Canonical)?;
2803 let id = active.identity.as_ref().ok_or(Reject::Canonical)?;
2804 let genesis = proof
2805 .owner_genesis
2806 .as_ref()
2807 .and_then(|g| g.genesis.as_ref())
2808 .ok_or(Reject::Root)?;
2809 if genesis.spool_uuid != id.spool_uuid {
2810 return Err(Reject::Root);
2811 }
2812 let chain = proof.owner_chain.as_ref().ok_or(Reject::Root)?;
2813 if owner_chain_digest(chain)? != active.owner_chain_digest
2814 || chain.spool_genesis_digest != id.spool_genesis_digest
2815 {
2816 return Err(Reject::Root);
2817 }
2818 for d in &proof.delegations {
2819 let body = d.body.as_ref().ok_or(Reject::Canonical)?;
2820 if body.logical_job_id != state.logical_job_id
2821 || body.retry_lineage_id != state.retry_lineage_id
2822 {
2823 return Err(Reject::Scope);
2824 }
2825 bundle_owner_reference(proof, body.identity.as_ref().ok_or(Reject::Canonical)?)?;
2826 }
2827 for g in &proof.genesis_authorities {
2828 bundle_owner_reference(
2829 proof,
2830 g.body
2831 .as_ref()
2832 .and_then(|g| g.identity.as_ref())
2833 .ok_or(Reject::Canonical)?,
2834 )?;
2835 }
2836 Ok(())
2837}
2838
2839pub fn validate_job_state_response(
2841 request: &GetImportJobStateRequest,
2842 response: &GetImportJobStateResponse,
2843) -> Result<(), Reject> {
2844 use prost::Message;
2845 validate_job_state_request(request)?;
2846 if response.encoded_len() > 2 * MAX_BUNDLE_BYTES {
2847 return Err(Reject::Bounds);
2848 }
2849 let state = response.state.as_ref().ok_or(Reject::Canonical)?;
2850 let proof = response.retained_proof.as_ref().ok_or(Reject::Canonical)?;
2851 validate_retained_proof(state, proof)?;
2852 let id = state
2853 .active_predecessor
2854 .as_ref()
2855 .and_then(|d| d.body.as_ref())
2856 .and_then(|d| d.identity.as_ref())
2857 .ok_or(Reject::Canonical)?;
2858 if state.logical_job_id != request.logical_job_id
2859 || request.destination.as_ref().is_none_or(|s| {
2860 initial_operation_id(&id.spool_uuid, false).map_or(true, |uuid| s.id != uuid)
2861 })
2862 {
2863 return Err(Reject::Scope);
2864 }
2865 Ok(())
2866}
2867
2868pub fn validate_renewal_preparation_from_read(
2871 request: &PrepareImportJobRequest,
2872 response: &PrepareImportJobResponse,
2873 read: &GetImportJobStateResponse,
2874) -> Result<(), Reject> {
2875 validate_preparation_response(request, response)?;
2876 let state = read.state.as_ref().ok_or(Reject::Canonical)?;
2877 validate_job_state_response(
2878 &GetImportJobStateRequest {
2879 destination: request.destination.clone(),
2880 logical_job_id: request.renew_logical_job_id.clone(),
2881 },
2882 read,
2883 )?;
2884 if response.renewal_state.as_ref() != Some(state) {
2885 return Err(Reject::StaleContext);
2886 }
2887 Ok(())
2888}
2889
2890pub fn validate_renew_request(
2894 request: &RenewImportJobRequest,
2895 read: &GetImportJobStateResponse,
2896) -> Result<(), Reject> {
2897 use prost::Message;
2898 if request.encoded_len() > 2 * MAX_BUNDLE_BYTES {
2899 return Err(Reject::Bounds);
2900 }
2901 if request.client_operation_id.is_empty() || request.client_operation_id.len() > 128 {
2902 return Err(Reject::Canonical);
2903 }
2904 let signed = request.renewal.as_ref().ok_or(Reject::Canonical)?;
2905 let renewal = signed.body.as_ref().ok_or(Reject::Canonical)?;
2906 let replacement = renewal
2907 .replacement
2908 .as_ref()
2909 .and_then(|d| d.body.as_ref())
2910 .ok_or(Reject::Canonical)?;
2911 validate_job_state_response(
2912 &GetImportJobStateRequest {
2913 destination: request.destination.clone(),
2914 logical_job_id: replacement.logical_job_id.clone(),
2915 },
2916 read,
2917 )?;
2918 let state = read.state.as_ref().ok_or(Reject::Canonical)?;
2919 let retained = read.retained_proof.as_ref().ok_or(Reject::Canonical)?;
2920 let proof = request.proof.as_ref().ok_or(Reject::Canonical)?;
2921 validate_bundle_bounds(proof)?;
2922 if renewal.predecessor_delegation_digest
2923 != signed_delegation_digest(state.active_predecessor.as_ref().ok_or(Reject::Canonical)?)?
2924 || renewal.expected_authority_epoch != state.authority_epoch
2925 {
2926 return Err(Reject::StaleContext);
2927 }
2928 if renewal.committed_manifest_digest
2929 != manifest_digest(state.committed_manifest.as_ref().ok_or(Reject::Canonical)?)?
2930 {
2931 return Err(Reject::StaleManifest);
2932 }
2933 let parent = resolve_bundle_permission(proof, &replacement.parent_permission_digest)?;
2937 let mut normalized = proof.clone();
2938 let mut permissions = retained.member_permissions.clone();
2939 if let Some(parent) = parent
2940 && !permissions.contains(parent)
2941 {
2942 permissions.push(parent.clone());
2943 }
2944 let mut addressed = permissions
2945 .into_iter()
2946 .map(|p| Ok((signed_permission_digest(&p)?, p)))
2947 .collect::<Result<Vec<_>, Reject>>()?;
2948 addressed.sort_by(|a, b| a.0.cmp(&b.0));
2949 let permissions = addressed.into_iter().map(|(_, p)| p).collect::<Vec<_>>();
2950 if proof.member_permissions != permissions {
2951 return Err(Reject::ImportPermission);
2952 }
2953 if proof
2954 .member_permission
2955 .as_ref()
2956 .is_some_and(|p| Some(p) != parent)
2957 {
2958 return Err(Reject::ImportPermission);
2959 }
2960 if !retained
2961 .owner_histories
2962 .iter()
2963 .all(|h| proof.owner_histories.contains(h))
2964 || !proof
2965 .ownership_transfers
2966 .starts_with(&retained.ownership_transfers)
2967 || !retained.policies.iter().all(|p| proof.policies.contains(p))
2968 {
2969 return Err(Reject::Root);
2970 }
2971 let chain = proof.owner_chain.as_ref().ok_or(Reject::Root)?;
2972 let id = replacement.identity.as_ref().ok_or(Reject::Canonical)?;
2973 bundle_owner_reference(proof, id)?;
2974 if owner_chain_digest(chain)? != replacement.owner_chain_digest
2975 || chain.spool_genesis_digest != id.spool_genesis_digest
2976 {
2977 return Err(Reject::Root);
2978 }
2979 normalized.member_permissions = retained.member_permissions.clone();
2980 normalized.member_permission = retained.member_permission.clone();
2981 normalized.owner_histories = retained.owner_histories.clone();
2982 normalized.ownership_transfers = retained.ownership_transfers.clone();
2983 normalized.policies = retained.policies.clone();
2984 normalized.owner_chain = retained.owner_chain.clone();
2985 if &normalized != retained {
2986 return Err(Reject::Scope);
2987 }
2988 let initial = retained
2989 .delegations
2990 .first()
2991 .and_then(|d| d.body.as_ref())
2992 .ok_or(Reject::Canonical)?;
2993 for branch in &replacement.branch_manifest {
2994 if !initial.branch_manifest.iter().any(|b| {
2995 b.genesis_authority_digest == branch.genesis_authority_digest
2996 && b.limit
2997 .as_ref()
2998 .zip(branch.limit.as_ref())
2999 .is_some_and(|(a, b)| {
3000 a.genesis_digest == b.genesis_digest
3001 && a.ref_name == b.ref_name
3002 && a.slot_id == b.slot_id
3003 })
3004 }) {
3005 return Err(Reject::GenesisBinding);
3006 }
3007 }
3008 Ok(())
3009}
3010
3011pub fn verify_renew_submission(
3014 request: &RenewImportJobRequest,
3015 read: &GetImportJobStateResponse,
3016 predecessor_owner: &ImportOwnerExpectation<'_>,
3017 current_owner: &ImportOwnerExpectation<'_>,
3018) -> Result<VerifiedImportDelegation, Reject> {
3019 validate_renew_request(request, read)?;
3020 let state = read.state.as_ref().ok_or(Reject::Canonical)?;
3021 let retained = read.retained_proof.as_ref().ok_or(Reject::Canonical)?;
3022 let active = state
3023 .active_predecessor
3024 .as_ref()
3025 .and_then(|d| d.body.as_ref())
3026 .ok_or(Reject::Canonical)?;
3027 let old = verify_renewal_predecessor(
3028 state,
3029 resolve_bundle_permission(retained, &active.parent_permission_digest)?,
3030 predecessor_owner,
3031 )?;
3032 let renewal = request.renewal.as_ref().ok_or(Reject::Canonical)?;
3033 let replacement = renewal
3034 .body
3035 .as_ref()
3036 .and_then(|r| r.replacement.as_ref())
3037 .and_then(|d| d.body.as_ref())
3038 .ok_or(Reject::Canonical)?;
3039 verify_renewal_from_state(
3040 renewal,
3041 &old,
3042 state,
3043 resolve_bundle_permission(
3044 request.proof.as_ref().ok_or(Reject::Canonical)?,
3045 &replacement.parent_permission_digest,
3046 )?,
3047 current_owner,
3048 )
3049}
3050
3051pub fn check_renew_replay(request_bytes: &[u8], stored_bytes: &[u8]) -> Result<(), Reject> {
3054 if request_bytes.len() > 2 * MAX_BUNDLE_BYTES {
3055 return Err(Reject::Bounds);
3056 }
3057 if request_bytes != stored_bytes {
3058 return Err(Reject::OperationIdReused);
3059 }
3060 Ok(())
3061}