Skip to main content

enclavia_protocol/
custody.rs

1//! Self-hosted control-key custody helpers.
2//!
3//! In self-hosted custody the backend never holds the control private
4//! key: the CLI signs upgrade confirmations and revocations with a key
5//! it keeps locally (passphrase-protected keyfile or YubiKey PIV). Both
6//! sides must agree on the exact bytes the envelope signature covers,
7//! which are the CBOR encoding of the [`ControlCommand`] the enclave
8//! decodes. These helpers are that single encoding path: the backend's
9//! managed flow and the CLI's self-hosted flow both call them, so the
10//! bytes can never drift between the two.
11//!
12//! The module also carries the signing-request DTOs exchanged over the
13//! two-phase confirm/revoke HTTP endpoints (`.../confirm/prepare` and
14//! `.../confirm/submit`, plus the revoke pair), shared verbatim by the
15//! CLI and the backend, and the DER to raw `r || s` re-encoding helper
16//! for signatures produced by PIV hardware or OpenSSL.
17
18use serde::{Deserialize, Serialize};
19
20use crate::{ControlCommand, RekeyParams};
21
22/// CBOR-encode a [`ControlCommand::PrepareUpgrade`].
23///
24/// Returns the exact bytes the ENVELOPE signature must be computed
25/// over (and that travel as `ClientMessage::Control.payload`). The
26/// enclave verifies the envelope signature against these bytes and
27/// then decodes them, so any re-encoding on the way breaks
28/// verification.
29///
30/// `payload` is the CBOR-encoded [`crate::chain::UpgradePayload`] and
31/// `payload_signature` the 64-byte raw `r || s` inner signature over
32/// it (see [`der_signature_to_raw`] for hardware signers that emit
33/// DER).
34pub fn encode_prepare_upgrade(
35    payload: &[u8],
36    payload_signature: &[u8; 64],
37    rekey: Option<RekeyParams>,
38    nonce: [u8; 32],
39) -> Vec<u8> {
40    let cmd = ControlCommand::PrepareUpgrade {
41        payload: payload.to_vec(),
42        payload_signature: payload_signature.to_vec(),
43        rekey,
44        nonce,
45    };
46    encode_command(&cmd)
47}
48
49/// CBOR-encode a [`ControlCommand::RevokeUpgrade`]. Same envelope
50/// contract as [`encode_prepare_upgrade`]; `payload` is the
51/// CBOR-encoded [`crate::chain::RevocationPayload`].
52pub fn encode_revoke_upgrade(
53    payload: &[u8],
54    payload_signature: &[u8; 64],
55    rollback: bool,
56    nonce: [u8; 32],
57) -> Vec<u8> {
58    let cmd = ControlCommand::RevokeUpgrade {
59        payload: payload.to_vec(),
60        payload_signature: payload_signature.to_vec(),
61        rollback,
62        nonce,
63    };
64    encode_command(&cmd)
65}
66
67/// Single serialization path for signed control commands. Writing into
68/// a `Vec` cannot fail for these plain-data enums, so the panic is
69/// unreachable in practice; panicking (vs. returning `Result`) keeps
70/// the two encode helpers infallible for callers on both sides.
71fn encode_command(cmd: &ControlCommand) -> Vec<u8> {
72    let mut buf = Vec::new();
73    ciborium::into_writer(cmd, &mut buf)
74        .expect("CBOR encoding a ControlCommand into a Vec cannot fail");
75    buf
76}
77
78/// Failure re-encoding a DER ECDSA signature to raw `r || s`.
79#[derive(Debug, thiserror::Error)]
80pub enum DerSignatureError {
81    /// The bytes did not parse as a DER-encoded P-256 ECDSA signature.
82    #[error("invalid DER ECDSA P-256 signature: {0}")]
83    InvalidDer(#[source] p256::ecdsa::Error),
84}
85
86/// Re-encode a DER ECDSA P-256 signature to the locked-in 64-byte raw
87/// `r || s` wire format: each scalar 32 bytes, big-endian,
88/// zero-padded.
89///
90/// PIV hardware (YubiKey) and OpenSSL emit DER, and may emit a high-S
91/// signature; the result is normalized to low-S so the enclave-side
92/// verifier accepts it regardless of which form the hardware produced.
93pub fn der_signature_to_raw(der: &[u8]) -> Result<[u8; 64], DerSignatureError> {
94    let sig = p256::ecdsa::Signature::from_der(der).map_err(DerSignatureError::InvalidDer)?;
95    let sig = sig.normalize_s().unwrap_or(sig);
96    let mut out = [0u8; 64];
97    out.copy_from_slice(&sig.to_bytes());
98    Ok(out)
99}
100
101/// Response body of `POST /enclaves/{id}/upgrades/{uid}/confirm/prepare`
102/// (self-hosted custody): everything the CLI needs to assemble and
103/// sign the `PrepareUpgrade` command offline.
104///
105/// The CLI signs `payload` (inner signature), calls
106/// [`encode_prepare_upgrade`] with `payload`, that signature, `rekey`,
107/// and `nonce`, then signs the returned bytes (envelope signature) and
108/// submits both via [`ConfirmSubmitRequest`].
109///
110/// `rekey` is embedded as the [`RekeyParams`] struct itself: its byte
111/// field serializes as a JSON number array (verbose but lossless), and
112/// carrying the typed struct guarantees the CLI re-embeds the exact
113/// value the backend prepared, so the CBOR command it assembles is
114/// byte-identical to what the backend would have assembled.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct ConfirmPrepareResponse {
117    /// CBOR-encoded [`crate::chain::UpgradePayload`], base64.
118    #[serde(with = "base64_vec")]
119    pub payload: Vec<u8>,
120    /// Current single-use control nonce fetched from the live enclave,
121    /// base64. Stays valid across the offline signing round-trip (the
122    /// enclave rotates it only when a `Control` message is processed).
123    #[serde(with = "base64_array32")]
124    pub nonce: [u8; 32],
125    /// Storage re-key parameters, `None` for stateless enclaves.
126    pub rekey: Option<RekeyParams>,
127    /// Activation time baked into `payload`, RFC3339. Informational:
128    /// the signed bytes are `payload`, this is for CLI display.
129    pub valid_from: String,
130}
131
132/// Request body of `POST /enclaves/{id}/upgrades/{uid}/confirm/submit`
133/// and `.../revoke/submit`: the fully-assembled command plus its
134/// envelope signature. The backend checks the decoded command matches
135/// what prepare issued (state-machine consistency only; the enclave is
136/// the real verifier) and dispatches it over the control channel.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct ConfirmSubmitRequest {
139    /// CBOR-encoded [`ControlCommand`] as produced by
140    /// [`encode_prepare_upgrade`] / [`encode_revoke_upgrade`], base64.
141    #[serde(with = "base64_vec")]
142    pub command: Vec<u8>,
143    /// 64-byte raw `r || s` P-256 signature over `command`, base64.
144    #[serde(with = "base64_vec")]
145    pub envelope_signature: Vec<u8>,
146}
147
148/// Revoke submissions carry the same shape as confirm submissions.
149pub type RevokeSubmitRequest = ConfirmSubmitRequest;
150
151/// Response body of `POST /enclaves/{id}/upgrades/{uid}/revoke/prepare`
152/// (self-hosted custody). Mirrors [`ConfirmPrepareResponse`] with the `RevokeUpgrade`
153/// command's field set: `rollback` instead of `rekey`, and no
154/// `valid_from` (revocations take effect immediately).
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct RevokePrepareResponse {
157    /// CBOR-encoded [`crate::chain::RevocationPayload`], base64.
158    #[serde(with = "base64_vec")]
159    pub payload: Vec<u8>,
160    /// Current single-use control nonce, base64.
161    #[serde(with = "base64_array32")]
162    pub nonce: [u8; 32],
163    /// Whether the enclave must roll back the LUKS keyslot added at
164    /// prepare time. Set by the backend from the staged row (a re-key
165    /// happened iff a new KMS key was minted).
166    pub rollback: bool,
167}
168
169/// Serde adapter: `Vec<u8>` as a standard-base64 (padded) JSON string,
170/// matching the chain endpoint's byte-field convention.
171mod base64_vec {
172    use base64::engine::general_purpose::STANDARD;
173    use base64::Engine as _;
174    use serde::{Deserialize, Deserializer, Serializer};
175
176    pub fn serialize<S: Serializer>(bytes: &Vec<u8>, ser: S) -> Result<S::Ok, S::Error> {
177        ser.serialize_str(&STANDARD.encode(bytes))
178    }
179
180    pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Vec<u8>, D::Error> {
181        let s = String::deserialize(de)?;
182        STANDARD.decode(s.as_bytes()).map_err(serde::de::Error::custom)
183    }
184}
185
186/// Serde adapter: `[u8; 32]` as a standard-base64 (padded) JSON string.
187/// Rejects any decoded length other than exactly 32 bytes.
188mod base64_array32 {
189    use base64::engine::general_purpose::STANDARD;
190    use base64::Engine as _;
191    use serde::{Deserialize, Deserializer, Serializer};
192
193    pub fn serialize<S: Serializer>(bytes: &[u8; 32], ser: S) -> Result<S::Ok, S::Error> {
194        ser.serialize_str(&STANDARD.encode(bytes))
195    }
196
197    pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<[u8; 32], D::Error> {
198        let s = String::deserialize(de)?;
199        let v = STANDARD.decode(s.as_bytes()).map_err(serde::de::Error::custom)?;
200        v.try_into()
201            .map_err(|v: Vec<u8>| serde::de::Error::custom(format!("expected 32 bytes, got {}", v.len())))
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use p256::ecdsa::signature::{Signer, Verifier};
209    use p256::ecdsa::{Signature, SigningKey, VerifyingKey};
210
211    fn sample_rekey() -> RekeyParams {
212        RekeyParams {
213            new_public_key: vec![0xAB; 70],
214            new_key_id: "arn:aws:kms:us-east-1:123:key/abc".into(),
215        }
216    }
217
218    /// Replicate the backend's current encoding sequence (build the enum,
219    /// `ciborium::into_writer`) so a helper drift shows up as a byte
220    /// mismatch here.
221    fn backend_style_encode(cmd: &ControlCommand) -> Vec<u8> {
222        let mut buf = Vec::new();
223        ciborium::into_writer(cmd, &mut buf).unwrap();
224        buf
225    }
226
227    #[test]
228    fn encode_prepare_upgrade_round_trips_and_matches_backend_encoding() {
229        let payload = vec![1u8, 2, 3, 4];
230        let payload_sig = [0xDEu8; 64];
231        let nonce = [0x42u8; 32];
232
233        let bytes = encode_prepare_upgrade(&payload, &payload_sig, Some(sample_rekey()), nonce);
234
235        // Byte-identical to the backend's encoding path.
236        let expected = backend_style_encode(&ControlCommand::PrepareUpgrade {
237            payload: payload.clone(),
238            payload_signature: payload_sig.to_vec(),
239            rekey: Some(sample_rekey()),
240            nonce,
241        });
242        assert_eq!(bytes, expected);
243
244        // Decodes as the command the enclave expects.
245        let back: ControlCommand = ciborium::from_reader(bytes.as_slice()).unwrap();
246        match back {
247            ControlCommand::PrepareUpgrade {
248                payload: p,
249                payload_signature: ps,
250                rekey,
251                nonce: n,
252            } => {
253                assert_eq!(p, payload);
254                assert_eq!(ps, payload_sig.to_vec());
255                let rk = rekey.expect("rekey present");
256                assert_eq!(rk.new_public_key, vec![0xAB; 70]);
257                assert_eq!(rk.new_key_id, "arn:aws:kms:us-east-1:123:key/abc");
258                assert_eq!(n, nonce);
259            }
260            _ => panic!("wrong variant"),
261        }
262    }
263
264    #[test]
265    fn encode_prepare_upgrade_stateless_matches_backend_encoding() {
266        let bytes = encode_prepare_upgrade(&[0xAA], &[0xBB; 64], None, [1u8; 32]);
267        let expected = backend_style_encode(&ControlCommand::PrepareUpgrade {
268            payload: vec![0xAA],
269            payload_signature: vec![0xBB; 64],
270            rekey: None,
271            nonce: [1u8; 32],
272        });
273        assert_eq!(bytes, expected);
274    }
275
276    #[test]
277    fn encode_revoke_upgrade_round_trips_and_matches_backend_encoding() {
278        let payload = vec![0xCCu8; 8];
279        let payload_sig = [0xDDu8; 64];
280        let nonce = [0x99u8; 32];
281
282        for rollback in [true, false] {
283            let bytes = encode_revoke_upgrade(&payload, &payload_sig, rollback, nonce);
284            let expected = backend_style_encode(&ControlCommand::RevokeUpgrade {
285                payload: payload.clone(),
286                payload_signature: payload_sig.to_vec(),
287                rollback,
288                nonce,
289            });
290            assert_eq!(bytes, expected);
291
292            let back: ControlCommand = ciborium::from_reader(bytes.as_slice()).unwrap();
293            match back {
294                ControlCommand::RevokeUpgrade {
295                    payload: p,
296                    rollback: rb,
297                    nonce: n,
298                    ..
299                } => {
300                    assert_eq!(p, payload);
301                    assert_eq!(rb, rollback);
302                    assert_eq!(n, nonce);
303                }
304                _ => panic!("wrong variant"),
305            }
306        }
307    }
308
309    #[test]
310    fn der_signature_to_raw_matches_direct_raw_encoding() {
311        let sk = SigningKey::from_bytes(&[7u8; 32].into()).unwrap();
312        let msg = b"custody der test vector";
313        let sig: Signature = sk.sign(msg);
314        // Reference: low-S normalized raw bytes.
315        let low = sig.normalize_s().unwrap_or(sig);
316
317        let raw = der_signature_to_raw(sig.to_der().as_bytes()).unwrap();
318        assert_eq!(raw, <[u8; 64]>::try_from(&low.to_bytes()[..]).unwrap());
319
320        // The wire form must verify exactly as the enclave does: parse the
321        // 64 raw bytes with from_slice, then verify.
322        let vk = VerifyingKey::from(&sk);
323        let parsed = Signature::from_slice(&raw).unwrap();
324        vk.verify(msg, &parsed).unwrap();
325    }
326
327    #[test]
328    fn der_signature_to_raw_normalizes_high_s() {
329        let sk = SigningKey::from_bytes(&[9u8; 32].into()).unwrap();
330        let msg = b"high-s normalization vector";
331        let sig: Signature = sk.sign(msg);
332        let low = sig.normalize_s().unwrap_or(sig);
333
334        // Synthesize the high-S twin: s' = n - s. PIV hardware can emit
335        // either form; the helper must map both onto the same low-S bytes.
336        let (r, s) = low.split_scalars();
337        let high_s = -*s.as_ref();
338        let high = Signature::from_scalars(r.to_bytes(), high_s.to_bytes()).unwrap();
339        assert!(high.normalize_s().is_some(), "twin must be high-S");
340
341        let raw = der_signature_to_raw(high.to_der().as_bytes()).unwrap();
342        assert_eq!(raw, <[u8; 64]>::try_from(&low.to_bytes()[..]).unwrap());
343
344        let vk = VerifyingKey::from(&sk);
345        let parsed = Signature::from_slice(&raw).unwrap();
346        vk.verify(msg, &parsed).unwrap();
347    }
348
349    #[test]
350    fn der_signature_to_raw_rejects_garbage() {
351        assert!(der_signature_to_raw(&[0u8; 64]).is_err());
352        assert!(der_signature_to_raw(b"not der").is_err());
353        assert!(der_signature_to_raw(&[]).is_err());
354    }
355
356    #[test]
357    fn confirm_prepare_response_serde_round_trip() {
358        let resp = ConfirmPrepareResponse {
359            payload: vec![1, 2, 3],
360            nonce: [0x11u8; 32],
361            rekey: Some(sample_rekey()),
362            valid_from: "2026-07-09T00:00:00Z".into(),
363        };
364        let json = serde_json::to_string(&resp).unwrap();
365        let back: ConfirmPrepareResponse = serde_json::from_str(&json).unwrap();
366        assert_eq!(back.payload, resp.payload);
367        assert_eq!(back.nonce, resp.nonce);
368        assert_eq!(back.valid_from, resp.valid_from);
369        let rk = back.rekey.as_ref().unwrap();
370        assert_eq!(rk.new_public_key, vec![0xAB; 70]);
371        assert_eq!(rk.new_key_id, "arn:aws:kms:us-east-1:123:key/abc");
372
373        // Byte fields are base64 strings on the wire, not number arrays.
374        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
375        assert!(v["payload"].is_string());
376        assert!(v["nonce"].is_string());
377    }
378
379    /// The load-bearing property: a command the CLI assembles from a
380    /// JSON-round-tripped prepare response is byte-identical to the one the
381    /// backend would have assembled from its in-memory values.
382    #[test]
383    fn rekey_survives_json_round_trip_byte_exactly() {
384        let payload = vec![5u8; 16];
385        let payload_sig = [0x77u8; 64];
386        let nonce = [0x33u8; 32];
387
388        let backend_cmd =
389            encode_prepare_upgrade(&payload, &payload_sig, Some(sample_rekey()), nonce);
390
391        let resp = ConfirmPrepareResponse {
392            payload,
393            nonce,
394            rekey: Some(sample_rekey()),
395            valid_from: "2026-07-09T00:00:00Z".into(),
396        };
397        let json = serde_json::to_string(&resp).unwrap();
398        let back: ConfirmPrepareResponse = serde_json::from_str(&json).unwrap();
399
400        let cli_cmd =
401            encode_prepare_upgrade(&back.payload, &payload_sig, back.rekey, back.nonce);
402        assert_eq!(cli_cmd, backend_cmd);
403    }
404
405    #[test]
406    fn confirm_submit_request_serde_round_trip() {
407        let req = ConfirmSubmitRequest {
408            command: vec![9, 8, 7],
409            envelope_signature: vec![0x55; 64],
410        };
411        let json = serde_json::to_string(&req).unwrap();
412        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
413        assert!(v["command"].is_string());
414        assert!(v["envelope_signature"].is_string());
415
416        let back: ConfirmSubmitRequest = serde_json::from_str(&json).unwrap();
417        assert_eq!(back.command, req.command);
418        assert_eq!(back.envelope_signature, req.envelope_signature);
419    }
420
421    #[test]
422    fn revoke_prepare_response_serde_round_trip() {
423        let resp = RevokePrepareResponse {
424            payload: vec![4, 5, 6],
425            nonce: [0x22u8; 32],
426            rollback: true,
427        };
428        let json = serde_json::to_string(&resp).unwrap();
429        let back: RevokePrepareResponse = serde_json::from_str(&json).unwrap();
430        assert_eq!(back.payload, resp.payload);
431        assert_eq!(back.nonce, resp.nonce);
432        assert!(back.rollback);
433    }
434
435    #[test]
436    fn nonce_deserialize_rejects_wrong_length() {
437        // 31 bytes of base64 must not silently truncate or pad.
438        let json = format!(
439            r#"{{"payload":"AQID","nonce":"{}","rekey":null,"valid_from":"x"}}"#,
440            {
441                use base64::Engine as _;
442                base64::engine::general_purpose::STANDARD.encode([0u8; 31])
443            }
444        );
445        assert!(serde_json::from_str::<ConfirmPrepareResponse>(&json).is_err());
446    }
447}