Skip to main content

confium_patterns/escrow/
service.rs

1//! Escrow service — encrypts and recovers keys.
2
3use crate::escrow::blob::EscrowBlob;
4use crate::escrow::metadata::EscrowMetadata;
5
6/// Errors during escrow operations.
7#[derive(Debug, thiserror::Error)]
8pub enum EscrowError {
9    /// Encapsulation failure.
10    #[error("encapsulation failure: {0}")]
11    Encapsulate(String),
12    /// AEAD encryption failure.
13    #[error("AEAD encryption failure: {0}")]
14    AeadEncrypt(String),
15    /// Decapsulation failure (threshold decryption ceremony failed).
16    #[error("decapsulation failure: {0}")]
17    Decapsulate(String),
18    /// Threshold not met.
19    #[error("threshold not met: have {have}, need {need}")]
20    ThresholdNotMet {
21        /// Number of partial decryptions collected.
22        have: usize,
23        /// Threshold T.
24        need: u32,
25    },
26    /// Invalid blob.
27    #[error("invalid blob: {0}")]
28    InvalidBlob(String),
29}
30
31/// Quorum public key (recipient of escrowed data).
32#[derive(Debug, Clone)]
33pub struct QuorumPublicKey {
34    /// Quorum identifier.
35    pub quorum_id: String,
36    /// Algorithm.
37    pub algorithm: String,
38    /// Raw public key bytes.
39    pub bytes: Vec<u8>,
40    /// Number of custodians N.
41    pub custodian_count: u32,
42    /// Threshold T.
43    pub threshold: u32,
44}
45
46/// Encapsulator hook — caller provides concrete threshold KEM implementation.
47pub trait Encapsulator {
48    /// Encapsulate to the quorum public key. Returns (encapsulated_key, shared_secret).
49    fn encapsulate(&self, recipient: &QuorumPublicKey) -> Result<(Vec<u8>, Vec<u8>), EscrowError>;
50}
51
52/// AEAD hook — caller provides concrete AEAD implementation.
53pub trait Aead {
54    /// Encrypt plaintext with shared_secret as key. Returns (ciphertext, nonce).
55    fn encrypt(
56        &self,
57        shared_secret: &[u8],
58        plaintext: &[u8],
59        aad: &[u8],
60    ) -> Result<(Vec<u8>, Vec<u8>), EscrowError>;
61
62    /// Decrypt ciphertext with shared_secret as key.
63    fn decrypt(
64        &self,
65        shared_secret: &[u8],
66        ciphertext: &[u8],
67        nonce: &[u8],
68        aad: &[u8],
69    ) -> Result<Vec<u8>, EscrowError>;
70}
71
72/// The escrow service.
73pub struct EscrowService;
74
75impl EscrowService {
76    /// Construct a new escrow service.
77    pub fn new() -> Self {
78        Self
79    }
80
81    /// Escrow a key (or any secret) to a recipient quorum.
82    #[allow(clippy::too_many_arguments)]
83    pub fn escrow(
84        &self,
85        plaintext_key: &[u8],
86        recipient: &QuorumPublicKey,
87        escrowed_by: &str,
88        key_id: &str,
89        key_type: &str,
90        encapsulator: &dyn Encapsulator,
91        aead: &dyn Aead,
92    ) -> Result<EscrowBlob, EscrowError> {
93        let metadata = EscrowMetadata::new(
94            escrowed_by,
95            key_id,
96            key_type,
97            recipient.custodian_count,
98            recipient.threshold,
99        );
100        let aad = metadata_string(&metadata);
101
102        let (encapsulated_key, shared_secret) = encapsulator.encapsulate(recipient)?;
103        let (ciphertext, nonce) = aead.encrypt(&shared_secret, plaintext_key, aad.as_bytes())?;
104
105        Ok(EscrowBlob {
106            recipient_quorum_id: recipient.quorum_id.clone(),
107            encapsulated_key,
108            ciphertext,
109            nonce,
110            aad: aad.into_bytes(),
111            metadata,
112        })
113    }
114
115    /// Recover a key from a blob, given the recovered shared secret.
116    ///
117    /// The caller is responsible for running the threshold decryption
118    /// ceremony to recover the shared secret (typically via
119    /// `confium-tc-kem`). This function takes the recovered secret
120    /// and performs the final AEAD decryption.
121    pub fn recover(
122        &self,
123        blob: &EscrowBlob,
124        shared_secret: &[u8],
125        aead: &dyn Aead,
126    ) -> Result<Vec<u8>, EscrowError> {
127        aead.decrypt(shared_secret, &blob.ciphertext, &blob.nonce, &blob.aad)
128    }
129}
130
131impl Default for EscrowService {
132    fn default() -> Self {
133        Self::new()
134    }
135}
136
137fn metadata_string(m: &EscrowMetadata) -> String {
138    // Lightweight deterministic encoding (canonical JSON via serde_json::to_string).
139    serde_json::to_string(m).unwrap_or_default()
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    /// Mock encapsulator: returns the public key bytes as encapsulated,
147    /// 32 zero bytes as shared secret.
148    struct MockEncapsulator;
149    impl Encapsulator for MockEncapsulator {
150        fn encapsulate(
151            &self,
152            recipient: &QuorumPublicKey,
153        ) -> Result<(Vec<u8>, Vec<u8>), EscrowError> {
154            Ok((recipient.bytes.clone(), vec![0u8; 32]))
155        }
156    }
157
158    /// Mock AEAD: XOR plaintext with shared_secret (truncated/extended).
159    struct MockAead;
160    impl Aead for MockAead {
161        fn encrypt(
162            &self,
163            shared_secret: &[u8],
164            plaintext: &[u8],
165            _aad: &[u8],
166        ) -> Result<(Vec<u8>, Vec<u8>), EscrowError> {
167            let mut ct = vec![0u8; plaintext.len()];
168            for (i, b) in plaintext.iter().enumerate() {
169                ct[i] = b ^ shared_secret[i % shared_secret.len()];
170            }
171            Ok((ct, vec![0u8; 12]))
172        }
173        fn decrypt(
174            &self,
175            shared_secret: &[u8],
176            ciphertext: &[u8],
177            _nonce: &[u8],
178            _aad: &[u8],
179        ) -> Result<Vec<u8>, EscrowError> {
180            let mut pt = vec![0u8; ciphertext.len()];
181            for (i, b) in ciphertext.iter().enumerate() {
182                pt[i] = b ^ shared_secret[i % shared_secret.len()];
183            }
184            Ok(pt)
185        }
186    }
187
188    fn sample_quorum() -> QuorumPublicKey {
189        QuorumPublicKey {
190            quorum_id: "test-quorum".into(),
191            algorithm: "mock-threshold-kem".into(),
192            bytes: vec![1u8; 32],
193            custodian_count: 3,
194            threshold: 2,
195        }
196    }
197
198    #[test]
199    fn escrow_then_recover_round_trips() {
200        let service = EscrowService::new();
201        let quorum = sample_quorum();
202        let plaintext = b"this is a very secret key";
203
204        let blob = service
205            .escrow(
206                plaintext,
207                &quorum,
208                "alice",
209                "key-1",
210                "Ed25519",
211                &MockEncapsulator,
212                &MockAead,
213            )
214            .unwrap();
215
216        // In real usage, this would come from a threshold decryption ceremony.
217        let shared_secret = vec![0u8; 32];
218        let recovered = service.recover(&blob, &shared_secret, &MockAead).unwrap();
219
220        assert_eq!(recovered.as_slice(), plaintext);
221        assert_eq!(blob.metadata.threshold, 2);
222        assert_eq!(blob.metadata.custodian_count, 3);
223    }
224
225    #[test]
226    fn blob_has_fingerprint() {
227        let service = EscrowService::new();
228        let blob = service
229            .escrow(
230                b"test",
231                &sample_quorum(),
232                "alice",
233                "key-1",
234                "Ed25519",
235                &MockEncapsulator,
236                &MockAead,
237            )
238            .unwrap();
239        let fp = blob.fingerprint();
240        assert_eq!(fp.len(), 32);
241    }
242}