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