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.1.
21
22use serde::{Deserialize, Serialize};
23use serde_json::Value;
24use std::collections::HashMap;
25
26// ============================================================================
27// Dynamic Binding
28// ============================================================================
29
30/// Generic dynamic key-value map with nested structure support.
31///
32/// Used for flexible executor bindings that vary by deployment context
33/// (e.g., Kubernetes, cloud provider, SPIFFE federation).
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
35pub struct DynamicMap(pub HashMap<String, Value>);
36
37impl DynamicMap {
38    pub fn new() -> Self {
39        Self(HashMap::new())
40    }
41
42    /// Adds a string value.
43    pub fn with(mut self, key: &str, value: &str) -> Self {
44        self.0.insert(key.into(), Value::String(value.into()));
45        self
46    }
47
48    /// Adds a nested map.
49    pub fn with_map(mut self, key: &str, value: DynamicMap) -> Self {
50        self.0
51            .insert(key.into(), serde_json::to_value(value).unwrap());
52        self
53    }
54
55    /// Adds an arbitrary JSON value.
56    pub fn with_value(mut self, key: &str, value: Value) -> Self {
57        self.0.insert(key.into(), value);
58        self
59    }
60
61    /// Adds a string array.
62    pub fn with_array(mut self, key: &str, values: Vec<&str>) -> Self {
63        let arr: Vec<Value> = values
64            .into_iter()
65            .map(|s| Value::String(s.into()))
66            .collect();
67        self.0.insert(key.into(), Value::Array(arr));
68        self
69    }
70
71    pub fn get(&self, key: &str) -> Option<&Value> {
72        self.0.get(key)
73    }
74
75    pub fn get_str(&self, key: &str) -> Option<&str> {
76        self.0.get(key)?.as_str()
77    }
78
79    pub fn get_map(&self, key: &str) -> Option<DynamicMap> {
80        let value = self.0.get(key)?;
81        serde_json::from_value(value.clone()).ok()
82    }
83
84    pub fn is_empty(&self) -> bool {
85        self.0.is_empty()
86    }
87}
88
89/// Executor binding - identifies executor within a federation context.
90pub type ExecutorBinding = DynamicMap;
91
92// ============================================================================
93// Executor
94// ============================================================================
95
96/// Executor at the current hop.
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
98pub struct Executor {
99    pub binding: ExecutorBinding,
100}
101
102// ============================================================================
103// Provenance Chain
104// ============================================================================
105
106/// Key material for signature verification.
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
108pub struct KeyMaterial {
109    #[serde(with = "serde_bytes")]
110    pub public_key: Vec<u8>,
111    pub alg: String,
112}
113
114/// CAT provenance - identifies who signed the previous PCA.
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
116pub struct CatProvenance {
117    pub issuer: String,
118    #[serde(with = "serde_bytes")]
119    pub signature: Vec<u8>,
120    pub key: KeyMaterial,
121}
122
123/// Executor provenance - identifies who signed the PoC.
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
125pub struct ExecutorProvenance {
126    pub issuer: String,
127    #[serde(with = "serde_bytes")]
128    pub signature: Vec<u8>,
129    pub key: KeyMaterial,
130}
131
132/// Provenance chain linking to the previous hop.
133///
134/// Contains both CAT and executor signatures for chain verification.
135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
136pub struct Provenance {
137    pub cat: CatProvenance,
138    pub executor: ExecutorProvenance,
139}
140
141// ============================================================================
142// Constraints
143// ============================================================================
144
145/// Temporal constraints on PCA validity.
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
147pub struct TemporalConstraints {
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub iat: Option<String>,
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub exp: Option<String>,
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub nbf: Option<String>,
154}
155
156/// All constraints on PCA validity.
157#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
158pub struct Constraints {
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub temporal: Option<TemporalConstraints>,
161}
162
163// ============================================================================
164// PCA Payload
165// ============================================================================
166
167/// PCA Payload - the CBOR content signed with COSE_Sign1.
168///
169/// The issuer and signature are stored in the COSE header, not here.
170/// This structure contains only the payload fields.
171#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
172pub struct PcaPayload {
173    /// Unique identifier for this hop
174    pub hop: String,
175    /// Immutable origin principal (p_0)
176    pub p_0: String,
177    /// Authority set (ops_i ⊆ ops_{i-1})
178    pub ops: Vec<String>,
179    /// Current executor binding
180    pub executor: Executor,
181    /// Causal chain reference (None for PCA_0)
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub provenance: Option<Provenance>,
184    /// Validity constraints
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub constraints: Option<Constraints>,
187}
188
189impl PcaPayload {
190    /// Serializes to CBOR bytes.
191    pub fn to_cbor(&self) -> Result<Vec<u8>, ciborium::ser::Error<std::io::Error>> {
192        let mut buf = Vec::new();
193        ciborium::into_writer(self, &mut buf)?;
194        Ok(buf)
195    }
196
197    /// Deserializes from CBOR bytes.
198    pub fn from_cbor(bytes: &[u8]) -> Result<Self, ciborium::de::Error<std::io::Error>> {
199        ciborium::from_reader(bytes)
200    }
201
202    /// Serializes to JSON string.
203    pub fn to_json(&self) -> Result<String, serde_json::Error> {
204        serde_json::to_string(self)
205    }
206
207    /// Serializes to pretty-printed JSON string.
208    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
209        serde_json::to_string_pretty(self)
210    }
211
212    /// Deserializes from JSON string.
213    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
214        serde_json::from_str(json)
215    }
216}
217
218// ============================================================================
219// Tests
220// ============================================================================
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    fn sample_pca_0() -> PcaPayload {
227        let binding = ExecutorBinding::new().with("org", "acme-corp");
228
229        PcaPayload {
230            hop: "gateway".into(),
231            p_0: "https://idp.example.com/users/alice".into(),
232            ops: vec!["read:/user/*".into(), "write:/user/*".into()],
233            executor: Executor { binding },
234            provenance: None,
235            constraints: Some(Constraints {
236                temporal: Some(TemporalConstraints {
237                    iat: Some("2025-12-11T10:00:00Z".into()),
238                    exp: Some("2025-12-11T11:00:00Z".into()),
239                    nbf: None,
240                }),
241            }),
242        }
243    }
244
245    fn sample_pca_n() -> PcaPayload {
246        let binding = ExecutorBinding::new().with("org", "acme-corp");
247
248        PcaPayload {
249            hop: "storage".into(),
250            p_0: "https://idp.example.com/users/alice".into(),
251            ops: vec!["read:/user/*".into()],
252            executor: Executor { binding },
253            provenance: Some(Provenance {
254                cat: CatProvenance {
255                    issuer: "https://cat.acme-corp.com".into(),
256                    signature: vec![0u8; 64],
257                    key: KeyMaterial {
258                        public_key: vec![0u8; 32],
259                        alg: "EdDSA".into(),
260                    },
261                },
262                executor: ExecutorProvenance {
263                    issuer: "spiffe://acme-corp/archive".into(),
264                    signature: vec![0u8; 64],
265                    key: KeyMaterial {
266                        public_key: vec![0u8; 32],
267                        alg: "EdDSA".into(),
268                    },
269                },
270            }),
271            constraints: Some(Constraints {
272                temporal: Some(TemporalConstraints {
273                    iat: Some("2025-12-11T10:00:00Z".into()),
274                    exp: Some("2025-12-11T10:30:00Z".into()),
275                    nbf: None,
276                }),
277            }),
278        }
279    }
280
281    #[test]
282    fn test_pca_0_cbor_roundtrip() {
283        let pca = sample_pca_0();
284        let cbor = pca.to_cbor().unwrap();
285        let decoded = PcaPayload::from_cbor(&cbor).unwrap();
286        assert_eq!(pca, decoded);
287        assert_eq!(decoded.hop, "gateway");
288        assert!(decoded.provenance.is_none());
289    }
290
291    #[test]
292    fn test_pca_n_cbor_roundtrip() {
293        let pca = sample_pca_n();
294        let cbor = pca.to_cbor().unwrap();
295        let decoded = PcaPayload::from_cbor(&cbor).unwrap();
296        assert_eq!(pca, decoded);
297        assert_eq!(decoded.hop, "storage");
298        assert!(decoded.provenance.is_some());
299    }
300
301    #[test]
302    fn test_json_roundtrip() {
303        let pca = sample_pca_n();
304        let json = pca.to_json().unwrap();
305        let decoded = PcaPayload::from_json(&json).unwrap();
306        assert_eq!(pca, decoded);
307    }
308
309    #[test]
310    fn test_cbor_smaller_than_json() {
311        let pca = sample_pca_n();
312        let cbor = pca.to_cbor().unwrap();
313        let json = pca.to_json().unwrap();
314
315        println!("CBOR: {} bytes", cbor.len());
316        println!("JSON: {} bytes", json.len());
317
318        assert!(cbor.len() < json.len());
319    }
320
321    #[test]
322    fn test_json_output() {
323        let pca = sample_pca_n();
324        let json = pca.to_json_pretty().unwrap();
325        println!("{}", json);
326    }
327
328    #[test]
329    fn test_monotonicity_ops_reduced() {
330        let pca_0 = sample_pca_0();
331        let pca_n = sample_pca_n();
332
333        assert_eq!(pca_0.ops.len(), 2);
334        assert_eq!(pca_n.ops.len(), 1);
335        assert_eq!(pca_0.p_0, pca_n.p_0);
336    }
337
338    #[test]
339    fn test_minimal_executor_binding() {
340        let binding = ExecutorBinding::new().with("org", "simple-org");
341
342        let pca = PcaPayload {
343            hop: "service-a".into(),
344            p_0: "https://idp.example.com/users/alice".into(),
345            ops: vec!["read:/user/*".into()],
346            executor: Executor { binding },
347            provenance: None,
348            constraints: None,
349        };
350
351        let cbor = pca.to_cbor().unwrap();
352        let decoded = PcaPayload::from_cbor(&cbor).unwrap();
353
354        assert_eq!(decoded.executor.binding.get_str("org"), Some("simple-org"));
355    }
356
357    #[test]
358    fn test_executor_binding_flexible() {
359        let binding = ExecutorBinding::new()
360            .with("org", "acme-corp")
361            .with("region", "eu-west-1")
362            .with("env", "prod");
363
364        let pca = PcaPayload {
365            hop: "api-gateway".into(),
366            p_0: "https://idp.example.com/users/alice".into(),
367            ops: vec!["invoke:*".into()],
368            executor: Executor { binding },
369            provenance: None,
370            constraints: None,
371        };
372
373        let cbor = pca.to_cbor().unwrap();
374        let decoded = PcaPayload::from_cbor(&cbor).unwrap();
375
376        assert_eq!(decoded.executor.binding.get_str("org"), Some("acme-corp"));
377        assert_eq!(
378            decoded.executor.binding.get_str("region"),
379            Some("eu-west-1")
380        );
381    }
382
383    #[test]
384    fn test_nested_binding() {
385        let k8s = DynamicMap::new()
386            .with("cluster", "prod-eu")
387            .with("namespace", "default");
388
389        let binding = ExecutorBinding::new()
390            .with("org", "acme-corp")
391            .with_map("kubernetes", k8s)
392            .with_array("regions", vec!["eu-west-1", "eu-west-2"]);
393
394        let pca = PcaPayload {
395            hop: "k8s-service".into(),
396            p_0: "https://idp.example.com/users/alice".into(),
397            ops: vec!["read:*".into()],
398            executor: Executor { binding },
399            provenance: None,
400            constraints: None,
401        };
402
403        let json = pca.to_json_pretty().unwrap();
404        println!("{}", json);
405
406        let cbor = pca.to_cbor().unwrap();
407        let decoded = PcaPayload::from_cbor(&cbor).unwrap();
408
409        let k8s_decoded = decoded.executor.binding.get_map("kubernetes").unwrap();
410        assert_eq!(k8s_decoded.get_str("cluster"), Some("prod-eu"));
411        assert_eq!(k8s_decoded.get_str("namespace"), Some("default"));
412    }
413
414    #[test]
415    fn test_binding_with_json_value() {
416        let binding = ExecutorBinding::new().with("org", "acme-corp").with_value(
417            "metadata",
418            serde_json::json!({
419                "version": "1.2.3",
420                "replicas": 3,
421                "labels": {
422                    "app": "gateway",
423                    "tier": "frontend"
424                }
425            }),
426        );
427
428        let pca = PcaPayload {
429            hop: "gateway".into(),
430            p_0: "https://idp.example.com/users/alice".into(),
431            ops: vec!["read:*".into()],
432            executor: Executor { binding },
433            provenance: None,
434            constraints: None,
435        };
436
437        let json = pca.to_json_pretty().unwrap();
438        println!("{}", json);
439
440        let cbor = pca.to_cbor().unwrap();
441        let decoded = PcaPayload::from_cbor(&cbor).unwrap();
442
443        let metadata = decoded.executor.binding.get("metadata").unwrap();
444        assert_eq!(metadata["version"], "1.2.3");
445        assert_eq!(metadata["replicas"], 3);
446    }
447}