Skip to main content

pray_core/
package_integrity.rs

1use crate::hashing::sha256_prefixed;
2use crate::registry::RegistryPackageVersion;
3use crate::{PrayError, PrayResult};
4use base64::{engine::general_purpose::STANDARD, Engine as _};
5use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
6use std::fs;
7use std::path::Path;
8
9pub const ED25519_SIGNATURE_PREFIX: &str = "ed25519:";
10pub const SIGNING_KEY_ENV: &str = "PRAY_SIGNING_KEY";
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct PackageSignatureMaterial {
14    pub signature: String,
15    pub signer_public_key: Option<String>,
16}
17
18pub fn require_remote_integrity_fields(
19    package_name: &str,
20    version: &str,
21    selected: &RegistryPackageVersion,
22) -> PrayResult<()> {
23    if selected.artifact_hash.as_deref().unwrap_or("").is_empty() {
24        return Err(PrayError::Integrity(format!(
25            "package {package_name} {version} is missing artifact_hash"
26        )));
27    }
28    if selected.tree_hash.as_deref().unwrap_or("").is_empty() {
29        return Err(PrayError::Integrity(format!(
30            "package {package_name} {version} is missing tree_hash"
31        )));
32    }
33    Ok(())
34}
35
36pub fn artifact_content_digest(artifact_bytes: &[u8], tree_hash: &str, signer: &str) -> String {
37    let mut payload = Vec::with_capacity(artifact_bytes.len() + tree_hash.len() + signer.len() + 2);
38    payload.extend_from_slice(artifact_bytes);
39    payload.push(0);
40    payload.extend_from_slice(tree_hash.as_bytes());
41    payload.push(0);
42    payload.extend_from_slice(signer.as_bytes());
43    sha256_prefixed(&payload)
44}
45
46pub fn package_hash_signing_payload(artifact_hash: &str, tree_hash: &str) -> Vec<u8> {
47    let mut payload = Vec::with_capacity(artifact_hash.len() + tree_hash.len() + 1);
48    payload.extend_from_slice(artifact_hash.as_bytes());
49    payload.push(0);
50    payload.extend_from_slice(tree_hash.as_bytes());
51    payload
52}
53
54pub fn sign_package_hashes(
55    signing_key: &SigningKey,
56    artifact_hash: &str,
57    tree_hash: &str,
58) -> String {
59    let payload = package_hash_signing_payload(artifact_hash, tree_hash);
60    let signature = signing_key.sign(&payload);
61    format!(
62        "{ED25519_SIGNATURE_PREFIX}{}",
63        STANDARD.encode(signature.to_bytes())
64    )
65}
66
67pub fn load_ed25519_signing_key(path: &Path) -> PrayResult<SigningKey> {
68    let private_key_bytes = fs::read(path).map_err(|error| {
69        PrayError::Unsupported(format!(
70            "failed to read signing key {}: {error}",
71            path.display()
72        ))
73    })?;
74    let seed: [u8; 32] = private_key_bytes.as_slice().try_into().map_err(|_| {
75        PrayError::Unsupported(format!(
76            "signing key {} must be 32 raw ed25519 seed bytes",
77            path.display()
78        ))
79    })?;
80    Ok(SigningKey::from_bytes(&seed))
81}
82
83pub fn resolve_publish_signing_key(explicit: Option<&Path>) -> PrayResult<Option<SigningKey>> {
84    if let Some(path) = explicit {
85        return Ok(Some(load_ed25519_signing_key(path)?));
86    }
87    match std::env::var(SIGNING_KEY_ENV) {
88        Ok(path) if !path.trim().is_empty() => {
89            Ok(Some(load_ed25519_signing_key(Path::new(path.trim()))?))
90        }
91        _ => Ok(None),
92    }
93}
94
95pub fn package_signature_for_publish(
96    signing_key: Option<&SigningKey>,
97    artifact_bytes: &[u8],
98    artifact_hash: &str,
99    tree_hash: &str,
100    signing_identity: &str,
101) -> PackageSignatureMaterial {
102    if let Some(key) = signing_key {
103        return PackageSignatureMaterial {
104            signature: sign_package_hashes(key, artifact_hash, tree_hash),
105            signer_public_key: Some(ssh_public_key_from_verifying_key(&key.verifying_key())),
106        };
107    }
108    PackageSignatureMaterial {
109        signature: artifact_content_digest(artifact_bytes, tree_hash, signing_identity),
110        signer_public_key: None,
111    }
112}
113
114pub fn verifying_key_bytes_from_ssh_public_key(public_key: &str) -> PrayResult<[u8; 32]> {
115    let mut fields = public_key.split_whitespace();
116    let algorithm = fields.next().ok_or_else(|| {
117        PrayError::Unsupported("public key must include an algorithm".to_string())
118    })?;
119    if algorithm != "ssh-ed25519" {
120        return Err(PrayError::Unsupported(format!(
121            "unsupported public key algorithm: {algorithm}"
122        )));
123    }
124    let key_value = fields
125        .next()
126        .ok_or_else(|| PrayError::Unsupported("public key must include key bytes".to_string()))?;
127    let blob = STANDARD
128        .decode(key_value.as_bytes())
129        .map_err(|error| PrayError::Parse {
130            kind: "public key",
131            message: error.to_string(),
132        })?;
133    let mut cursor = blob.as_slice();
134    let blob_algorithm = read_ssh_string(&mut cursor)?;
135    if blob_algorithm != b"ssh-ed25519" {
136        return Err(PrayError::Parse {
137            kind: "public key",
138            message: "ed25519 public key blob must start with ssh-ed25519".to_string(),
139        });
140    }
141    let key_bytes = read_ssh_string(&mut cursor)?;
142    key_bytes
143        .as_slice()
144        .try_into()
145        .map_err(|_| PrayError::Parse {
146            kind: "public key",
147            message: "ed25519 public key must be 32 bytes".to_string(),
148        })
149}
150
151pub fn ssh_public_key_from_verifying_key(verifying_key: &VerifyingKey) -> String {
152    let mut blob = Vec::new();
153    write_ssh_string(&mut blob, b"ssh-ed25519");
154    write_ssh_string(&mut blob, verifying_key.as_bytes());
155    format!("ssh-ed25519 {}", STANDARD.encode(blob))
156}
157
158pub fn verify_package_signature(
159    package_name: &str,
160    version: &str,
161    selected: &RegistryPackageVersion,
162    artifact_bytes: &[u8],
163    tree_hash: &str,
164) -> PrayResult<()> {
165    let Some(signature) = selected.signature.as_deref() else {
166        return Ok(());
167    };
168    if let Some(encoded) = signature.strip_prefix(ED25519_SIGNATURE_PREFIX) {
169        let public_key = selected.signer_public_key.as_deref().ok_or_else(|| {
170            PrayError::Integrity(format!(
171                "package {package_name} {version} ed25519 signature missing signer_public_key"
172            ))
173        })?;
174        let artifact_hash = selected.artifact_hash.as_deref().ok_or_else(|| {
175            PrayError::Integrity(format!(
176                "package {package_name} {version} is missing artifact_hash"
177            ))
178        })?;
179        let key_bytes = verifying_key_bytes_from_ssh_public_key(public_key)?;
180        let verifying_key =
181            VerifyingKey::from_bytes(&key_bytes).map_err(|error| PrayError::Parse {
182                kind: "public key",
183                message: error.to_string(),
184            })?;
185        let signature_bytes =
186            STANDARD
187                .decode(encoded.as_bytes())
188                .map_err(|error| PrayError::Parse {
189                    kind: "signature",
190                    message: error.to_string(),
191                })?;
192        let signature = Signature::from_slice(&signature_bytes)
193            .map_err(|error| PrayError::Integrity(error.to_string()))?;
194        let payload = package_hash_signing_payload(artifact_hash, tree_hash);
195        verifying_key.verify(&payload, &signature).map_err(|_| {
196            PrayError::Integrity(format!(
197                "package signature mismatch for {package_name} {version}"
198            ))
199        })?;
200        return Ok(());
201    }
202
203    let signing_identity = crate::ssh_identity::package_signing_identity(
204        selected.signer.as_deref(),
205        selected.signer_fingerprint.as_deref(),
206    )
207    .ok_or_else(|| {
208        PrayError::Integrity(format!(
209            "package signature missing signer for {package_name} {version}"
210        ))
211    })?;
212    let expected = artifact_content_digest(artifact_bytes, tree_hash, &signing_identity);
213    if expected != signature {
214        return Err(PrayError::Integrity(format!(
215            "package signature mismatch for {package_name} {version}"
216        )));
217    }
218    Ok(())
219}
220
221fn read_ssh_string(cursor: &mut &[u8]) -> PrayResult<Vec<u8>> {
222    if cursor.len() < 4 {
223        return Err(PrayError::Resolution(
224            "truncated ssh public key blob".to_string(),
225        ));
226    }
227    let length = u32::from_be_bytes([cursor[0], cursor[1], cursor[2], cursor[3]]) as usize;
228    *cursor = &cursor[4..];
229    if cursor.len() < length {
230        return Err(PrayError::Resolution(
231            "truncated ssh public key blob".to_string(),
232        ));
233    }
234    let (value, rest) = cursor.split_at(length);
235    *cursor = rest;
236    Ok(value.to_vec())
237}
238
239fn write_ssh_string(buffer: &mut Vec<u8>, bytes: &[u8]) {
240    buffer.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
241    buffer.extend_from_slice(bytes);
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn ed25519_package_hash_signatures_round_trip() {
250        let signing_key = SigningKey::from_bytes(&[7u8; 32]);
251        let artifact_hash = "sha256:artifact";
252        let tree_hash = "sha256:tree";
253        let signature = sign_package_hashes(&signing_key, artifact_hash, tree_hash);
254        let public_key = ssh_public_key_from_verifying_key(&signing_key.verifying_key());
255        let selected = RegistryPackageVersion {
256            version: "1.0.0".to_string(),
257            artifact: "v1/artifacts/pkg.praypkg".to_string(),
258            artifact_hash: Some(artifact_hash.to_string()),
259            tree_hash: Some(tree_hash.to_string()),
260            signature: Some(signature),
261            signer_public_key: Some(public_key),
262            ..RegistryPackageVersion::default()
263        };
264        verify_package_signature("sample/base", "1.0.0", &selected, b"unused", tree_hash)
265            .expect("ed25519 signature should verify");
266    }
267
268    #[test]
269    fn require_remote_integrity_fields_fails_when_hashes_missing() {
270        let selected = RegistryPackageVersion {
271            version: "1.0.0".to_string(),
272            artifact: "v1/artifacts/pkg.praypkg".to_string(),
273            ..RegistryPackageVersion::default()
274        };
275        let error = require_remote_integrity_fields("sample/base", "1.0.0", &selected)
276            .expect_err("missing hashes");
277        assert!(error.to_string().contains("artifact_hash"));
278    }
279
280    #[test]
281    fn package_signature_for_publish_prefers_ed25519_when_key_present() {
282        let signing_key = SigningKey::from_bytes(&[9u8; 32]);
283        let artifact_bytes = b"archive";
284        let artifact_hash = sha256_prefixed(artifact_bytes);
285        let tree_hash = "sha256:tree";
286        let material = package_signature_for_publish(
287            Some(&signing_key),
288            artifact_bytes,
289            &artifact_hash,
290            tree_hash,
291            "legacy-signer",
292        );
293        assert!(material.signature.starts_with(ED25519_SIGNATURE_PREFIX));
294        assert_eq!(
295            material.signer_public_key.as_deref(),
296            Some(ssh_public_key_from_verifying_key(&signing_key.verifying_key()).as_str())
297        );
298        let selected = RegistryPackageVersion {
299            version: "1.0.0".to_string(),
300            artifact: "v1/artifacts/pkg.praypkg".to_string(),
301            artifact_hash: Some(artifact_hash),
302            tree_hash: Some(tree_hash.to_string()),
303            signature: Some(material.signature),
304            signer_public_key: material.signer_public_key,
305            ..RegistryPackageVersion::default()
306        };
307        verify_package_signature("sample/base", "1.0.0", &selected, artifact_bytes, tree_hash)
308            .expect("ed25519 publish signature should verify");
309    }
310
311    #[test]
312    fn package_signature_for_publish_falls_back_to_content_digest() {
313        let artifact_bytes = b"archive";
314        let artifact_hash = sha256_prefixed(artifact_bytes);
315        let tree_hash = "sha256:tree";
316        let material = package_signature_for_publish(
317            None,
318            artifact_bytes,
319            &artifact_hash,
320            tree_hash,
321            "legacy-signer",
322        );
323        assert!(!material.signature.starts_with(ED25519_SIGNATURE_PREFIX));
324        assert_eq!(material.signer_public_key, None);
325        assert_eq!(
326            material.signature,
327            artifact_content_digest(artifact_bytes, tree_hash, "legacy-signer")
328        );
329    }
330}