pic_pca/
pca.rs

1/*
2 * Copyright Nitro Agility S.r.l.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      https://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17//! PCA (Provenance Causal Authority) payload model.
18//!
19//! Defines the PCA data structure for CBOR serialization within COSE_Sign1 envelope.
20//! Based on PIC Spec v0.2.
21//!
22//! The PCA represents the causally derived authority at each execution hop.
23//! Key properties:
24//! - `p_0` is immutable throughout the chain (origin principal)
25//! - `ops` can only decrease (monotonicity: ops_i ⊆ ops_{i-1})
26//! - `provenance` links to the previous hop via `kid` references
27
28use serde::{Deserialize, Serialize};
29use serde_json::Value;
30use std::collections::HashMap;
31
32/// Generic dynamic key-value map with nested structure support.
33///
34/// Used for flexible executor bindings that vary by deployment context
35/// (e.g., Kubernetes, cloud provider, SPIFFE federation).
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
37pub struct DynamicMap(pub HashMap<String, Value>);
38
39impl DynamicMap {
40    pub fn new() -> Self {
41        Self(HashMap::new())
42    }
43
44    /// Adds a string value.
45    pub fn with(mut self, key: &str, value: &str) -> Self {
46        self.0.insert(key.into(), Value::String(value.into()));
47        self
48    }
49
50    /// Adds a nested map.
51    pub fn with_map(mut self, key: &str, value: DynamicMap) -> Self {
52        self.0
53            .insert(key.into(), serde_json::to_value(value).unwrap());
54        self
55    }
56
57    /// Adds an arbitrary JSON value.
58    pub fn with_value(mut self, key: &str, value: Value) -> Self {
59        self.0.insert(key.into(), value);
60        self
61    }
62
63    /// Adds a string array.
64    pub fn with_array(mut self, key: &str, values: Vec<&str>) -> Self {
65        let arr: Vec<Value> = values
66            .into_iter()
67            .map(|s| Value::String(s.into()))
68            .collect();
69        self.0.insert(key.into(), Value::Array(arr));
70        self
71    }
72
73    pub fn get(&self, key: &str) -> Option<&Value> {
74        self.0.get(key)
75    }
76
77    pub fn get_str(&self, key: &str) -> Option<&str> {
78        self.0.get(key)?.as_str()
79    }
80
81    pub fn get_map(&self, key: &str) -> Option<DynamicMap> {
82        let value = self.0.get(key)?;
83        serde_json::from_value(value.clone()).ok()
84    }
85
86    pub fn is_empty(&self) -> bool {
87        self.0.is_empty()
88    }
89}
90
91/// Executor binding - identifies executor within a federation context.
92pub type ExecutorBinding = DynamicMap;
93
94/// Executor at the current hop.
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
96pub struct Executor {
97    pub binding: ExecutorBinding,
98}
99
100/// CAT provenance - identifies who signed the previous PCA.
101///
102/// Uses `kid` (Key ID) which can be resolved to obtain the public key.
103/// The kid can be a SPIFFE ID, DID, URL, or any resolvable identifier.
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
105pub struct CatProvenance {
106    /// Key identifier (SPIFFE ID, DID, URL, etc.) - resolvable to public key
107    pub kid: String,
108    /// Signature bytes from the predecessor PCA
109    #[serde(with = "serde_bytes")]
110    pub signature: Vec<u8>,
111}
112
113/// Executor provenance - identifies who signed the PoC.
114///
115/// Uses `kid` which references the key in the executor's attestation.
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
117pub struct ExecutorProvenance {
118    /// Key identifier (SPIFFE ID, DID, URL, etc.) - matches attestation
119    pub kid: String,
120    /// Signature bytes from the PoC
121    #[serde(with = "serde_bytes")]
122    pub signature: Vec<u8>,
123}
124
125/// Provenance chain linking to the previous hop.
126///
127/// Contains both CAT and executor references for chain verification.
128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
129pub struct Provenance {
130    pub cat: CatProvenance,
131    pub executor: ExecutorProvenance,
132}
133
134/// Temporal constraints on PCA validity.
135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
136pub struct TemporalConstraints {
137    /// Issued at timestamp (ISO 8601)
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub iat: Option<String>,
140    /// Expiration timestamp (ISO 8601)
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub exp: Option<String>,
143    /// Not before timestamp (ISO 8601)
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub nbf: Option<String>,
146}
147
148/// All constraints on PCA validity.
149#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
150pub struct Constraints {
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub temporal: Option<TemporalConstraints>,
153}
154
155/// PCA Payload - the CBOR content signed with COSE_Sign1.
156///
157/// The `kid` (key identifier) and `alg` are stored in the COSE protected header.
158/// This structure contains only the payload fields.
159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
160pub struct PcaPayload {
161    /// Position in causal chain (0 for PCA_0)
162    pub hop: u32,
163    /// Immutable origin principal (p_0) - never changes throughout the chain
164    pub p_0: String,
165    /// Authority set (ops_i ⊆ ops_{i-1}) - can only decrease
166    pub ops: Vec<String>,
167    /// Current executor binding
168    pub executor: Executor,
169    /// Causal chain reference (None for PCA_0)
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub provenance: Option<Provenance>,
172    /// Validity constraints
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub constraints: Option<Constraints>,
175}
176
177impl PcaPayload {
178    /// Serializes to CBOR bytes.
179    pub fn to_cbor(&self) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
180        cbor4ii::serde::to_vec(Vec::new(), self).map_err(|e| e.into())
181    }
182
183    /// Deserializes from CBOR bytes.
184    pub fn from_cbor(bytes: &[u8]) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
185        cbor4ii::serde::from_slice(bytes).map_err(|e| e.into())
186    }
187
188    /// Serializes to JSON string.
189    pub fn to_json(&self) -> Result<String, serde_json::Error> {
190        serde_json::to_string(self)
191    }
192
193    /// Serializes to pretty-printed JSON string.
194    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
195        serde_json::to_string_pretty(self)
196    }
197
198    /// Deserializes from JSON string.
199    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
200        serde_json::from_str(json)
201    }
202
203    /// Returns true if this is the origin PCA (hop 0).
204    pub fn is_origin(&self) -> bool {
205        self.hop == 0
206    }
207
208    /// Checks if the given ops are a subset of this PCA's ops (monotonicity check).
209    pub fn allows_ops(&self, requested_ops: &[String]) -> bool {
210        requested_ops.iter().all(|op| self.ops.contains(op))
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    fn sample_pca_0() -> PcaPayload {
219        let binding = ExecutorBinding::new().with("org", "acme-corp");
220
221        PcaPayload {
222            hop: 0,
223            p_0: "https://idp.example.com/users/alice".into(),
224            ops: vec!["read:/user/*".into(), "write:/user/*".into()],
225            executor: Executor { binding },
226            provenance: None,
227            constraints: Some(Constraints {
228                temporal: Some(TemporalConstraints {
229                    iat: Some("2025-12-11T10:00:00Z".into()),
230                    exp: Some("2025-12-11T11:00:00Z".into()),
231                    nbf: None,
232                }),
233            }),
234        }
235    }
236
237    fn sample_pca_n() -> PcaPayload {
238        let binding = ExecutorBinding::new().with("org", "acme-corp");
239
240        PcaPayload {
241            hop: 2,
242            p_0: "https://idp.example.com/users/alice".into(),
243            ops: vec!["read:/user/*".into()],
244            executor: Executor { binding },
245            provenance: Some(Provenance {
246                cat: CatProvenance {
247                    kid: "https://cat.acme-corp.com/keys/1".into(),
248                    signature: vec![0u8; 64],
249                },
250                executor: ExecutorProvenance {
251                    kid: "spiffe://acme-corp/ns/prod/sa/archive".into(),
252                    signature: vec![0u8; 64],
253                },
254            }),
255            constraints: Some(Constraints {
256                temporal: Some(TemporalConstraints {
257                    iat: Some("2025-12-11T10:00:00Z".into()),
258                    exp: Some("2025-12-11T10:30:00Z".into()),
259                    nbf: None,
260                }),
261            }),
262        }
263    }
264
265    #[test]
266    fn test_pca_0_cbor_roundtrip() {
267        let pca = sample_pca_0();
268        let cbor = pca.to_cbor().unwrap();
269        let decoded = PcaPayload::from_cbor(&cbor).unwrap();
270        assert_eq!(pca, decoded);
271        assert_eq!(decoded.hop, 0);
272        assert!(decoded.provenance.is_none());
273        assert!(decoded.is_origin());
274    }
275
276    #[test]
277    fn test_pca_n_cbor_roundtrip() {
278        let pca = sample_pca_n();
279        let cbor = pca.to_cbor().unwrap();
280        let decoded = PcaPayload::from_cbor(&cbor).unwrap();
281        assert_eq!(pca, decoded);
282        assert_eq!(decoded.hop, 2);
283        assert!(decoded.provenance.is_some());
284        assert!(!decoded.is_origin());
285    }
286
287    #[test]
288    fn test_provenance_uses_kid() {
289        let pca = sample_pca_n();
290        let provenance = pca.provenance.unwrap();
291
292        assert!(provenance.cat.kid.starts_with("https://"));
293        assert!(provenance.executor.kid.starts_with("spiffe://"));
294    }
295
296    #[test]
297    fn test_json_roundtrip() {
298        let pca = sample_pca_n();
299        let json = pca.to_json().unwrap();
300        let decoded = PcaPayload::from_json(&json).unwrap();
301        assert_eq!(pca, decoded);
302    }
303
304    #[test]
305    fn test_cbor_smaller_than_json() {
306        let pca = sample_pca_n();
307        let cbor = pca.to_cbor().unwrap();
308        let json = pca.to_json().unwrap();
309
310        println!("CBOR: {} bytes", cbor.len());
311        println!("JSON: {} bytes", json.len());
312
313        assert!(cbor.len() < json.len());
314    }
315
316    #[test]
317    fn test_monotonicity_ops_reduced() {
318        let pca_0 = sample_pca_0();
319        let pca_n = sample_pca_n();
320
321        assert_eq!(pca_0.ops.len(), 2);
322        assert_eq!(pca_n.ops.len(), 1);
323        assert_eq!(pca_0.p_0, pca_n.p_0);
324    }
325
326    #[test]
327    fn test_allows_ops() {
328        let pca = sample_pca_0();
329
330        assert!(pca.allows_ops(&["read:/user/*".into()]));
331        assert!(pca.allows_ops(&["read:/user/*".into(), "write:/user/*".into()]));
332        assert!(!pca.allows_ops(&["read:/sys/*".into()]));
333    }
334
335    #[test]
336    fn test_minimal_executor_binding() {
337        let binding = ExecutorBinding::new().with("org", "simple-org");
338
339        let pca = PcaPayload {
340            hop: 1,
341            p_0: "https://idp.example.com/users/alice".into(),
342            ops: vec!["read:/user/*".into()],
343            executor: Executor { binding },
344            provenance: None,
345            constraints: None,
346        };
347
348        let cbor = pca.to_cbor().unwrap();
349        let decoded = PcaPayload::from_cbor(&cbor).unwrap();
350
351        assert_eq!(decoded.executor.binding.get_str("org"), Some("simple-org"));
352    }
353
354    #[test]
355    fn test_executor_binding_flexible() {
356        let binding = ExecutorBinding::new()
357            .with("org", "acme-corp")
358            .with("region", "eu-west-1")
359            .with("env", "prod");
360
361        let pca = PcaPayload {
362            hop: 0,
363            p_0: "https://idp.example.com/users/alice".into(),
364            ops: vec!["invoke:*".into()],
365            executor: Executor { binding },
366            provenance: None,
367            constraints: None,
368        };
369
370        let cbor = pca.to_cbor().unwrap();
371        let decoded = PcaPayload::from_cbor(&cbor).unwrap();
372
373        assert_eq!(decoded.executor.binding.get_str("org"), Some("acme-corp"));
374        assert_eq!(
375            decoded.executor.binding.get_str("region"),
376            Some("eu-west-1")
377        );
378    }
379
380    #[test]
381    fn test_nested_binding() {
382        let k8s = DynamicMap::new()
383            .with("cluster", "prod-eu")
384            .with("namespace", "default");
385
386        let binding = ExecutorBinding::new()
387            .with("org", "acme-corp")
388            .with_map("kubernetes", k8s)
389            .with_array("regions", vec!["eu-west-1", "eu-west-2"]);
390
391        let pca = PcaPayload {
392            hop: 0,
393            p_0: "https://idp.example.com/users/alice".into(),
394            ops: vec!["read:*".into()],
395            executor: Executor { binding },
396            provenance: None,
397            constraints: None,
398        };
399
400        let cbor = pca.to_cbor().unwrap();
401        let decoded = PcaPayload::from_cbor(&cbor).unwrap();
402
403        let k8s_decoded = decoded.executor.binding.get_map("kubernetes").unwrap();
404        assert_eq!(k8s_decoded.get_str("cluster"), Some("prod-eu"));
405        assert_eq!(k8s_decoded.get_str("namespace"), Some("default"));
406    }
407
408    #[test]
409    fn test_binding_with_json_value() {
410        let binding = ExecutorBinding::new().with("org", "acme-corp").with_value(
411            "metadata",
412            serde_json::json!({
413                "version": "1.2.3",
414                "replicas": 3,
415                "labels": {
416                    "app": "gateway",
417                    "tier": "frontend"
418                }
419            }),
420        );
421
422        let pca = PcaPayload {
423            hop: 0,
424            p_0: "https://idp.example.com/users/alice".into(),
425            ops: vec!["read:*".into()],
426            executor: Executor { binding },
427            provenance: None,
428            constraints: None,
429        };
430
431        let cbor = pca.to_cbor().unwrap();
432        let decoded = PcaPayload::from_cbor(&cbor).unwrap();
433
434        let metadata = decoded.executor.binding.get("metadata").unwrap();
435        assert_eq!(metadata["version"], "1.2.3");
436        assert_eq!(metadata["replicas"], 3);
437    }
438}