Skip to main content

heddle_api/
hybrid_codec.rs

1//! Fixed-order counted framing shared by the new HYBRID records. See
2//! owner_records.proto L22–31; signatures never cover protobuf serialization.
3use ed25519_dalek::{Signature, VerifyingKey};
4use prost::Message;
5use sha2::{Digest, Sha256};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
8pub enum Reject {
9    #[error("unsupported format")]
10    Version,
11    #[error("noncanonical or missing field")]
12    Canonical,
13    #[error("size or count bound exceeded")]
14    Bounds,
15    #[error("invalid signature")]
16    Signature,
17    #[error("independent authority/root mismatch")]
18    Root,
19    #[error("semantically invalid record")]
20    Semantic,
21    #[error("generation rollback or equivocation")]
22    HighWater,
23    #[error("illegal signer lifecycle transition")]
24    Transition,
25    #[error("job key cannot be a witness")]
26    JobAsWitness,
27    #[error("key roles overlap")]
28    KeyRole,
29    #[error("authority is not valid at verification time")]
30    Expired,
31    #[error("delegation scope violation")]
32    Scope,
33    #[error("prepared frozen fields changed")]
34    PreparedFields,
35    #[error("preparation refused: {0:?}")]
36    PreparationRefused(crate::heddle::api::v1alpha2::ImportPreparationRefusalReason),
37    #[error("observe mode lacks its signed disclosure")]
38    RefDisclosure,
39    #[error("known commit must be pinned exactly")]
40    RefPinning,
41    #[error("missing or mismatched provider source selection")]
42    SourceSelection,
43    #[error("ImportSource requires CommitImportJob")]
44    ImportSourceRequiresCommit,
45    #[error("client operation ID was reused with changed inputs")]
46    OperationIdReused,
47    #[error("Commit must return its created pending operation")]
48    PendingOperation,
49    #[error("delegation window exceeds prepared host bounds")]
50    ValidityBounds,
51    #[error("genesis binding does not match prepared branch")]
52    GenesisBinding,
53    #[error("missing typed owner import permission")]
54    ImportPermission,
55    #[error("renewal forks the logical job or widens remaining scope")]
56    RenewalFork,
57    #[error("committed manifest changed before renewal")]
58    StaleManifest,
59    #[error("replacement includes an already committed slot")]
60    CommittedSlot,
61    #[error("context is stale at mutation boundary")]
62    StaleContext,
63    #[error("missing or invalid exact retirement inclusion proof")]
64    Proof,
65    #[error("witness is revoked")]
66    Revoked,
67    #[error("committed slot conflict")]
68    SlotConflict,
69    #[error("missing or mismatched exact boundary acceptance binding")]
70    BoundaryAcceptance,
71    #[error("incompatible peer")]
72    Protocol,
73}
74
75pub trait Canonical {
76    fn write(&self, out: &mut Vec<u8>) -> Result<(), Reject>;
77}
78pub fn canonical<T: Canonical>(value: &T) -> Result<Vec<u8>, Reject> {
79    let mut out = Vec::new();
80    value.write(&mut out)?;
81    if out.len() > 1024 * 1024 {
82        return Err(Reject::Bounds);
83    }
84    Ok(out)
85}
86pub fn counted(out: &mut Vec<u8>, value: &[u8]) -> Result<(), Reject> {
87    if value.len() > 1024 * 1024
88        || out.len().saturating_add(value.len()).saturating_add(4) > 1024 * 1024
89    {
90        return Err(Reject::Bounds);
91    }
92    out.extend_from_slice(&(value.len() as u32).to_be_bytes());
93    out.extend_from_slice(value);
94    Ok(())
95}
96pub fn hash(parts: &[&[u8]]) -> Vec<u8> {
97    let mut hash = Sha256::new();
98    for part in parts {
99        hash.update(part);
100    }
101    hash.finalize().to_vec()
102}
103pub fn signing_digest<T: Canonical>(domain: &str, value: &T) -> Result<Vec<u8>, Reject> {
104    Ok(hash(&[domain.as_bytes(), &canonical(value)?]))
105}
106pub fn key_id(public_key: &[u8]) -> Vec<u8> {
107    hash(&[b"heddle-key-v1", &1_u32.to_be_bytes(), public_key])
108}
109pub fn width(value: &[u8], expected: usize) -> Result<(), Reject> {
110    if value.len() != expected {
111        return Err(Reject::Canonical);
112    }
113    Ok(())
114}
115pub fn verify(public_key: &[u8], input: &[u8], signature: &[u8]) -> Result<(), Reject> {
116    let key: &[u8; 32] = public_key.try_into().map_err(|_| Reject::Canonical)?;
117    let signature: &[u8; 64] = signature.try_into().map_err(|_| Reject::Canonical)?;
118    VerifyingKey::from_bytes(key)
119        .map_err(|_| Reject::Signature)?
120        .verify_strict(input, &Signature::from_bytes(signature))
121        .map_err(|_| Reject::Signature)
122}
123/// Invoke at the untrusted protobuf boundary. Prost discards unknown fields;
124/// byte-for-byte re-encoding also rejects duplicate tags, overlong varints,
125/// noncanonical field order and trailing data. No discarded field is signed.
126pub fn strict_decode<T: Message + Default>(bytes: &[u8], max: usize) -> Result<T, Reject> {
127    if bytes.is_empty() || bytes.len() > max {
128        return Err(Reject::Bounds);
129    }
130    let value = T::decode(bytes).map_err(|_| Reject::Canonical)?;
131    if value.encode_to_vec() != bytes {
132        return Err(Reject::Canonical);
133    }
134    Ok(value)
135}
136
137macro_rules! field {
138    ($out:ident, $v:expr, b) => {
139        $crate::hybrid_codec::counted($out, &$v)?
140    };
141    ($out:ident, $v:expr, s) => {
142        $crate::hybrid_codec::counted($out, $v.as_bytes())?
143    };
144    ($out:ident, $v:expr, u) => {
145        $out.extend_from_slice(&$v.to_be_bytes())
146    };
147    ($out:ident, $v:expr, e) => {
148        $out.extend_from_slice(
149            &u32::try_from($v)
150                .map_err(|_| $crate::hybrid_codec::Reject::Canonical)?
151                .to_be_bytes(),
152        )
153    };
154    ($out:ident, $v:expr, m) => {
155        $crate::hybrid_codec::Canonical::write(
156            $v.as_ref().ok_or($crate::hybrid_codec::Reject::Canonical)?,
157            $out,
158        )?
159    };
160    ($out:ident, $v:expr, o) => {
161        $out.extend_from_slice(&u32::from($v.is_some()).to_be_bytes());
162        if let Some(value) = &$v {
163            $crate::hybrid_codec::Canonical::write(value, $out)?;
164        }
165    };
166    ($out:ident, $v:expr, l) => {
167        if $v.len() > 4096 {
168            return Err($crate::hybrid_codec::Reject::Bounds);
169        }
170        $out.extend_from_slice(&($v.len() as u32).to_be_bytes());
171        for value in &$v {
172            $crate::hybrid_codec::Canonical::write(value, $out)?;
173        }
174    };
175    ($out:ident, $v:expr, p) => {
176        if $v.len() > 4 {
177            return Err($crate::hybrid_codec::Reject::Bounds);
178        }
179        $out.extend_from_slice(&($v.len() as u32).to_be_bytes());
180        for value in &$v {
181            $out.extend_from_slice(
182                &u32::try_from(*value)
183                    .map_err(|_| $crate::hybrid_codec::Reject::Canonical)?
184                    .to_be_bytes(),
185            );
186        }
187    };
188    ($out:ident, $v:expr, h) => {
189        if $v.len() > 128 {
190            return Err($crate::hybrid_codec::Reject::Bounds);
191        }
192        $out.extend_from_slice(&($v.len() as u32).to_be_bytes());
193        for value in &$v {
194            $crate::hybrid_codec::counted($out, value)?;
195        }
196    };
197    ($out:ident, $v:expr, q) => {
198        if $v.len() > 64 {
199            return Err($crate::hybrid_codec::Reject::Bounds);
200        }
201        $out.extend_from_slice(&($v.len() as u32).to_be_bytes());
202        for value in &$v {
203            $crate::hybrid_codec::counted($out, value)?;
204        }
205    };
206}
207macro_rules! record {
208    ($ty:ty, $($field:ident : $kind:ident),+ $(,)?) => {
209        impl $crate::hybrid_codec::Canonical for $ty {
210            fn write(&self, out: &mut Vec<u8>) -> Result<(), $crate::hybrid_codec::Reject> {
211                $(field!(out, self.$field, $kind);
212                  if out.len() > 1024 * 1024 { return Err($crate::hybrid_codec::Reject::Bounds); })+
213                Ok(())
214            }
215        }
216    };
217}
218pub(crate) use {field, record};