Skip to main content

heddle_object_model/object/
key_binding.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Offline-verifiable bindings from signing keys to durable identities.
3
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use super::{ContentHash, StateSignature};
8
9/// Domain separator for signatures that authorize a [`KeyBinding`].
10pub const KEY_BINDING_SIGNING_PAYLOAD_VERSION_TAG: &[u8] = b"hd-key-binding-v1\x00";
11
12/// Domain separator for authority signatures over registry checkpoints.
13pub const KEY_BINDING_REGISTRY_SIGNING_PAYLOAD_VERSION_TAG: &[u8] =
14    b"hd-key-binding-registry-v3\x00";
15
16/// Capability granted to a key by an authority-signed registry checkpoint.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum KeyRole {
20    /// May sign source-history states.
21    Author,
22    /// May sign human or agent co-review evidence.
23    Reviewer,
24    /// May sign automated preview evidence.
25    CiRunner,
26}
27
28impl KeyRole {
29    pub fn as_str(self) -> &'static str {
30        match self {
31            Self::Author => "author",
32            Self::Reviewer => "reviewer",
33            Self::CiRunner => "ci_runner",
34        }
35    }
36}
37
38/// A signing key's role within an identity's provenance chain.
39#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
40pub struct KeyBinding {
41    /// Signature algorithm used by the bound key.
42    pub algorithm: String,
43    /// Hex-encoded raw public key bytes.
44    pub public_key: String,
45    /// Durable identity subject resolved by this key.
46    pub identity_ref: String,
47    /// Repository capability granted to this key.
48    pub role: KeyRole,
49    /// Identity-key signature authorizing this binding.
50    pub added_by_sig: StateSignature,
51    /// First instant at which this binding may authenticate authored objects.
52    pub valid_from: DateTime<Utc>,
53    /// First instant at which this binding no longer authenticates new objects.
54    #[serde(default)]
55    pub revoked_at: Option<DateTime<Utc>>,
56    /// Content hash of the identity-owned root binding that authorized this
57    /// key. Only one delegation hop is permitted by repository verification.
58    #[serde(default)]
59    pub delegated_from: Option<ContentHash>,
60}
61
62impl KeyBinding {
63    /// Deterministic bytes covered by [`Self::added_by_sig`].
64    pub fn canonical_signing_payload(&self) -> Vec<u8> {
65        let mut payload = Vec::with_capacity(256);
66        payload.extend_from_slice(KEY_BINDING_SIGNING_PAYLOAD_VERSION_TAG);
67        push_field(&mut payload, self.algorithm.as_bytes());
68        push_field(&mut payload, self.public_key.as_bytes());
69        push_field(&mut payload, self.identity_ref.as_bytes());
70        push_field(&mut payload, self.role.as_str().as_bytes());
71        push_time(&mut payload, self.valid_from);
72        push_optional_time(&mut payload, self.revoked_at);
73        push_optional_hash(&mut payload, self.delegated_from);
74        payload
75    }
76
77    /// Stable address of this signed binding.
78    pub fn content_hash(&self) -> Result<ContentHash, KeyBindingError> {
79        self.validate()?;
80        let encoded =
81            rmp_serde::to_vec(self).map_err(|error| KeyBindingError::Codec(error.to_string()))?;
82        Ok(ContentHash::compute_typed("key-binding", &encoded))
83    }
84
85    /// Validate the durable shape. Cryptographic authorization is checked by
86    /// the repository resolver, which has access to the signing backends.
87    pub fn validate(&self) -> Result<(), KeyBindingError> {
88        require_non_empty(&self.algorithm, KeyBindingError::EmptyAlgorithm)?;
89        require_hex(&self.public_key, KeyBindingError::InvalidPublicKey)?;
90        require_non_empty(&self.identity_ref, KeyBindingError::EmptyIdentityRef)?;
91        require_non_empty(
92            &self.added_by_sig.algorithm,
93            KeyBindingError::EmptyAddedByAlgorithm,
94        )?;
95        require_hex(
96            &self.added_by_sig.public_key,
97            KeyBindingError::InvalidAddedByPublicKey,
98        )?;
99        require_hex(
100            &self.added_by_sig.signature,
101            KeyBindingError::InvalidAddedBySignature,
102        )?;
103        if self
104            .revoked_at
105            .is_some_and(|revoked| revoked < self.valid_from)
106        {
107            return Err(KeyBindingError::RevokedBeforeValid);
108        }
109        Ok(())
110    }
111}
112
113/// Versioned, authority-signed checkpoint of the key-binding registry.
114#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
115pub struct KeyBindingRegistry {
116    pub format_version: u8,
117    /// Monotonic checkpoint number. Genesis is epoch zero.
118    pub epoch: u64,
119    /// Content hash of the immediately preceding checkpoint.
120    pub previous_registry: Option<ContentHash>,
121    /// Trusted repository/identity-authority signature over this checkpoint.
122    pub authority_signature: StateSignature,
123    pub bindings: Vec<KeyBinding>,
124}
125
126impl KeyBindingRegistry {
127    pub const FORMAT_VERSION: u8 = 3;
128
129    pub fn new(
130        epoch: u64,
131        previous_registry: Option<ContentHash>,
132        authority_signature: StateSignature,
133        bindings: Vec<KeyBinding>,
134    ) -> Self {
135        Self {
136            format_version: Self::FORMAT_VERSION,
137            epoch,
138            previous_registry,
139            authority_signature,
140            bindings,
141        }
142    }
143
144    /// Deterministic bytes covered by [`Self::authority_signature`].
145    pub fn canonical_checkpoint_signing_payload(&self) -> Result<Vec<u8>, KeyBindingError> {
146        let mut payload = Vec::with_capacity(128 + self.bindings.len() * 32);
147        payload.extend_from_slice(KEY_BINDING_REGISTRY_SIGNING_PAYLOAD_VERSION_TAG);
148        payload.push(self.format_version);
149        payload.extend_from_slice(&self.epoch.to_le_bytes());
150        push_optional_hash(&mut payload, self.previous_registry);
151        payload.extend_from_slice(&(self.bindings.len() as u64).to_le_bytes());
152        for binding in &self.bindings {
153            payload.extend_from_slice(binding.content_hash()?.as_bytes());
154        }
155        Ok(payload)
156    }
157
158    pub fn encode(&self) -> Result<Vec<u8>, KeyBindingError> {
159        self.validate()?;
160        rmp_serde::to_vec(self).map_err(|error| KeyBindingError::Codec(error.to_string()))
161    }
162
163    pub fn decode(bytes: &[u8]) -> Result<Self, KeyBindingError> {
164        let registry: Self = rmp_serde::from_slice(bytes)
165            .map_err(|error| KeyBindingError::Codec(error.to_string()))?;
166        registry.validate()?;
167        Ok(registry)
168    }
169
170    /// Stable address of the registry's validated canonical encoding.
171    pub fn content_hash(&self) -> Result<ContentHash, KeyBindingError> {
172        Ok(ContentHash::compute_typed(
173            "key-binding-registry",
174            &self.encode()?,
175        ))
176    }
177
178    pub fn validate(&self) -> Result<(), KeyBindingError> {
179        if self.format_version != Self::FORMAT_VERSION {
180            return Err(KeyBindingError::UnsupportedVersion(self.format_version));
181        }
182        match (self.epoch, self.previous_registry) {
183            (0, Some(_)) => return Err(KeyBindingError::GenesisHasPrevious),
184            (1.., None) => return Err(KeyBindingError::MissingPreviousRegistry(self.epoch)),
185            _ => {}
186        }
187        require_non_empty(
188            &self.authority_signature.algorithm,
189            KeyBindingError::EmptyAuthorityAlgorithm,
190        )?;
191        require_hex(
192            &self.authority_signature.public_key,
193            KeyBindingError::InvalidAuthorityPublicKey,
194        )?;
195        require_hex(
196            &self.authority_signature.signature,
197            KeyBindingError::InvalidAuthoritySignature,
198        )?;
199        for (index, binding) in self.bindings.iter().enumerate() {
200            binding.validate()?;
201            if self.bindings[..index].iter().any(|prior| {
202                prior.algorithm.eq_ignore_ascii_case(&binding.algorithm)
203                    && prior.public_key.eq_ignore_ascii_case(&binding.public_key)
204            }) {
205                return Err(KeyBindingError::DuplicateKey);
206            }
207        }
208        Ok(())
209    }
210}
211
212fn require_non_empty(value: &str, error: KeyBindingError) -> Result<(), KeyBindingError> {
213    if value.trim().is_empty() {
214        Err(error)
215    } else {
216        Ok(())
217    }
218}
219
220fn require_hex(value: &str, error: KeyBindingError) -> Result<(), KeyBindingError> {
221    if value.is_empty() || hex::decode(value).is_err() {
222        Err(error)
223    } else {
224        Ok(())
225    }
226}
227
228fn push_field(payload: &mut Vec<u8>, value: &[u8]) {
229    payload.extend_from_slice(&(value.len() as u64).to_le_bytes());
230    payload.extend_from_slice(value);
231}
232
233fn push_optional_hash(payload: &mut Vec<u8>, value: Option<ContentHash>) {
234    match value {
235        Some(value) => {
236            payload.push(1);
237            payload.extend_from_slice(value.as_bytes());
238        }
239        None => payload.push(0),
240    }
241}
242
243fn push_time(payload: &mut Vec<u8>, value: DateTime<Utc>) {
244    payload.extend_from_slice(&value.timestamp().to_le_bytes());
245    payload.extend_from_slice(&value.timestamp_subsec_nanos().to_le_bytes());
246}
247
248fn push_optional_time(payload: &mut Vec<u8>, value: Option<DateTime<Utc>>) {
249    match value {
250        Some(value) => {
251            payload.push(1);
252            push_time(payload, value);
253        }
254        None => payload.push(0),
255    }
256}
257
258#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
259pub enum KeyBindingError {
260    #[error("unsupported key-binding registry version {0}")]
261    UnsupportedVersion(u8),
262    #[error("key-binding registry codec error: {0}")]
263    Codec(String),
264    #[error("genesis key-binding registry must not reference a previous checkpoint")]
265    GenesisHasPrevious,
266    #[error("key-binding registry epoch {0} must reference its previous checkpoint")]
267    MissingPreviousRegistry(u64),
268    #[error("key-binding registry authority signature algorithm must not be empty")]
269    EmptyAuthorityAlgorithm,
270    #[error("key-binding registry authority public key must be non-empty hexadecimal bytes")]
271    InvalidAuthorityPublicKey,
272    #[error("key-binding registry authority signature must be non-empty hexadecimal bytes")]
273    InvalidAuthoritySignature,
274    #[error("key binding algorithm must not be empty")]
275    EmptyAlgorithm,
276    #[error("key binding public key must be non-empty hexadecimal bytes")]
277    InvalidPublicKey,
278    #[error("key binding identity_ref must not be empty")]
279    EmptyIdentityRef,
280    #[error("key binding authorizing signature algorithm must not be empty")]
281    EmptyAddedByAlgorithm,
282    #[error("key binding authorizing public key must be non-empty hexadecimal bytes")]
283    InvalidAddedByPublicKey,
284    #[error("key binding authorizing signature must be non-empty hexadecimal bytes")]
285    InvalidAddedBySignature,
286    #[error("key binding revoked_at must not precede valid_from")]
287    RevokedBeforeValid,
288    #[error("key-binding registry contains a duplicate algorithm/public-key pair")]
289    DuplicateKey,
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn prior_registry_format_is_rejected_instead_of_dual_read() {
298        let legacy = KeyBindingRegistry {
299            format_version: 2,
300            epoch: 0,
301            previous_registry: None,
302            authority_signature: StateSignature {
303                algorithm: "ed25519".to_string(),
304                public_key: "11".repeat(32),
305                signature: "22".repeat(64),
306            },
307            bindings: Vec::new(),
308        };
309        let bytes = rmp_serde::to_vec(&legacy).expect("encode unsupported fixture");
310        assert!(matches!(
311            KeyBindingRegistry::decode(&bytes),
312            Err(KeyBindingError::UnsupportedVersion(2))
313        ));
314    }
315}