Skip to main content

varve_core/
keys.rs

1//! Signing-key material (REQ-KEYGEN-001).
2//!
3//! A ten-persona documentation audit found the extender path was not merely
4//! undocumented but CLOSED: nothing in varve emitted the public half of a
5//! signing key, so the 64-hex `trust-root` that `varve-realms.toml` demands was
6//! unobtainable. One persona held a working key, signed a layer with it, and
7//! tried six derivations plus offline DSSE-PAE verification across six message
8//! encodings — none matched. Four of five blocked personas were blocked here.
9//! Everything needed to run a second realm existed except the one function that
10//! prints a public key.
11//!
12//! The format, stated once so nobody has to reverse-engineer it again: a varve
13//! signing key is **64 bytes, hex-encoded (128 characters)** — a 32-byte
14//! ed25519 seed followed by its 32-byte public key. The trust root a consumer
15//! pins is that second half alone, 32 bytes as 64 hex characters.
16//!
17//! Because the public half travels inside the secret file, it can DISAGREE with
18//! the seed. A key like that signs happily and produces layers no trust root
19//! can ever verify, which is exactly what varve was doing with 64 bytes of
20//! random entropy. `check_keypair` exists so the produce side can refuse that
21//! before signing rather than after publishing.
22
23/// Bytes in a varve signing key: seed ‖ public.
24pub const SECRET_LEN: usize = 64;
25/// Bytes in the public half — what a realm pins as `trust-root`.
26pub const PUBLIC_LEN: usize = 32;
27
28#[derive(Debug, thiserror::Error)]
29pub enum KeyError {
30    #[error(
31        "{path} holds {got} hex character(s); a varve signing key is {want} — a 32-byte \
32         ed25519 seed followed by its 32-byte public key. Mint one with `varve keygen`."
33    )]
34    WrongLength {
35        path: String,
36        got: usize,
37        want: usize,
38    },
39    #[error("{path} is not hex: {reason}")]
40    NotHex { path: String, reason: String },
41    #[error(
42        "{path} is not a consistent keypair: the public half it carries is not the one its \
43         seed derives. Signing with it produces layers NO trust root can verify. Mint a \
44         fresh key with `varve keygen`."
45    )]
46    Mismatched { path: String },
47}
48
49/// Mint a signing key. Returns `(secret_hex, public_hex)` — the secret is what
50/// `deposit --key` reads, the public is what a realm pins as `trust-root`.
51pub fn generate() -> (String, String) {
52    let (sk, pk) = crate::verify::generate_root_keypair();
53    (hex_encode(&sk), hex_encode(&pk))
54}
55
56/// The public half of a signing key, in the exact form `trust-root` accepts.
57/// Validates length, encoding, and — crucially — that the carried public half
58/// actually belongs to the seed.
59pub fn public_from_secret(secret_hex: &str, path: &str) -> Result<String, KeyError> {
60    let bytes = decode_secret(secret_hex, path)?;
61    check_derived(&bytes, path)?;
62    Ok(hex_encode(&bytes[32..]))
63}
64
65/// Refuse a key that cannot produce verifiable signatures, BEFORE it signs
66/// anything. Returns the decoded 64 bytes on success.
67pub fn check_keypair(secret_hex: &str, path: &str) -> Result<Vec<u8>, KeyError> {
68    let bytes = decode_secret(secret_hex, path)?;
69    check_derived(&bytes, path)?;
70    Ok(bytes)
71}
72
73fn decode_secret(secret_hex: &str, path: &str) -> Result<Vec<u8>, KeyError> {
74    let trimmed = secret_hex.trim();
75    if trimmed.len() != SECRET_LEN * 2 {
76        return Err(KeyError::WrongLength {
77            path: path.to_string(),
78            got: trimmed.len(),
79            want: SECRET_LEN * 2,
80        });
81    }
82    hex_decode(trimmed).map_err(|reason| KeyError::NotHex {
83        path: path.to_string(),
84        reason,
85    })
86}
87
88/// The carried public half must actually verify what the seed signs. This is a
89/// ROUND TRIP rather than a structural derivation: sign a probe and verify it
90/// with the embedded public key. It tests the property that matters — "does
91/// signing with this key produce something a consumer can verify" — instead of
92/// a proxy for it, and it is the same mechanism `deposit` uses to check the
93/// envelope it just wrote.
94fn check_derived(bytes: &[u8], path: &str) -> Result<(), KeyError> {
95    let mismatched = || KeyError::Mismatched {
96        path: path.to_string(),
97    };
98    let probe = br#"{"varve":"keypair-probe"}"#;
99    let envelope = crate::verify::dsse_sign_typed(probe, PROBE_TYPE, bytes, "probe")
100        .map_err(|_| mismatched())?;
101    let verified = crate::verify::dsse_verify_typed(envelope.as_bytes(), PROBE_TYPE, &bytes[32..])
102        .map_err(|_| mismatched())?;
103    if verified != probe {
104        return Err(mismatched());
105    }
106    Ok(())
107}
108
109/// Payload type for the keypair-consistency probe. Distinct from every real
110/// document type, so a probe envelope can never be mistaken for one.
111const PROBE_TYPE: &str = "application/vnd.pulseengine.varve.keypair-probe.v1+json";
112
113fn hex_encode(bytes: &[u8]) -> String {
114    bytes.iter().map(|b| format!("{b:02x}")).collect()
115}
116
117fn hex_decode(s: &str) -> Result<Vec<u8>, String> {
118    if !s.len().is_multiple_of(2) {
119        return Err("odd number of hex digits".into());
120    }
121    (0..s.len())
122        .step_by(2)
123        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| e.to_string()))
124        .collect()
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    // rivet: verifies REQ-KEYGEN-001
132    #[test]
133    fn a_minted_key_yields_the_public_half_a_realm_pins() {
134        let (secret, public) = generate();
135        assert_eq!(secret.len(), SECRET_LEN * 2, "128 hex characters");
136        assert_eq!(public.len(), PUBLIC_LEN * 2, "64 hex characters");
137        // The route that did not exist: secret file -> the trust-root value.
138        assert_eq!(public_from_secret(&secret, "k").unwrap(), public);
139        // …and it is the shape realms already demand.
140        assert!(public.chars().all(|c| c.is_ascii_hexdigit()));
141    }
142
143    // rivet: verifies REQ-KEYGEN-001
144    #[test]
145    fn a_key_that_signs_unverifiably_is_refused_before_it_signs() {
146        // 64 bytes of entropy: varve accepted this and emitted signed layers no
147        // trust root on earth could verify, exit 0. This is that case.
148        let entropy = "ab".repeat(SECRET_LEN);
149        match check_keypair(&entropy, "random.key") {
150            Err(KeyError::Mismatched { .. }) => {}
151            other => panic!("entropy must be refused as a keypair, got {other:?}"),
152        }
153        // A real key with its public half corrupted is the same fault.
154        let (secret, _) = generate();
155        let mut bad = secret.clone();
156        bad.replace_range(64..66, if &secret[64..66] == "aa" { "bb" } else { "aa" });
157        assert!(matches!(
158            check_keypair(&bad, "tampered.key"),
159            Err(KeyError::Mismatched { .. })
160        ));
161        // The good one still passes.
162        assert!(check_keypair(&secret, "good.key").is_ok());
163    }
164
165    // rivet: verifies REQ-KEYGEN-001
166    #[test]
167    fn the_wrong_length_says_what_it_wanted() {
168        // The old error said "Ed25519 signature function error" and gave the
169        // same text for every wrong length. A 32-byte ed25519 secret — which
170        // the --key help text described — is the likeliest mistake.
171        let thirty_two = "ab".repeat(32);
172        let err = check_keypair(&thirty_two, "root.key").unwrap_err();
173        let msg = err.to_string();
174        assert!(msg.contains("64"), "must name what it got: {msg}");
175        assert!(msg.contains("128"), "must name what it needs: {msg}");
176        assert!(msg.contains("varve keygen"), "must carry its fix: {msg}");
177    }
178
179    // rivet: verifies REQ-KEYGEN-001
180    #[test]
181    fn non_hex_is_its_own_error_not_a_length_complaint() {
182        let not_hex = "z".repeat(SECRET_LEN * 2);
183        assert!(matches!(
184            check_keypair(&not_hex, "k"),
185            Err(KeyError::NotHex { .. })
186        ));
187    }
188
189    // rivet: verifies REQ-KEYGEN-001
190    #[test]
191    fn a_minted_key_actually_signs_and_verifies_end_to_end() {
192        // The property that matters: a key varve mints must produce a layer the
193        // public half it printed can verify. Anything less and keygen is a
194        // formatting exercise.
195        let (secret, public) = generate();
196        let sk = check_keypair(&secret, "k").unwrap();
197        let payload = crate::manifest::fixtures::manifest_with_tools(
198            "2026.08.0",
199            "qualified",
200            1,
201            "2026-08-01T00:00:00Z",
202            &[("synth", "sha256:aa")],
203        );
204        let envelope = crate::verify::sign_layer_manifest(&payload, &sk, "test-root").unwrap();
205        let pk = hex_decode(&public).unwrap();
206        let back = crate::verify::dsse_verify_typed(
207            envelope.as_bytes(),
208            crate::verify::LAYER_PAYLOAD_TYPE,
209            &pk,
210        )
211        .unwrap();
212        assert_eq!(
213            back, payload,
214            "the minted key round-trips through a real layer"
215        );
216    }
217}