use crate::hashing::sha256_prefixed;
use crate::registry::RegistryPackageVersion;
use crate::{PrayError, PrayResult};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use std::fs;
use std::path::Path;
pub const ED25519_SIGNATURE_PREFIX: &str = "ed25519:";
pub const SIGNING_KEY_ENV: &str = "PRAY_SIGNING_KEY";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackageSignatureMaterial {
pub signature: String,
pub signer_public_key: Option<String>,
}
pub fn require_remote_integrity_fields(
package_name: &str,
version: &str,
selected: &RegistryPackageVersion,
) -> PrayResult<()> {
if selected.artifact_hash.as_deref().unwrap_or("").is_empty() {
return Err(PrayError::Integrity(format!(
"package {package_name} {version} is missing artifact_hash"
)));
}
if selected.tree_hash.as_deref().unwrap_or("").is_empty() {
return Err(PrayError::Integrity(format!(
"package {package_name} {version} is missing tree_hash"
)));
}
Ok(())
}
pub fn artifact_content_digest(artifact_bytes: &[u8], tree_hash: &str, signer: &str) -> String {
let mut payload = Vec::with_capacity(artifact_bytes.len() + tree_hash.len() + signer.len() + 2);
payload.extend_from_slice(artifact_bytes);
payload.push(0);
payload.extend_from_slice(tree_hash.as_bytes());
payload.push(0);
payload.extend_from_slice(signer.as_bytes());
sha256_prefixed(&payload)
}
pub fn package_hash_signing_payload(artifact_hash: &str, tree_hash: &str) -> Vec<u8> {
let mut payload = Vec::with_capacity(artifact_hash.len() + tree_hash.len() + 1);
payload.extend_from_slice(artifact_hash.as_bytes());
payload.push(0);
payload.extend_from_slice(tree_hash.as_bytes());
payload
}
pub fn sign_package_hashes(
signing_key: &SigningKey,
artifact_hash: &str,
tree_hash: &str,
) -> String {
let payload = package_hash_signing_payload(artifact_hash, tree_hash);
let signature = signing_key.sign(&payload);
format!(
"{ED25519_SIGNATURE_PREFIX}{}",
STANDARD.encode(signature.to_bytes())
)
}
pub fn load_ed25519_signing_key(path: &Path) -> PrayResult<SigningKey> {
let private_key_bytes = fs::read(path).map_err(|error| {
PrayError::Unsupported(format!(
"failed to read signing key {}: {error}",
path.display()
))
})?;
let seed: [u8; 32] = private_key_bytes.as_slice().try_into().map_err(|_| {
PrayError::Unsupported(format!(
"signing key {} must be 32 raw ed25519 seed bytes",
path.display()
))
})?;
Ok(SigningKey::from_bytes(&seed))
}
pub fn resolve_publish_signing_key(explicit: Option<&Path>) -> PrayResult<Option<SigningKey>> {
if let Some(path) = explicit {
return Ok(Some(load_ed25519_signing_key(path)?));
}
match std::env::var(SIGNING_KEY_ENV) {
Ok(path) if !path.trim().is_empty() => {
Ok(Some(load_ed25519_signing_key(Path::new(path.trim()))?))
}
_ => Ok(None),
}
}
pub fn package_signature_for_publish(
signing_key: Option<&SigningKey>,
artifact_bytes: &[u8],
artifact_hash: &str,
tree_hash: &str,
signing_identity: &str,
) -> PackageSignatureMaterial {
if let Some(key) = signing_key {
return PackageSignatureMaterial {
signature: sign_package_hashes(key, artifact_hash, tree_hash),
signer_public_key: Some(ssh_public_key_from_verifying_key(&key.verifying_key())),
};
}
PackageSignatureMaterial {
signature: artifact_content_digest(artifact_bytes, tree_hash, signing_identity),
signer_public_key: None,
}
}
pub fn verifying_key_bytes_from_ssh_public_key(public_key: &str) -> PrayResult<[u8; 32]> {
let mut fields = public_key.split_whitespace();
let algorithm = fields.next().ok_or_else(|| {
PrayError::Unsupported("public key must include an algorithm".to_string())
})?;
if algorithm != "ssh-ed25519" {
return Err(PrayError::Unsupported(format!(
"unsupported public key algorithm: {algorithm}"
)));
}
let key_value = fields
.next()
.ok_or_else(|| PrayError::Unsupported("public key must include key bytes".to_string()))?;
let blob = STANDARD
.decode(key_value.as_bytes())
.map_err(|error| PrayError::Parse {
kind: "public key",
message: error.to_string(),
})?;
let mut cursor = blob.as_slice();
let blob_algorithm = read_ssh_string(&mut cursor)?;
if blob_algorithm != b"ssh-ed25519" {
return Err(PrayError::Parse {
kind: "public key",
message: "ed25519 public key blob must start with ssh-ed25519".to_string(),
});
}
let key_bytes = read_ssh_string(&mut cursor)?;
key_bytes
.as_slice()
.try_into()
.map_err(|_| PrayError::Parse {
kind: "public key",
message: "ed25519 public key must be 32 bytes".to_string(),
})
}
pub fn ssh_public_key_from_verifying_key(verifying_key: &VerifyingKey) -> String {
let mut blob = Vec::new();
write_ssh_string(&mut blob, b"ssh-ed25519");
write_ssh_string(&mut blob, verifying_key.as_bytes());
format!("ssh-ed25519 {}", STANDARD.encode(blob))
}
pub fn verify_package_signature(
package_name: &str,
version: &str,
selected: &RegistryPackageVersion,
artifact_bytes: &[u8],
tree_hash: &str,
) -> PrayResult<()> {
let Some(signature) = selected.signature.as_deref() else {
return Ok(());
};
if let Some(encoded) = signature.strip_prefix(ED25519_SIGNATURE_PREFIX) {
let public_key = selected.signer_public_key.as_deref().ok_or_else(|| {
PrayError::Integrity(format!(
"package {package_name} {version} ed25519 signature missing signer_public_key"
))
})?;
let artifact_hash = selected.artifact_hash.as_deref().ok_or_else(|| {
PrayError::Integrity(format!(
"package {package_name} {version} is missing artifact_hash"
))
})?;
let key_bytes = verifying_key_bytes_from_ssh_public_key(public_key)?;
let verifying_key =
VerifyingKey::from_bytes(&key_bytes).map_err(|error| PrayError::Parse {
kind: "public key",
message: error.to_string(),
})?;
let signature_bytes =
STANDARD
.decode(encoded.as_bytes())
.map_err(|error| PrayError::Parse {
kind: "signature",
message: error.to_string(),
})?;
let signature = Signature::from_slice(&signature_bytes)
.map_err(|error| PrayError::Integrity(error.to_string()))?;
let payload = package_hash_signing_payload(artifact_hash, tree_hash);
verifying_key.verify(&payload, &signature).map_err(|_| {
PrayError::Integrity(format!(
"package signature mismatch for {package_name} {version}"
))
})?;
return Ok(());
}
let signing_identity = crate::ssh_identity::package_signing_identity(
selected.signer.as_deref(),
selected.signer_fingerprint.as_deref(),
)
.ok_or_else(|| {
PrayError::Integrity(format!(
"package signature missing signer for {package_name} {version}"
))
})?;
let expected = artifact_content_digest(artifact_bytes, tree_hash, &signing_identity);
if expected != signature {
return Err(PrayError::Integrity(format!(
"package signature mismatch for {package_name} {version}"
)));
}
Ok(())
}
fn read_ssh_string(cursor: &mut &[u8]) -> PrayResult<Vec<u8>> {
if cursor.len() < 4 {
return Err(PrayError::Resolution(
"truncated ssh public key blob".to_string(),
));
}
let length = u32::from_be_bytes([cursor[0], cursor[1], cursor[2], cursor[3]]) as usize;
*cursor = &cursor[4..];
if cursor.len() < length {
return Err(PrayError::Resolution(
"truncated ssh public key blob".to_string(),
));
}
let (value, rest) = cursor.split_at(length);
*cursor = rest;
Ok(value.to_vec())
}
fn write_ssh_string(buffer: &mut Vec<u8>, bytes: &[u8]) {
buffer.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
buffer.extend_from_slice(bytes);
}