pic_pca/
poc.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//! PoC (Proof of Continuity) payload model.
18//!
19//! Defines the PoC data structure for CBOR serialization within COSE_Sign1 envelope.
20//! Based on PIC Spec v0.1.
21
22use crate::pca::{Constraints, ExecutorBinding, KeyMaterial};
23use serde::{Deserialize, Serialize};
24
25// ============================================================================
26// Proof Components
27// ============================================================================
28
29/// Proof of Identity - asserts the executor's claimed identity.
30///
31/// Supported types: `spiffe_svid`, `jwt`, `vc`, `x509`.
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
33pub struct ProofOfIdentity {
34    pub r#type: String,
35    #[serde(with = "serde_bytes")]
36    pub value: Vec<u8>,
37}
38
39/// Proof of Possession - demonstrates control over a credential or key.
40///
41/// Supported types: `signature`.
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
43pub struct ProofOfPossession {
44    pub r#type: String,
45    #[serde(with = "serde_bytes")]
46    pub value: Vec<u8>,
47}
48
49/// Challenge Response - freshness binding to a PCC (PIC Causal Challenge).
50///
51/// Supported types: `nonce`.
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
53pub struct ChallengeResponse {
54    pub r#type: String,
55    #[serde(with = "serde_bytes")]
56    pub value: Vec<u8>,
57}
58
59/// Proof bundle containing all executor authentication components.
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
61pub struct Proof {
62    /// Proof of Identity
63    pub poi: ProofOfIdentity,
64    /// Proof of Possession
65    pub pop: ProofOfPossession,
66    /// Challenge response (present if PCC was issued)
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub challenge: Option<ChallengeResponse>,
69    /// Public key for PoC signature verification
70    pub key_material: KeyMaterial,
71}
72
73// ============================================================================
74// Successor
75// ============================================================================
76
77/// Successor - proposed authority for the next hop.
78///
79/// Must satisfy monotonicity: `ops ⊆ predecessor.ops`.
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
81pub struct Successor {
82    /// Requested operations (must be subset of predecessor)
83    pub ops: Vec<String>,
84    /// Next executor binding (if known at submission time)
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub executor: Option<ExecutorBinding>,
87    /// Restricted constraints (must be subset of predecessor)
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub constraints: Option<Constraints>,
90}
91
92// ============================================================================
93// PoC Payload
94// ============================================================================
95
96/// PoC Payload - the CBOR content signed by the executor with COSE_Sign1.
97///
98/// The predecessor is stored as raw COSE bytes to:
99/// - Avoid forced deserialization on creation
100/// - Preserve original bytes for signature verification
101/// - Enable efficient forwarding without re-encoding
102///
103/// CAT deserializes the predecessor when validating monotonicity.
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
105pub struct PocPayload {
106    /// Predecessor PCA as raw COSE_Sign1 bytes
107    #[serde(with = "serde_bytes")]
108    pub predecessor: Vec<u8>,
109    /// Proposed authority for next hop
110    pub successor: Successor,
111    /// Executor authentication proofs
112    pub proof: Proof,
113}
114
115impl PocPayload {
116    /// Serializes to CBOR bytes.
117    pub fn to_cbor(&self) -> Result<Vec<u8>, ciborium::ser::Error<std::io::Error>> {
118        let mut buf = Vec::new();
119        ciborium::into_writer(self, &mut buf)?;
120        Ok(buf)
121    }
122
123    /// Deserializes from CBOR bytes.
124    pub fn from_cbor(bytes: &[u8]) -> Result<Self, ciborium::de::Error<std::io::Error>> {
125        ciborium::from_reader(bytes)
126    }
127
128    /// Serializes to JSON string.
129    pub fn to_json(&self) -> Result<String, serde_json::Error> {
130        serde_json::to_string(self)
131    }
132
133    /// Serializes to pretty-printed JSON string.
134    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
135        serde_json::to_string_pretty(self)
136    }
137
138    /// Deserializes from JSON string.
139    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
140        serde_json::from_str(json)
141    }
142}
143
144// ============================================================================
145// Builder
146// ============================================================================
147
148/// Builder for creating PoC payloads with validation.
149#[derive(Debug, Clone)]
150pub struct PocBuilder {
151    predecessor: Vec<u8>,
152    ops: Vec<String>,
153    executor: Option<ExecutorBinding>,
154    constraints: Option<Constraints>,
155    poi: Option<ProofOfIdentity>,
156    pop: Option<ProofOfPossession>,
157    challenge: Option<ChallengeResponse>,
158    key_material: Option<KeyMaterial>,
159}
160
161impl PocBuilder {
162    /// Creates a new builder with the predecessor PCA bytes.
163    pub fn new(predecessor_cose_bytes: Vec<u8>) -> Self {
164        Self {
165            predecessor: predecessor_cose_bytes,
166            ops: Vec::new(),
167            executor: None,
168            constraints: None,
169            poi: None,
170            pop: None,
171            challenge: None,
172            key_material: None,
173        }
174    }
175
176    /// Sets the requested operations (must be subset of predecessor).
177    pub fn ops(mut self, ops: Vec<String>) -> Self {
178        self.ops = ops;
179        self
180    }
181
182    /// Sets the next executor binding.
183    pub fn executor(mut self, binding: ExecutorBinding) -> Self {
184        self.executor = Some(binding);
185        self
186    }
187
188    /// Sets the constraints.
189    pub fn constraints(mut self, constraints: Constraints) -> Self {
190        self.constraints = Some(constraints);
191        self
192    }
193
194    /// Sets the Proof of Identity.
195    pub fn poi(mut self, poi_type: &str, value: Vec<u8>) -> Self {
196        self.poi = Some(ProofOfIdentity {
197            r#type: poi_type.into(),
198            value,
199        });
200        self
201    }
202
203    /// Sets the Proof of Possession.
204    pub fn pop(mut self, pop_type: &str, value: Vec<u8>) -> Self {
205        self.pop = Some(ProofOfPossession {
206            r#type: pop_type.into(),
207            value,
208        });
209        self
210    }
211
212    /// Sets the challenge response.
213    pub fn challenge(mut self, challenge_type: &str, value: Vec<u8>) -> Self {
214        self.challenge = Some(ChallengeResponse {
215            r#type: challenge_type.into(),
216            value,
217        });
218        self
219    }
220
221    /// Sets the key material for signature verification.
222    pub fn key_material(mut self, public_key: Vec<u8>, alg: &str) -> Self {
223        self.key_material = Some(KeyMaterial {
224            public_key,
225            alg: alg.into(),
226        });
227        self
228    }
229
230    /// Builds the PoC payload, returning an error if required fields are missing.
231    pub fn build(self) -> Result<PocPayload, &'static str> {
232        let poi = self.poi.ok_or("PoI is required")?;
233        let pop = self.pop.ok_or("PoP is required")?;
234        let key_material = self.key_material.ok_or("Key material is required")?;
235
236        if self.ops.is_empty() {
237            return Err("Ops cannot be empty");
238        }
239
240        Ok(PocPayload {
241            predecessor: self.predecessor,
242            successor: Successor {
243                ops: self.ops,
244                executor: self.executor,
245                constraints: self.constraints,
246            },
247            proof: Proof {
248                poi,
249                pop,
250                challenge: self.challenge,
251                key_material,
252            },
253        })
254    }
255}
256
257// ============================================================================
258// Tests
259// ============================================================================
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::pca::{Executor, ExecutorBinding, PcaPayload, TemporalConstraints};
265
266    /// Creates sample predecessor bytes (simulates COSE-signed PCA).
267    fn sample_predecessor_bytes() -> Vec<u8> {
268        let pca = PcaPayload {
269            hop: "gateway".into(),
270            p_0: "https://idp.example.com/users/alice".into(),
271            ops: vec!["read:/user/*".into(), "write:/user/*".into()],
272            executor: Executor {
273                binding: ExecutorBinding::new().with("org", "acme"),
274            },
275            provenance: None,
276            constraints: None,
277        };
278        pca.to_cbor().unwrap()
279    }
280
281    #[test]
282    fn test_poc_cbor_roundtrip() {
283        let poc = PocPayload {
284            predecessor: sample_predecessor_bytes(),
285            successor: Successor {
286                ops: vec!["read:/user/*".into()],
287                executor: Some(ExecutorBinding::new().with("namespace", "prod")),
288                constraints: Some(Constraints {
289                    temporal: Some(TemporalConstraints {
290                        iat: None,
291                        exp: Some("2025-12-11T10:30:00Z".into()),
292                        nbf: None,
293                    }),
294                }),
295            },
296            proof: Proof {
297                poi: ProofOfIdentity {
298                    r#type: "spiffe_svid".into(),
299                    value: vec![0x01, 0x02, 0x03],
300                },
301                pop: ProofOfPossession {
302                    r#type: "signature".into(),
303                    value: vec![0x04, 0x05, 0x06],
304                },
305                challenge: Some(ChallengeResponse {
306                    r#type: "nonce".into(),
307                    value: vec![0x07, 0x08, 0x09],
308                }),
309                key_material: KeyMaterial {
310                    public_key: vec![0u8; 32],
311                    alg: "EdDSA".into(),
312                },
313            },
314        };
315
316        let cbor = poc.to_cbor().unwrap();
317        let decoded = PocPayload::from_cbor(&cbor).unwrap();
318
319        assert_eq!(poc, decoded);
320        assert_eq!(decoded.successor.ops, vec!["read:/user/*"]);
321    }
322
323    #[test]
324    fn test_poc_json_roundtrip() {
325        let poc = PocPayload {
326            predecessor: sample_predecessor_bytes(),
327            successor: Successor {
328                ops: vec!["read:/user/*".into()],
329                executor: None,
330                constraints: None,
331            },
332            proof: Proof {
333                poi: ProofOfIdentity {
334                    r#type: "jwt".into(),
335                    value: b"eyJhbGciOiJFUzI1NiJ9...".to_vec(),
336                },
337                pop: ProofOfPossession {
338                    r#type: "signature".into(),
339                    value: vec![0xAB; 64],
340                },
341                challenge: None,
342                key_material: KeyMaterial {
343                    public_key: vec![0u8; 32],
344                    alg: "ES256".into(),
345                },
346            },
347        };
348
349        let json = poc.to_json().unwrap();
350        let decoded = PocPayload::from_json(&json).unwrap();
351
352        assert_eq!(poc, decoded);
353    }
354
355    #[test]
356    fn test_poc_builder() {
357        let poc = PocBuilder::new(sample_predecessor_bytes())
358            .ops(vec!["read:/user/*".into()])
359            .executor(ExecutorBinding::new().with("namespace", "prod"))
360            .poi("spiffe_svid", vec![0x01, 0x02])
361            .pop("signature", vec![0x03, 0x04])
362            .challenge("nonce", vec![0x05, 0x06])
363            .key_material(vec![0u8; 32], "EdDSA")
364            .build()
365            .unwrap();
366
367        assert_eq!(poc.successor.ops, vec!["read:/user/*"]);
368        assert!(poc.successor.executor.is_some());
369        assert!(poc.proof.challenge.is_some());
370    }
371
372    #[test]
373    fn test_poc_builder_minimal() {
374        let poc = PocBuilder::new(sample_predecessor_bytes())
375            .ops(vec!["read:/user/*".into()])
376            .poi("jwt", vec![0x01])
377            .pop("signature", vec![0x02])
378            .key_material(vec![0u8; 32], "EdDSA")
379            .build()
380            .unwrap();
381
382        assert!(poc.successor.executor.is_none());
383        assert!(poc.successor.constraints.is_none());
384        assert!(poc.proof.challenge.is_none());
385    }
386
387    #[test]
388    fn test_poc_builder_missing_required() {
389        let result = PocBuilder::new(sample_predecessor_bytes())
390            .ops(vec!["read:/user/*".into()])
391            .poi("jwt", vec![0x01])
392            // Missing PoP
393            .key_material(vec![0u8; 32], "EdDSA")
394            .build();
395
396        assert!(result.is_err());
397        assert_eq!(result.unwrap_err(), "PoP is required");
398    }
399
400    #[test]
401    fn test_poc_builder_empty_ops() {
402        let result = PocBuilder::new(sample_predecessor_bytes())
403            .poi("jwt", vec![0x01])
404            .pop("signature", vec![0x02])
405            .key_material(vec![0u8; 32], "EdDSA")
406            .build();
407
408        assert!(result.is_err());
409        assert_eq!(result.unwrap_err(), "Ops cannot be empty");
410    }
411
412    #[test]
413    fn test_monotonicity_example() {
414        // Predecessor has: read + write
415        // Successor requests only: read
416        // Valid monotonicity: ops ⊆ predecessor.ops
417
418        let poc = PocBuilder::new(sample_predecessor_bytes())
419            .ops(vec!["read:/user/*".into()])
420            .poi("spiffe_svid", vec![0x01])
421            .pop("signature", vec![0x02])
422            .key_material(vec![0u8; 32], "EdDSA")
423            .build()
424            .unwrap();
425
426        assert_eq!(poc.successor.ops.len(), 1);
427    }
428}