Skip to main content

varve_core/
verify.rs

1//! Manifest signature verification against the PulseEngine trust root
2//! (REQ-VERIFY-001, DD-003).
3//!
4//! Layer manifests travel as DSSE envelopes (payload = the OCI image index
5//! JSON), signed with the PulseEngine root ed25519 key via sigil's `wsc`
6//! library. The envelope authenticates both the payload bytes and the
7//! payload *type* (DSSE PAE), so a signed something-else cannot be replayed
8//! as a layer manifest.
9//!
10//! The verifier holds the trust root; sources cannot see it, supply it, or
11//! relax it. Keyless/Fulcio verification via sigil's airgapped module is the
12//! intended future — blocked on sigil#219 — and slots in behind the same
13//! [`crate::install::ManifestVerifier`] trait when it lands.
14
15use wsc::dsse::{DsseEnvelope, Ed25519DsseSigner, Ed25519DsseVerifier};
16
17use crate::install::{ManifestVerifier, VerifyError};
18
19/// The authenticated payload type for a layer manifest envelope.
20pub const LAYER_PAYLOAD_TYPE: &str = "application/vnd.pulseengine.varve.layer.v1+json";
21
22/// Verifies layer-manifest envelopes against a pinned ed25519 root public
23/// key (32 raw bytes).
24pub struct PinnedKeyVerifier {
25    verifier: Ed25519DsseVerifier,
26}
27
28impl PinnedKeyVerifier {
29    pub fn from_public_key_bytes(public_key: &[u8]) -> Result<Self, VerifyError> {
30        let verifier = Ed25519DsseVerifier::from_bytes(public_key)
31            .map_err(|e| VerifyError(format!("invalid trust-root public key: {e}")))?;
32        Ok(PinnedKeyVerifier { verifier })
33    }
34}
35
36impl ManifestVerifier for PinnedKeyVerifier {
37    fn verify(&self, fetched: &[u8]) -> Result<Vec<u8>, VerifyError> {
38        verify_with(&self.verifier, fetched, LAYER_PAYLOAD_TYPE)
39    }
40}
41
42/// Verify a DSSE envelope of a specific payload type against a pinned root
43/// public key, returning the authenticated payload. The one verification
44/// path shared by layer manifests, line-status documents (DD-008) and
45/// release sums (DD-009) — different payload types, one trust root, so the
46/// type check is what keeps signed documents from posing as each other.
47pub fn dsse_verify_typed(
48    envelope: &[u8],
49    expected_payload_type: &str,
50    root_public_key: &[u8],
51) -> Result<Vec<u8>, VerifyError> {
52    let verifier = Ed25519DsseVerifier::from_bytes(root_public_key)
53        .map_err(|e| VerifyError(format!("invalid trust-root public key: {e}")))?;
54    verify_with(&verifier, envelope, expected_payload_type)
55}
56
57fn verify_with(
58    verifier: &Ed25519DsseVerifier,
59    envelope: &[u8],
60    expected_payload_type: &str,
61) -> Result<Vec<u8>, VerifyError> {
62    let text = std::str::from_utf8(envelope)
63        .map_err(|_| VerifyError("fetched bytes are not UTF-8 DSSE JSON".into()))?;
64    let envelope = DsseEnvelope::from_json(text)
65        .map_err(|e| VerifyError(format!("not a DSSE envelope: {e}")))?;
66    if envelope.payload_type != expected_payload_type {
67        return Err(VerifyError(format!(
68            "envelope payload type is '{}', expected '{expected_payload_type}' — refusing to \
69             accept a signed document as something it is not",
70            envelope.payload_type
71        )));
72    }
73    envelope.verify_all(verifier).map_err(|e| {
74        VerifyError(format!(
75            "signature does not verify against the trust root: {e}"
76        ))
77    })
78}
79
80/// Sign a payload of a given type into a DSSE envelope (JSON) — the
81/// producing half, shared by deposit, line-status and release-sums signing
82/// so the two sides can never drift apart.
83pub fn dsse_sign_typed(
84    payload: &[u8],
85    payload_type: &str,
86    secret_key: &[u8],
87    key_id: &str,
88) -> Result<String, VerifyError> {
89    let signer = Ed25519DsseSigner::from_bytes(secret_key, Some(key_id.to_string()))
90        .map_err(|e| VerifyError(format!("invalid signing key: {e}")))?;
91    let envelope = DsseEnvelope::sign(payload, payload_type, &signer)
92        .map_err(|e| VerifyError(format!("signing failed: {e}")))?;
93    envelope
94        .to_json_pretty()
95        .map_err(|e| VerifyError(format!("envelope serialization failed: {e}")))
96}
97
98/// Sign layer-manifest payload bytes into a DSSE envelope (JSON).
99pub fn sign_layer_manifest(
100    payload: &[u8],
101    secret_key: &[u8],
102    key_id: &str,
103) -> Result<String, VerifyError> {
104    dsse_sign_typed(payload, LAYER_PAYLOAD_TYPE, secret_key, key_id)
105}
106
107/// Generate an ed25519 root keypair: (secret 64 bytes, public 32 bytes).
108/// Test and provisioning helper — real roots are generated in a key
109/// ceremony, not on a build machine.
110pub fn generate_root_keypair() -> (Vec<u8>, Vec<u8>) {
111    let kp = wsc::KeyPair::generate();
112    (kp.sk.sk.to_vec(), kp.pk.pk.to_vec())
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::install::{InstallError, InstallPolicy, install};
119    use crate::manifest::fixtures::manifest_with_tools;
120    use crate::pin::Pin;
121    use crate::rollback::HighWaterMarks;
122    use crate::source::MemorySource;
123    use crate::store::{Store, manifest_digest};
124
125    fn keys() -> (Vec<u8>, Vec<u8>) {
126        generate_root_keypair()
127    }
128
129    // rivet: verifies REQ-VERIFY-001
130    #[test]
131    fn a_signed_manifest_verifies_and_yields_its_payload() {
132        let (sk, pk) = keys();
133        let payload = manifest_with_tools("2026.07.0", "qualified", 1, "2026-07-31T09:14:00Z", &[]);
134        let envelope = sign_layer_manifest(&payload, &sk, "varve-root-1").unwrap();
135        let verifier = PinnedKeyVerifier::from_public_key_bytes(&pk).unwrap();
136        use crate::install::ManifestVerifier as _;
137        assert_eq!(verifier.verify(envelope.as_bytes()).unwrap(), payload);
138    }
139
140    // rivet: verifies REQ-VERIFY-001
141    #[test]
142    fn a_signature_from_an_untrusted_key_is_rejected() {
143        let (sk, _) = keys();
144        let (_, other_pk) = keys();
145        let payload = manifest_with_tools("2026.07.0", "qualified", 1, "2026-07-31T09:14:00Z", &[]);
146        let envelope = sign_layer_manifest(&payload, &sk, "varve-root-1").unwrap();
147        let verifier = PinnedKeyVerifier::from_public_key_bytes(&other_pk).unwrap();
148        use crate::install::ManifestVerifier as _;
149        assert!(verifier.verify(envelope.as_bytes()).is_err());
150    }
151
152    // rivet: verifies REQ-VERIFY-001
153    #[test]
154    fn a_tampered_payload_is_rejected() {
155        let (sk, pk) = keys();
156        let payload = manifest_with_tools("2026.07.0", "qualified", 1, "2026-07-31T09:14:00Z", &[]);
157        let envelope = sign_layer_manifest(&payload, &sk, "varve-root-1").unwrap();
158        // Flip the counter inside the base64 payload by re-signing... no —
159        // tamper the envelope JSON's payload field directly.
160        let mut parsed: serde_json::Value = serde_json::from_str(&envelope).unwrap();
161        let b64 = parsed["payload"].as_str().unwrap().to_string();
162        let mut chars: Vec<char> = b64.chars().collect();
163        chars[0] = if chars[0] == 'A' { 'B' } else { 'A' };
164        parsed["payload"] = serde_json::Value::String(chars.into_iter().collect());
165        let tampered = serde_json::to_string(&parsed).unwrap();
166        let verifier = PinnedKeyVerifier::from_public_key_bytes(&pk).unwrap();
167        use crate::install::ManifestVerifier as _;
168        assert!(verifier.verify(tampered.as_bytes()).is_err());
169    }
170
171    // rivet: verifies REQ-VERIFY-001
172    #[test]
173    fn a_signed_envelope_of_the_wrong_payload_type_is_rejected() {
174        let (sk, pk) = keys();
175        let payload = b"some signed but non-manifest content";
176        let signer = wsc::dsse::Ed25519DsseSigner::from_bytes(&sk, None).unwrap();
177        let envelope = wsc::dsse::DsseEnvelope::sign(payload, "application/other", &signer)
178            .unwrap()
179            .to_json()
180            .unwrap();
181        let verifier = PinnedKeyVerifier::from_public_key_bytes(&pk).unwrap();
182        use crate::install::ManifestVerifier as _;
183        let err = verifier.verify(envelope.as_bytes()).unwrap_err();
184        assert!(err.to_string().contains("payload type"), "got: {err}");
185    }
186
187    // rivet: verifies REQ-VERIFY-001
188    #[test]
189    fn install_accepts_a_properly_signed_layer_and_rejects_the_same_bytes_resigned_by_an_impostor()
190    {
191        let (sk, pk) = keys();
192        let (impostor_sk, _) = keys();
193        let synth = b"tool-bytes".to_vec();
194        let blob_digest = manifest_digest(&synth);
195        let payload = manifest_with_tools(
196            "2026.07.0",
197            "qualified",
198            1,
199            "2026-07-31T09:14:00Z",
200            &[("synth", &blob_digest)],
201        );
202        let pin = Pin::parse(
203            "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\n",
204            "varve.toml",
205        )
206        .unwrap();
207        let policy = InstallPolicy {
208            index: None,
209            now: "2026-08-07T00:00:00Z",
210            staleness_threshold_days: 90,
211            platform: "test-platform",
212        };
213        let verifier = PinnedKeyVerifier::from_public_key_bytes(&pk).unwrap();
214
215        // Properly signed: accepted, and the *payload* is what lands.
216        let good = sign_layer_manifest(&payload, &sk, "varve-root-1").unwrap();
217        let source = MemorySource::new()
218            .with_manifest(good.as_bytes())
219            .with_blob(&blob_digest, &synth);
220        let tmp = tempfile::tempdir().unwrap();
221        let store = Store::at(tmp.path().join("root"));
222        let mut marks = HighWaterMarks::load(&tmp.path().join("root")).unwrap();
223        let outcome = install(&pin, &source, &verifier, &store, &mut marks, &policy).unwrap();
224        assert_eq!(outcome.digest, manifest_digest(&payload));
225        let entry = store.get(&outcome.digest).unwrap().unwrap();
226        assert_eq!(
227            std::fs::read(entry.root.join("layer.json")).unwrap(),
228            payload,
229            "the core stores the verified payload, not the envelope"
230        );
231
232        // Same payload, impostor signature: rejected before anything lands.
233        let evil = sign_layer_manifest(&payload, &impostor_sk, "varve-root-1").unwrap();
234        let evil_source = MemorySource::new()
235            .with_manifest(evil.as_bytes())
236            .with_blob(&blob_digest, &synth);
237        let tmp2 = tempfile::tempdir().unwrap();
238        let store2 = Store::at(tmp2.path().join("root"));
239        let mut marks2 = HighWaterMarks::load(&tmp2.path().join("root")).unwrap();
240        let err =
241            install(&pin, &evil_source, &verifier, &store2, &mut marks2, &policy).unwrap_err();
242        assert!(matches!(err, InstallError::Verify(_)), "got: {err}");
243        assert!(store2.list().unwrap().is_empty());
244    }
245}