1use prost::Message;
5use sha2::{Digest, Sha256};
6
7use crate::heddle::api::v1alpha2::{
8 RegisterTimelineOriginRequest, TimelineAdmissionAcceptance, TimelineOriginCredentialClass,
9 TimelineOriginCredentialIdentity, TimelineOriginEndorsement, UploadRunSummary,
10 UploadScrubbedTimelineRequest, UploadTimelineEvent, UploadTimelineEventKind,
11 UploadTimelineTool, operation_record, timeline_admission_acceptance::Authority,
12 timeline_origin_credential_identity::Identity,
13};
14
15pub const MAX_TIMELINE_REQUEST_BYTES: usize = 256 * 1024;
16pub const MAX_TIMELINE_EVENT_BYTES: usize = 2 * 1024;
17pub const MAX_TIMELINE_SNAPSHOT_BYTES: usize = 4 * 1024;
18pub const MAX_TIMELINE_EVENTS: usize = 64;
19pub const MAX_TIMELINE_ORIGIN_BISCUIT_BYTES: usize = 64 * 1024;
20pub const MAX_TIMELINE_OWNER_BUNDLE_BYTES: usize = 64 * 1024;
21pub const ORIGIN_DOMAIN: &[u8] = b"heddle-timeline-run-origin-v3\0";
22pub const DERIVATION_PATH_DOMAIN: &[u8] = b"heddle-timeline-derivation-path-v1\0";
23pub const ACCEPTANCE_DOMAIN: &[u8] = b"heddle-timeline-run-acceptance-v1\0";
24pub const UPLOAD_DOMAIN: &[u8] = b"heddle-timeline-upload-v1\0";
25const MAX_POSITION: u64 = i64::MAX as u64;
26const MAX_TIMESTAMP_SECONDS: i64 = 253_402_300_799;
27const MIN_TIMESTAMP_SECONDS: i64 = -62_135_596_800;
28
29#[derive(Debug, Clone, Copy, Eq, PartialEq, thiserror::Error)]
30#[error("invalid hosted timeline {0}")]
31pub struct TimelineValidationError(pub &'static str);
32
33pub fn validate_raw_request_size(raw: &[u8]) -> Result<(), TimelineValidationError> {
36 check(raw.len() <= MAX_TIMELINE_REQUEST_BYTES, "request size")
37}
38pub fn validate_raw_event_size(raw: &[u8]) -> Result<(), TimelineValidationError> {
39 check(raw.len() <= MAX_TIMELINE_EVENT_BYTES, "event size")
40}
41pub fn validate_raw_snapshot_size(raw: &[u8]) -> Result<(), TimelineValidationError> {
42 check(raw.len() <= MAX_TIMELINE_SNAPSHOT_BYTES, "snapshot size")
43}
44
45fn check(ok: bool, field: &'static str) -> Result<(), TimelineValidationError> {
46 if ok {
47 Ok(())
48 } else {
49 Err(TimelineValidationError(field))
50 }
51}
52
53pub fn valid_canonical_uuid(value: &str) -> bool {
54 value.len() == 36
55 && value.bytes().enumerate().all(|(i, b)| {
56 if matches!(i, 8 | 13 | 18 | 23) {
57 b == b'-'
58 } else {
59 b.is_ascii_digit() || (b'a'..=b'f').contains(&b)
60 }
61 })
62}
63
64pub fn valid_run_id(value: &str) -> bool {
65 (1..=128).contains(&value.len())
66 && value
67 .bytes()
68 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
69}
70
71pub fn valid_agent_label(value: &str) -> bool {
72 (1..=64).contains(&value.len())
73 && value
74 .bytes()
75 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b':' | b'-'))
76}
77
78pub fn valid_verified_agent_id(value: &str, agent: bool) -> bool {
81 if agent {
82 value.is_empty() || valid_agent_label(value)
83 } else {
84 value.is_empty()
85 }
86}
87
88pub fn validate_summary(value: &UploadRunSummary) -> Result<(), TimelineValidationError> {
89 check(
90 value.encoded_len() <= MAX_TIMELINE_SNAPSHOT_BYTES,
91 "snapshot size",
92 )?;
93 check(
94 matches!(
95 operation_record::State::try_from(value.state),
96 Ok(operation_record::State::Queued
97 | operation_record::State::Running
98 | operation_record::State::Completed
99 | operation_record::State::Failed
100 | operation_record::State::Canceled
101 | operation_record::State::WaitingForHuman
102 | operation_record::State::Paused)
103 ),
104 "snapshot state",
105 )?;
106 check(
107 matches!(value.harness.as_str(), "claude-code" | "codex" | "other"),
108 "snapshot harness",
109 )
110}
111
112pub fn validate_event(
113 value: &UploadTimelineEvent,
114 now_micros: i128,
115) -> Result<(), TimelineValidationError> {
116 check(
117 value.encoded_len() <= MAX_TIMELINE_EVENT_BYTES,
118 "event size",
119 )?;
120 check(value.position <= MAX_POSITION, "event position")?;
121 let kind = UploadTimelineEventKind::try_from(value.kind)
122 .map_err(|_| TimelineValidationError("event kind"))?;
123 check(kind != UploadTimelineEventKind::Unspecified, "event kind")?;
124 let timestamp = value
125 .recorded_at
126 .as_ref()
127 .ok_or(TimelineValidationError("recorded_at"))?;
128 check(
129 (MIN_TIMESTAMP_SECONDS..=MAX_TIMESTAMP_SECONDS).contains(×tamp.seconds)
130 && (0..1_000_000_000).contains(×tamp.nanos)
131 && timestamp.nanos % 1000 == 0
132 && i128::from(timestamp.seconds) * 1_000_000 + i128::from(timestamp.nanos / 1000)
133 <= now_micros + 300_000_000,
134 "recorded_at",
135 )?;
136 let tool_kind = matches!(
137 kind,
138 UploadTimelineEventKind::ToolStarted | UploadTimelineEventKind::ToolFinished
139 );
140 check(value.tool_name.is_some() == tool_kind, "tool_name presence")?;
141 if let Some(tool) = value.tool_name {
142 check(
143 matches!(UploadTimelineTool::try_from(tool), Ok(value) if value != UploadTimelineTool::Unspecified),
144 "tool_name",
145 )?;
146 }
147 Ok(())
148}
149
150fn validate_origin_fields(
151 value: &TimelineOriginEndorsement,
152) -> Result<(), TimelineValidationError> {
153 check(value.deployment_public_key.len() == 32, "deployment key")?;
154 check(valid_canonical_uuid(&value.spool_id), "origin spool")?;
155 check(value.thread_id.len() == 32, "origin thread")?;
156 check(valid_run_id(&value.run_id), "origin run")?;
157 check(
158 valid_canonical_uuid(&value.principal_id),
159 "origin principal",
160 )?;
161 check(
162 matches!(
163 TimelineOriginCredentialClass::try_from(value.credential_class),
164 Ok(TimelineOriginCredentialClass::DirectHuman | TimelineOriginCredentialClass::Agent)
165 ),
166 "origin credential class",
167 )?;
168 check(
169 value.effective_pop_key_sha256.len() == 32,
170 "origin actor digest",
171 )?;
172 let identity = value
173 .credential_identity
174 .as_ref()
175 .ok_or(TimelineValidationError("origin credential identity"))?;
176 validate_credential_identity(identity)?;
177 check(
178 !matches!(identity.identity, Some(Identity::OfflineDerived(_)))
179 || value.credential_class == TimelineOriginCredentialClass::Agent as i32,
180 "offline origin class",
181 )?;
182 check(
183 value.uploader_device_public_key.len() == 32,
184 "origin uploader key",
185 )?;
186 Ok(())
187}
188
189pub fn validate_credential_identity(
190 value: &TimelineOriginCredentialIdentity,
191) -> Result<(), TimelineValidationError> {
192 match value.identity.as_ref() {
193 Some(Identity::ServerIssued(issued)) => check(
194 (1..=128).contains(&issued.credential_id.len()),
195 "issued credential ID",
196 ),
197 Some(Identity::OfflineDerived(derived)) => {
198 check(
199 (1..=128).contains(&derived.issued_ancestor_credential_id.len()),
200 "issued ancestor credential ID",
201 )?;
202 check(
203 derived.terminal_revocation_id.len() == 64,
204 "terminal revocation ID",
205 )?;
206 check(
207 derived.derivation_path_sha256.len() == 32,
208 "derivation path digest",
209 )
210 }
211 None => Err(TimelineValidationError("credential identity variant")),
212 }
213}
214
215fn append_credential_identity(
216 value: &TimelineOriginCredentialIdentity,
217 bytes: &mut Vec<u8>,
218) -> Result<(), TimelineValidationError> {
219 validate_credential_identity(value)?;
220 match value.identity.as_ref() {
221 Some(Identity::ServerIssued(issued)) => {
222 bytes.push(1);
223 counted(&issued.credential_id, bytes);
224 }
225 Some(Identity::OfflineDerived(derived)) => {
226 bytes.push(2);
227 counted(&derived.issued_ancestor_credential_id, bytes);
228 counted(&derived.terminal_revocation_id, bytes);
229 counted(&derived.derivation_path_sha256, bytes);
230 }
231 None => return Err(TimelineValidationError("credential identity variant")),
232 }
233 Ok(())
234}
235
236pub fn derivation_path_sha256(
239 revocation_ids: &[Vec<u8>],
240) -> Result<[u8; 32], TimelineValidationError> {
241 check(
242 (2..=MAX_TIMELINE_ORIGIN_BISCUIT_BYTES / 64).contains(&revocation_ids.len())
243 && revocation_ids.iter().all(|id| id.len() == 64),
244 "derivation path IDs",
245 )?;
246 let mut bytes = DERIVATION_PATH_DOMAIN.to_vec();
247 bytes.extend_from_slice(&(revocation_ids.len() as u32).to_be_bytes());
248 for id in revocation_ids {
249 bytes.extend_from_slice(id);
250 }
251 Ok(Sha256::digest(bytes).into())
252}
253
254pub fn validate_upload_provenance(
257 value: &UploadScrubbedTimelineRequest,
258 exact_verified_registration_binding: bool,
259) -> Result<(), TimelineValidationError> {
260 let origin = value
261 .origin
262 .as_ref()
263 .ok_or(TimelineValidationError("origin"))?;
264 let identity = origin
265 .credential_identity
266 .as_ref()
267 .ok_or(TimelineValidationError("origin credential identity"))?;
268 validate_origin_biscuit(
269 origin,
270 &value.origin_credential_biscuit,
271 matches!(identity.identity, Some(Identity::OfflineDerived(_)))
272 && !exact_verified_registration_binding,
273 )
274}
275
276fn validate_origin_biscuit(
277 origin: &TimelineOriginEndorsement,
278 biscuit: &[u8],
279 required_for_offline: bool,
280) -> Result<(), TimelineValidationError> {
281 let identity = origin
282 .credential_identity
283 .as_ref()
284 .ok_or(TimelineValidationError("origin credential identity"))?;
285 match identity.identity.as_ref() {
286 Some(Identity::ServerIssued(_)) => check(biscuit.is_empty(), "issued origin biscuit"),
287 Some(Identity::OfflineDerived(_)) => check(
288 biscuit.len() <= MAX_TIMELINE_ORIGIN_BISCUIT_BYTES
289 && (!required_for_offline || !biscuit.is_empty()),
290 "offline origin biscuit",
291 ),
292 None => Err(TimelineValidationError("credential identity variant")),
293 }
294}
295
296pub fn validate_origin(value: &TimelineOriginEndorsement) -> Result<(), TimelineValidationError> {
297 validate_origin_fields(value)?;
298 check(value.signature.len() == 64, "origin signature")
299}
300
301fn validate_acceptance_fields(
302 value: &TimelineAdmissionAcceptance,
303) -> Result<(), TimelineValidationError> {
304 check(value.origin_sha256.len() == 32, "acceptance origin digest")?;
305 check(
306 value.uploader_device_public_key.len() == 32,
307 "acceptance uploader key",
308 )?;
309 check(
310 value.deployment_public_key.len() == 32,
311 "acceptance deployment key",
312 )?;
313 check(
314 value.request_sha256.len() == 32,
315 "acceptance request digest",
316 )?;
317 check(value.first_position <= MAX_POSITION, "acceptance position")?;
318 check(
319 value.event_count <= MAX_TIMELINE_EVENTS as u32,
320 "acceptance event count",
321 )?;
322 match value.authority.as_ref() {
323 Some(Authority::PrincipalCredentialId(id)) => {
324 check((1..=128).contains(&id.len()), "acceptance credential ID")
325 }
326 Some(Authority::OwnerDerivedCapability(bytes)) => check(
327 (1..=MAX_TIMELINE_OWNER_BUNDLE_BYTES).contains(&bytes.len()),
328 "acceptance capability",
329 ),
330 None => Err(TimelineValidationError("acceptance authority")),
331 }
332}
333
334pub fn validate_acceptance(
335 value: &TimelineAdmissionAcceptance,
336) -> Result<(), TimelineValidationError> {
337 validate_acceptance_fields(value)?;
338 check(value.signature.len() == 64, "acceptance signature")
339}
340
341fn validate_binding(
342 thread: Option<&crate::heddle::api::v1alpha2::ThreadRef>,
343 run: Option<&crate::heddle::api::v1alpha2::RecordRef>,
344 origin: &TimelineOriginEndorsement,
345) -> Result<(), TimelineValidationError> {
346 let thread = thread.ok_or(TimelineValidationError("thread"))?;
347 let run = run.ok_or(TimelineValidationError("run"))?;
348 let spool = thread
349 .spool
350 .as_ref()
351 .ok_or(TimelineValidationError("thread spool"))?;
352 let id = thread
353 .id
354 .as_ref()
355 .ok_or(TimelineValidationError("thread ID"))?;
356 check(valid_canonical_uuid(&spool.id), "spool UUID")?;
357 check(id.value.len() == 32, "thread ID")?;
358 check(valid_run_id(&run.id), "run ID")?;
359 check(
360 run.spool.as_ref().is_some_and(|r| r.id == spool.id),
361 "run spool",
362 )?;
363 check(
364 origin.spool_id == spool.id && origin.thread_id == id.value && origin.run_id == run.id,
365 "origin binding",
366 )
367}
368
369pub fn validate_registration(
370 value: &RegisterTimelineOriginRequest,
371) -> Result<(), TimelineValidationError> {
372 check(
373 valid_canonical_uuid(&value.client_operation_id),
374 "client operation ID",
375 )?;
376 let origin = value
377 .origin
378 .as_ref()
379 .ok_or(TimelineValidationError("origin"))?;
380 validate_origin(origin)?;
381 validate_origin_biscuit(origin, &value.origin_credential_biscuit, true)?;
382 validate_binding(value.thread.as_ref(), value.run.as_ref(), origin)
383}
384
385pub fn validate_upload(
386 value: &UploadScrubbedTimelineRequest,
387 now_micros: i128,
388) -> Result<(), TimelineValidationError> {
389 check(
390 value.encoded_len() <= MAX_TIMELINE_REQUEST_BYTES,
391 "request size",
392 )?;
393 check(
394 valid_canonical_uuid(&value.client_operation_id),
395 "client operation ID",
396 )?;
397 check(
398 value.canonicalization_version == 1,
399 "canonicalization version",
400 )?;
401 check(value.first_position <= MAX_POSITION, "first position")?;
402 check(value.events.len() <= MAX_TIMELINE_EVENTS, "event count")?;
403 check(
404 !value.events.is_empty() || value.snapshot.is_some(),
405 "run-only snapshot",
406 )?;
407 check(
408 value
409 .first_position
410 .checked_add(value.events.len() as u64)
411 .is_some(),
412 "position overflow",
413 )?;
414 if let Some(snapshot) = &value.snapshot {
415 validate_summary(snapshot)?;
416 }
417 for (offset, event) in value.events.iter().enumerate() {
418 validate_event(event, now_micros)?;
419 check(
420 event.position == value.first_position + offset as u64,
421 "event sequence",
422 )?;
423 }
424 let origin = value
425 .origin
426 .as_ref()
427 .ok_or(TimelineValidationError("origin"))?;
428 validate_origin(origin)?;
429 validate_origin_biscuit(origin, &value.origin_credential_biscuit, false)?;
430 validate_binding(value.thread.as_ref(), value.run.as_ref(), origin)?;
431 if let Some(acceptance) = &value.acceptance {
432 validate_acceptance(acceptance)?;
433 check(
434 acceptance.origin_sha256 == origin_digest(origin)?,
435 "acceptance origin",
436 )?;
437 check(
438 acceptance.uploader_device_public_key == origin.uploader_device_public_key
439 && acceptance.deployment_public_key == origin.deployment_public_key,
440 "acceptance binding",
441 )?;
442 check(
443 acceptance.first_position == value.first_position
444 && acceptance.event_count as usize == value.events.len(),
445 "acceptance range",
446 )?;
447 check(
448 acceptance.request_sha256 == logical_digest_bytes(value)?,
449 "acceptance request digest",
450 )?;
451 }
452 Ok(())
453}
454
455pub fn logical_request_digest(
458 value: &UploadScrubbedTimelineRequest,
459 now_micros: i128,
460) -> Result<[u8; 32], TimelineValidationError> {
461 validate_upload(value, now_micros)?;
462 logical_digest_bytes(value)
463}
464
465fn logical_digest_bytes(
466 value: &UploadScrubbedTimelineRequest,
467) -> Result<[u8; 32], TimelineValidationError> {
468 let thread = value
469 .thread
470 .as_ref()
471 .ok_or(TimelineValidationError("thread"))?;
472 let spool = thread
473 .spool
474 .as_ref()
475 .ok_or(TimelineValidationError("spool"))?;
476 let thread_id = thread
477 .id
478 .as_ref()
479 .ok_or(TimelineValidationError("thread ID"))?;
480 let run = value.run.as_ref().ok_or(TimelineValidationError("run"))?;
481 let origin = value
482 .origin
483 .as_ref()
484 .ok_or(TimelineValidationError("origin"))?;
485 let mut bytes = UPLOAD_DOMAIN.to_vec();
486 counted(value.client_operation_id.as_bytes(), &mut bytes);
487 counted(spool.id.as_bytes(), &mut bytes);
488 counted(&thread_id.value, &mut bytes);
489 counted(run.id.as_bytes(), &mut bytes);
490 bytes.extend_from_slice(&value.canonicalization_version.to_be_bytes());
491 bytes.extend_from_slice(&value.run_revision.to_be_bytes());
492 bytes.push(u8::from(value.snapshot.is_some()));
493 if let Some(snapshot) = &value.snapshot {
494 bytes.extend_from_slice(&(snapshot.state as u32).to_be_bytes());
495 counted(snapshot.harness.as_bytes(), &mut bytes);
496 }
497 bytes.extend_from_slice(&(value.events.len() as u32).to_be_bytes());
498 for event in &value.events {
499 bytes.extend_from_slice(&event.position.to_be_bytes());
500 bytes.extend_from_slice(&(event.kind as u32).to_be_bytes());
501 let at = event
502 .recorded_at
503 .as_ref()
504 .ok_or(TimelineValidationError("recorded_at"))?;
505 bytes.extend_from_slice(&at.seconds.to_be_bytes());
506 bytes.extend_from_slice(&(at.nanos as u32).to_be_bytes());
507 bytes.push(u8::from(event.tool_name.is_some()));
508 if let Some(tool) = event.tool_name {
509 bytes.extend_from_slice(&(tool as u32).to_be_bytes());
510 }
511 }
512 counted(&origin_digest(origin)?, &mut bytes);
513 bytes.extend_from_slice(&value.first_position.to_be_bytes());
514 Ok(Sha256::digest(bytes).into())
515}
516
517fn counted(bytes: &[u8], into: &mut Vec<u8>) {
518 into.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
519 into.extend_from_slice(bytes);
520}
521
522pub fn origin_signing_bytes(
524 value: &TimelineOriginEndorsement,
525) -> Result<Vec<u8>, TimelineValidationError> {
526 validate_origin_fields(value)?;
527 let mut bytes = ORIGIN_DOMAIN.to_vec();
528 counted(&value.deployment_public_key, &mut bytes);
529 counted(value.spool_id.as_bytes(), &mut bytes);
530 counted(&value.thread_id, &mut bytes);
531 counted(value.run_id.as_bytes(), &mut bytes);
532 counted(value.principal_id.as_bytes(), &mut bytes);
533 bytes.push(value.credential_class as u8);
534 counted(&value.effective_pop_key_sha256, &mut bytes);
535 append_credential_identity(
536 value
537 .credential_identity
538 .as_ref()
539 .ok_or(TimelineValidationError("origin credential identity"))?,
540 &mut bytes,
541 )?;
542 counted(&value.uploader_device_public_key, &mut bytes);
543 Ok(bytes)
544}
545
546pub fn origin_digest(
548 value: &TimelineOriginEndorsement,
549) -> Result<[u8; 32], TimelineValidationError> {
550 validate_origin(value)?;
551 let mut bytes = origin_signing_bytes(value)?;
552 bytes.extend_from_slice(&value.signature);
553 Ok(Sha256::digest(bytes).into())
554}
555
556pub fn acceptance_signing_bytes(
559 value: &TimelineAdmissionAcceptance,
560) -> Result<Vec<u8>, TimelineValidationError> {
561 validate_acceptance_fields(value)?;
562 let mut bytes = ACCEPTANCE_DOMAIN.to_vec();
563 counted(&value.origin_sha256, &mut bytes);
564 counted(&value.uploader_device_public_key, &mut bytes);
565 counted(&value.deployment_public_key, &mut bytes);
566 counted(&value.request_sha256, &mut bytes);
567 bytes.extend_from_slice(&value.first_position.to_be_bytes());
568 bytes.extend_from_slice(&value.event_count.to_be_bytes());
569 match value
570 .authority
571 .as_ref()
572 .ok_or(TimelineValidationError("acceptance authority"))?
573 {
574 Authority::PrincipalCredentialId(id) => {
575 bytes.push(1);
576 counted(id, &mut bytes);
577 }
578 Authority::OwnerDerivedCapability(capability) => {
579 bytes.push(2);
580 counted(capability, &mut bytes);
581 }
582 }
583 Ok(bytes)
584}