Skip to main content

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}