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 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
32/// Inspect transport lengths before full protobuf allocation. Unknown fields
33/// must be checked separately by the receiving server's wire decoder.
34pub 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
77/// The verified credential supplies this display-only label. Direct-human
78/// runs require the empty label; an agent may be unlabelled.
79pub 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(&timestamp.seconds)
129            && (0..1_000_000_000).contains(&timestamp.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
235/// Commit to every raw Biscuit revocation ID, from the issued authority block
236/// through the terminal block. The 64 KiB chain bound limits the input size.
237pub 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
253/// Admission must resolve an exact previously verified registration before an
254/// offline-derived upload may omit its chain. Acceptance is not provenance.
255pub 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
453/// The exact v1 logical digest excludes both acceptance and fresh transport
454/// proof. Reusing the operation ID with any other digest is a conflict.
455pub 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
520/// Exact domain-separated bytes signed by the immutable origin credential.
521pub 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
544/// Digest identifies one endorsement, including its original signature.
545pub 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
554/// Exact domain-separated acceptance transcript; authority is verified in the
555/// server transaction against current principal or owner-derived scope.
556pub 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}