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>, ciborium::ser::Error<std::io::Error>> {
180        let mut buf = Vec::new();
181        ciborium::into_writer(self, &mut buf)?;
182        Ok(buf)
183    }
184
185    /// Deserializes from CBOR bytes.
186    pub fn from_cbor(bytes: &[u8]) -> Result<Self, ciborium::de::Error<std::io::Error>> {
187        ciborium::from_reader(bytes)
188    }
189
190    /// Serializes to JSON string.
191    pub fn to_json(&self) -> Result<String, serde_json::Error> {
192        serde_json::to_string(self)
193    }
194
195    /// Serializes to pretty-printed JSON string.
196    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
197        serde_json::to_string_pretty(self)
198    }
199
200    /// Deserializes from JSON string.
201    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
202        serde_json::from_str(json)
203    }
204
205    /// Returns true if this is the origin PCA (hop 0).
206    pub fn is_origin(&self) -> bool {
207        self.hop == 0
208    }
209
210    /// Checks if the given ops are a subset of this PCA's ops (monotonicity check).
211    pub fn allows_ops(&self, requested_ops: &[String]) -> bool {
212        requested_ops.iter().all(|op| self.ops.contains(op))
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    fn sample_pca_0() -> PcaPayload {
221        let binding = ExecutorBinding::new().with("org", "acme-corp");
222
223        PcaPayload {
224            hop: 0,
225            p_0: "https://idp.example.com/users/alice".into(),
226            ops: vec!["read:/user/*".into(), "write:/user/*".into()],
227            executor: Executor { binding },
228            provenance: None,
229            constraints: Some(Constraints {
230                temporal: Some(TemporalConstraints {
231                    iat: Some("2025-12-11T10:00:00Z".into()),
232                    exp: Some("2025-12-11T11:00:00Z".into()),
233                    nbf: None,
234                }),
235            }),
236        }
237    }
238
239    fn sample_pca_n() -> PcaPayload {
240        let binding = ExecutorBinding::new().with("org", "acme-corp");
241
242        PcaPayload {
243            hop: 2,
244            p_0: "https://idp.example.com/users/alice".into(),
245            ops: vec!["read:/user/*".into()],
246            executor: Executor { binding },
247            provenance: Some(Provenance {
248                cat: CatProvenance {
249                    kid: "https://cat.acme-corp.com/keys/1".into(),
250                    signature: vec![0u8; 64],
251                },
252                executor: ExecutorProvenance {
253                    kid: "spiffe://acme-corp/ns/prod/sa/archive".into(),
254                    signature: vec![0u8; 64],
255                },
256            }),
257            constraints: Some(Constraints {
258                temporal: Some(TemporalConstraints {
259                    iat: Some("2025-12-11T10:00:00Z".into()),
260                    exp: Some("2025-12-11T10:30:00Z".into()),
261                    nbf: None,
262                }),
263            }),
264        }
265    }
266
267    #[test]
268    fn test_pca_0_cbor_roundtrip() {
269        let pca = sample_pca_0();
270        let cbor = pca.to_cbor().unwrap();
271        let decoded = PcaPayload::from_cbor(&cbor).unwrap();
272        assert_eq!(pca, decoded);
273        assert_eq!(decoded.hop, 0);
274        assert!(decoded.provenance.is_none());
275        assert!(decoded.is_origin());
276    }
277
278    #[test]
279    fn test_pca_n_cbor_roundtrip() {
280        let pca = sample_pca_n();
281        let cbor = pca.to_cbor().unwrap();
282        let decoded = PcaPayload::from_cbor(&cbor).unwrap();
283        assert_eq!(pca, decoded);
284        assert_eq!(decoded.hop, 2);
285        assert!(decoded.provenance.is_some());
286        assert!(!decoded.is_origin());
287    }
288
289    #[test]
290    fn test_provenance_uses_kid() {
291        let pca = sample_pca_n();
292        let provenance = pca.provenance.unwrap();
293
294        assert!(provenance.cat.kid.starts_with("https://"));
295        assert!(provenance.executor.kid.starts_with("spiffe://"));
296    }
297
298    #[test]
299    fn test_json_roundtrip() {
300        let pca = sample_pca_n();
301        let json = pca.to_json().unwrap();
302        let decoded = PcaPayload::from_json(&json).unwrap();
303        assert_eq!(pca, decoded);
304    }
305
306    #[test]
307    fn test_cbor_smaller_than_json() {
308        let pca = sample_pca_n();
309        let cbor = pca.to_cbor().unwrap();
310        let json = pca.to_json().unwrap();
311
312        println!("CBOR: {} bytes", cbor.len());
313        println!("JSON: {} bytes", json.len());
314
315        assert!(cbor.len() < json.len());
316    }
317
318    #[test]
319    fn test_monotonicity_ops_reduced() {
320        let pca_0 = sample_pca_0();
321        let pca_n = sample_pca_n();
322
323        assert_eq!(pca_0.ops.len(), 2);
324        assert_eq!(pca_n.ops.len(), 1);
325        assert_eq!(pca_0.p_0, pca_n.p_0);
326    }
327
328    #[test]
329    fn test_allows_ops() {
330        let pca = sample_pca_0();
331
332        assert!(pca.allows_ops(&["read:/user/*".into()]));
333        assert!(pca.allows_ops(&["read:/user/*".into(), "write:/user/*".into()]));
334        assert!(!pca.allows_ops(&["read:/sys/*".into()]));
335    }
336
337    #[test]
338    fn test_minimal_executor_binding() {
339        let binding = ExecutorBinding::new().with("org", "simple-org");
340
341        let pca = PcaPayload {
342            hop: 1,
343            p_0: "https://idp.example.com/users/alice".into(),
344            ops: vec!["read:/user/*".into()],
345            executor: Executor { binding },
346            provenance: None,
347            constraints: None,
348        };
349
350        let cbor = pca.to_cbor().unwrap();
351        let decoded = PcaPayload::from_cbor(&cbor).unwrap();
352
353        assert_eq!(decoded.executor.binding.get_str("org"), Some("simple-org"));
354    }
355
356    #[test]
357    fn test_executor_binding_flexible() {
358        let binding = ExecutorBinding::new()
359            .with("org", "acme-corp")
360            .with("region", "eu-west-1")
361            .with("env", "prod");
362
363        let pca = PcaPayload {
364            hop: 0,
365            p_0: "https://idp.example.com/users/alice".into(),
366            ops: vec!["invoke:*".into()],
367            executor: Executor { binding },
368            provenance: None,
369            constraints: None,
370        };
371
372        let cbor = pca.to_cbor().unwrap();
373        let decoded = PcaPayload::from_cbor(&cbor).unwrap();
374
375        assert_eq!(decoded.executor.binding.get_str("org"), Some("acme-corp"));
376        assert_eq!(
377            decoded.executor.binding.get_str("region"),
378            Some("eu-west-1")
379        );
380    }
381
382    #[test]
383    fn test_nested_binding() {
384        let k8s = DynamicMap::new()
385            .with("cluster", "prod-eu")
386            .with("namespace", "default");
387
388        let binding = ExecutorBinding::new()
389            .with("org", "acme-corp")
390            .with_map("kubernetes", k8s)
391            .with_array("regions", vec!["eu-west-1", "eu-west-2"]);
392
393        let pca = PcaPayload {
394            hop: 0,
395            p_0: "https://idp.example.com/users/alice".into(),
396            ops: vec!["read:*".into()],
397            executor: Executor { binding },
398            provenance: None,
399            constraints: None,
400        };
401
402        let cbor = pca.to_cbor().unwrap();
403        let decoded = PcaPayload::from_cbor(&cbor).unwrap();
404
405        let k8s_decoded = decoded.executor.binding.get_map("kubernetes").unwrap();
406        assert_eq!(k8s_decoded.get_str("cluster"), Some("prod-eu"));
407        assert_eq!(k8s_decoded.get_str("namespace"), Some("default"));
408    }
409
410    #[test]
411    fn test_binding_with_json_value() {
412        let binding = ExecutorBinding::new().with("org", "acme-corp").with_value(
413            "metadata",
414            serde_json::json!({
415                "version": "1.2.3",
416                "replicas": 3,
417                "labels": {
418                    "app": "gateway",
419                    "tier": "frontend"
420                }
421            }),
422        );
423
424        let pca = PcaPayload {
425            hop: 0,
426            p_0: "https://idp.example.com/users/alice".into(),
427            ops: vec!["read:*".into()],
428            executor: Executor { binding },
429            provenance: None,
430            constraints: None,
431        };
432
433        let cbor = pca.to_cbor().unwrap();
434        let decoded = PcaPayload::from_cbor(&cbor).unwrap();
435
436        let metadata = decoded.executor.binding.get("metadata").unwrap();
437        assert_eq!(metadata["version"], "1.2.3");
438        assert_eq!(metadata["replicas"], 3);
439    }
440}