Skip to main content

heddle_api/
timeline_upload.rs

1//! Shared bounds for the hosted v1 timeline input projection.
2//! Servers must also reject unknown protobuf fields before decoding them away.
3
4use 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
33/// Inspect transport lengths before full protobuf allocation. Unknown fields
34/// must be checked separately by the receiving server's wire decoder.
35pub 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
78/// The verified credential supplies this display-only label. Direct-human
79/// runs require the empty label; an agent may be unlabelled.
80pub 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(&timestamp.seconds)
130            && (0..1_000_000_000).contains(&timestamp.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
236/// Commit to every raw Biscuit revocation ID, from the issued authority block
237/// through the terminal block. The 64 KiB chain bound limits the input size.
238pub 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
254/// Admission must resolve an exact previously verified registration before an
255/// offline-derived upload may omit its chain. Acceptance is not provenance.
256pub 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
455/// The exact v1 logical digest excludes both acceptance and fresh transport
456/// proof. Reusing the operation ID with any other digest is a conflict.
457pub 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
522/// Exact domain-separated bytes signed by the immutable origin credential.
523pub 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
546/// Digest identifies one endorsement, including its original signature.
547pub 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
556/// Exact domain-separated acceptance transcript; authority is verified in the
557/// server transaction against current principal or owner-derived scope.
558pub 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}