pray_core/
package_integrity.rs1use 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}