chio_kernel/
capability_lineage.rs1use serde::{Deserialize, Serialize};
8
9use chio_core::capability::{scope::ChioScope, token::CapabilityToken};
10use chio_core::crypto::PublicKey;
11
12use crate::receipt_store::ReceiptStoreError;
13
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum CapabilitySnapshotProvenance {
21 SignedToken,
23 SyntheticAnchor,
25 #[default]
27 LegacyProjection,
28}
29
30impl CapabilitySnapshotProvenance {
31 #[must_use]
32 pub const fn as_str(self) -> &'static str {
33 match self {
34 Self::SignedToken => "signed_token",
35 Self::SyntheticAnchor => "synthetic_anchor",
36 Self::LegacyProjection => "legacy_projection",
37 }
38 }
39}
40
41impl std::str::FromStr for CapabilitySnapshotProvenance {
42 type Err = String;
43
44 fn from_str(value: &str) -> Result<Self, Self::Err> {
45 match value {
46 "signed_token" => Ok(Self::SignedToken),
47 "synthetic_anchor" => Ok(Self::SyntheticAnchor),
48 "legacy_projection" => Ok(Self::LegacyProjection),
49 _ => Err(format!(
50 "unsupported capability snapshot provenance {value:?}"
51 )),
52 }
53 }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct CapabilitySnapshot {
58 pub capability_id: String,
60 pub subject_key: String,
62 pub issuer_key: String,
64 pub issued_at: u64,
66 pub expires_at: u64,
68 pub grants_json: String,
70 pub delegation_depth: u64,
72 pub parent_capability_id: Option<String>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub federated_parent_capability_id: Option<String>,
77 #[serde(default)]
80 pub provenance: CapabilitySnapshotProvenance,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub signed_capability: Option<CapabilityToken>,
84}
85
86impl CapabilitySnapshot {
87 pub fn validate_for_transport(&self) -> Result<(), ReceiptStoreError> {
89 self.validate(false)
90 }
91
92 pub fn validate_for_local_read(&self) -> Result<(), ReceiptStoreError> {
95 self.validate(true)
96 }
97
98 fn validate(&self, allow_legacy: bool) -> Result<(), ReceiptStoreError> {
99 if self
100 .federated_parent_capability_id
101 .as_deref()
102 .is_some_and(|parent| parent == self.capability_id)
103 {
104 return Err(self.conflict("uses itself as its federated parent"));
105 }
106
107 match self.provenance {
108 CapabilitySnapshotProvenance::SignedToken => self.validate_signed_token(),
109 CapabilitySnapshotProvenance::SyntheticAnchor => self.validate_synthetic_anchor(),
110 CapabilitySnapshotProvenance::LegacyProjection if allow_legacy => {
111 if self.signed_capability.is_some() {
112 return Err(self.conflict("marks a signed token as a legacy projection"));
113 }
114 Ok(())
115 }
116 CapabilitySnapshotProvenance::LegacyProjection => Err(self.conflict(
117 "uses legacy projection provenance outside the local migration boundary",
118 )),
119 }
120 }
121
122 fn validate_signed_token(&self) -> Result<(), ReceiptStoreError> {
123 let Some(token) = self.signed_capability.as_ref() else {
124 return Err(self.conflict("claims signed-token provenance without a signed token"));
125 };
126 token.validate_schema().map_err(|error| {
127 self.conflict(&format!(
128 "contains a signed token with an invalid schema: {error}"
129 ))
130 })?;
131 if !token.verify_signature().map_err(|error| {
132 self.conflict(&format!(
133 "contains a signed token that could not be verified: {error}"
134 ))
135 })? {
136 return Err(self.conflict("contains a signed token with an invalid signature"));
137 }
138
139 let persisted_scope: ChioScope = serde_json::from_str(&self.grants_json)?;
140 let scope_matches =
141 serde_json::to_value(&persisted_scope)? == serde_json::to_value(&token.scope)?;
142 let fields_match = self.capability_id == token.id
143 && self.subject_key == token.subject.to_hex()
144 && self.issuer_key == token.issuer.to_hex()
145 && self.issued_at == token.issued_at
146 && self.expires_at == token.expires_at
147 && scope_matches;
148 if !fields_match {
149 return Err(self.conflict("does not match its signed token projection"));
150 }
151
152 let expected_parent = token
153 .delegation_chain
154 .last()
155 .map(|link| link.capability_id.as_str());
156 if self.parent_capability_id.as_deref() != expected_parent
157 || self.delegation_depth != token.delegation_chain.len() as u64
158 {
159 return Err(self.conflict("does not match its signed delegation lineage"));
160 }
161 Ok(())
162 }
163
164 fn validate_synthetic_anchor(&self) -> Result<(), ReceiptStoreError> {
165 if self.signed_capability.is_some() {
166 return Err(self.conflict("marks a signed token as a synthetic anchor"));
167 }
168 let digest = self.capability_id.strip_prefix("fed-del-");
169 if !digest.is_some_and(|digest| {
170 digest.len() == 64
171 && digest
172 .bytes()
173 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
174 }) {
175 return Err(self.conflict("is not a structurally valid fed-del anchor"));
176 }
177 if self.parent_capability_id.is_some() || self.delegation_depth != 0 {
178 return Err(self.conflict("places unsigned federation data in signed lineage fields"));
179 }
180 if self.issued_at >= self.expires_at {
181 return Err(self.conflict("has an empty or reversed validity window"));
182 }
183 PublicKey::from_hex(&self.subject_key)
184 .map_err(|error| self.conflict(&format!("has an invalid subject key: {error}")))?;
185 PublicKey::from_hex(&self.issuer_key)
186 .map_err(|error| self.conflict(&format!("has an invalid issuer key: {error}")))?;
187 let _: ChioScope = serde_json::from_str(&self.grants_json)?;
188 Ok(())
189 }
190
191 fn conflict(&self, reason: &str) -> ReceiptStoreError {
192 ReceiptStoreError::Conflict(format!(
193 "capability lineage {} {reason}",
194 self.capability_id
195 ))
196 }
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct StoredCapabilitySnapshot {
202 pub seq: u64,
203 pub snapshot: CapabilitySnapshot,
204}
205
206#[derive(Debug, thiserror::Error)]
208pub enum CapabilityLineageError {
209 #[error("receipt store error: {0}")]
210 ReceiptStore(#[from] ReceiptStoreError),
211
212 #[error("sqlite error: {0}")]
213 Sqlite(#[from] rusqlite::Error),
214
215 #[error("json error: {0}")]
216 Json(#[from] serde_json::Error),
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
224 fn legacy_snapshot_json_defaults_to_local_only_provenance(
225 ) -> Result<(), Box<dyn std::error::Error>> {
226 let snapshot: CapabilitySnapshot = serde_json::from_value(serde_json::json!({
227 "capability_id": "legacy-capability",
228 "subject_key": "legacy-subject",
229 "issuer_key": "legacy-issuer",
230 "issued_at": 1,
231 "expires_at": 2,
232 "grants_json": "{}",
233 "delegation_depth": 0,
234 "parent_capability_id": null
235 }))?;
236
237 assert_eq!(
238 snapshot.provenance,
239 CapabilitySnapshotProvenance::LegacyProjection
240 );
241 assert!(snapshot.federated_parent_capability_id.is_none());
242 assert!(snapshot.validate_for_local_read().is_ok());
243 assert!(snapshot.validate_for_transport().is_err());
244 Ok(())
245 }
246}