paygress/volume_encryption.rs
1// Volume encryption — consumer-side key derivation for Phase 1.
2//
3// The provider creates a LUKS-encrypted volume keyed by the bytes
4// shipped in `EncryptedSpawnPodRequest.volume_encryption.key_b64`.
5// This module gives consumers a *deterministic* way to compute that
6// key from material they already hold (their nsec + the workload id),
7// so a respawn after eviction or top-up doesn't need a separate
8// out-of-band key vault.
9//
10// Determinism is the load-bearing property:
11// derive_volume_key(nsec, workload_id) == derive_volume_key(nsec, workload_id)
12// always. The consumer can recompute the same key on every respawn
13// without persisting anything beyond what they already persist (the
14// nsec in `~/.paygress/identity` and the workload id printed at spawn
15// time).
16//
17// Threat model recap (mirrors the doc on `VolumeEncryption`):
18// - Defends against post-eviction disk forensics, lazy host backups,
19// co-tenant attacks on shared storage, cold-disk seizure.
20// - Does NOT defend against a live host with `CAP_SYS_PTRACE` reading
21// /proc/<pid>/mem or extracting the LUKS key from the kernel
22// keyring while the workload runs. That requires hardware
23// confidential VMs (SEV-SNP / TDX), gated behind the
24// `attested-research-tier` `IsolationLevel`.
25//
26// Why one-shot SHA-256 instead of HKDF: the inputs are
27// already-uniform high-entropy material (a 32-byte secp256k1 secret
28// key plus a UUID). HKDF's extract step exists to handle non-uniform
29// input keying material; we don't have that. A domain-separated
30// SHA-256 is sufficient and avoids pulling another dep just to derive
31// 32 bytes.
32
33use sha2::{Digest, Sha256};
34
35/// Domain-separation tag for v1 volume keys. Bumping this breaks
36/// every existing volume — only do so on a schema version bump
37/// of `VolumeEncryption`.
38const KDF_DOMAIN_V1: &[u8] = b"paygress-volume-v1\0";
39
40/// Derive the 32-byte volume key from the consumer's nsec bytes and
41/// the workload id.
42///
43/// Inputs:
44/// - `nsec_bytes` — the consumer's 32-byte secp256k1 secret key
45/// (raw bytes, not bech32-encoded).
46/// - `workload_id` — the consumer-assigned workload identifier
47/// (the same UUID-shaped string passed in
48/// `EncryptedSpawnPodRequest.workload_id`).
49///
50/// The two inputs are length-prefixed implicitly via the trailing
51/// NUL byte in `KDF_DOMAIN_V1` — `workload_id` cannot contain NULs
52/// (it's a UUID), so collisions across the (nsec, workload_id)
53/// boundary are not constructible.
54pub fn derive_volume_key(nsec_bytes: &[u8; 32], workload_id: &str) -> [u8; 32] {
55 let mut hasher = Sha256::new();
56 hasher.update(KDF_DOMAIN_V1);
57 hasher.update(nsec_bytes);
58 hasher.update(b"\0");
59 hasher.update(workload_id.as_bytes());
60 let digest = hasher.finalize();
61 let mut out = [0u8; 32];
62 out.copy_from_slice(&digest);
63 out
64}
65
66#[cfg(test)]
67mod tests {
68 use super::*;
69
70 fn nsec(b: u8) -> [u8; 32] {
71 [b; 32]
72 }
73
74 #[test]
75 fn derivation_is_deterministic() {
76 let k1 = derive_volume_key(&nsec(0x42), "workload-abc");
77 let k2 = derive_volume_key(&nsec(0x42), "workload-abc");
78 assert_eq!(k1, k2);
79 }
80
81 #[test]
82 fn different_workload_ids_yield_different_keys() {
83 let k1 = derive_volume_key(&nsec(0x42), "workload-a");
84 let k2 = derive_volume_key(&nsec(0x42), "workload-b");
85 assert_ne!(k1, k2);
86 }
87
88 #[test]
89 fn different_nsecs_yield_different_keys() {
90 let k1 = derive_volume_key(&nsec(0x01), "workload-x");
91 let k2 = derive_volume_key(&nsec(0x02), "workload-x");
92 assert_ne!(k1, k2);
93 }
94
95 #[test]
96 fn key_is_thirty_two_bytes() {
97 let k = derive_volume_key(&nsec(0x00), "");
98 assert_eq!(k.len(), 32);
99 }
100
101 #[test]
102 fn boundary_collision_is_not_constructible() {
103 // The NUL separator means appending the boundary into one
104 // half cannot impersonate the other half. Concretely:
105 // (nsec="X..", workload="Y..") must not collide with
106 // (nsec="X..Y", workload="..") or similar splits. We can't
107 // construct nsecs with arbitrary bytes via the public API
108 // (it's [u8; 32]), but we sanity-check that the workload-id
109 // cannot back-derive the same digest by tunneling NUL.
110 let k1 = derive_volume_key(&nsec(0x42), "ab");
111 let k2 = derive_volume_key(&nsec(0x42), "a\0b");
112 assert_ne!(
113 k1, k2,
114 "embedding NUL in workload_id must not collide with the canonical separator"
115 );
116 }
117}