use base64::Engine as _;
use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig};
use hmac::Mac;
use hmac::digest::KeyInit;
use sha2::{Digest as _, Sha256};
const B64_STD_LENIENT: GeneralPurpose = GeneralPurpose::new(
&base64::alphabet::STANDARD,
GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent),
);
const B64_URL_LENIENT: GeneralPurpose = GeneralPurpose::new(
&base64::alphabet::URL_SAFE,
GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent),
);
#[derive(Debug, Clone, Copy)]
pub enum Codec {
Base64,
Base64Url,
Hex,
}
impl Codec {
pub fn parse(name: &str) -> Option<Codec> {
match name {
"hex" => Some(Codec::Hex),
"base64" => Some(Codec::Base64),
"base64url" => Some(Codec::Base64Url),
_ => None,
}
}
}
pub fn encode_bytes(codec: Codec, bytes: &[u8]) -> String {
match codec {
Codec::Base64 => base64::engine::general_purpose::STANDARD.encode(bytes),
Codec::Base64Url => base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes),
Codec::Hex => hex::encode(bytes),
}
}
pub fn decode_bytes(codec: Codec, s: &str) -> Result<Vec<u8>, String> {
match codec {
Codec::Base64 => B64_STD_LENIENT.decode(s).map_err(|e| e.to_string()),
Codec::Base64Url => B64_URL_LENIENT.decode(s).map_err(|e| e.to_string()),
Codec::Hex => hex::decode(s).map_err(|e| e.to_string()),
}
}
pub fn mac_compute<M: Mac + KeyInit>(key: &[u8], data: &[u8]) -> Vec<u8> {
let mut mac = M::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(data);
mac.finalize().into_bytes().to_vec()
}
pub fn mac_verify<M: Mac + KeyInit>(key: &[u8], data: &[u8], signature: &[u8]) -> bool {
let Ok(mut mac) = M::new_from_slice(key) else {
return false;
};
mac.update(data);
mac.verify_slice(signature).is_ok()
}
pub fn random_bytes(n: usize) -> Vec<u8> {
use rand::Rng as _;
let mut buf = vec![0u8; n];
rand::rng().fill_bytes(&mut buf);
buf
}
pub fn sha256_digest(bytes: &[u8]) -> String {
format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
}
#[derive(Default)]
pub struct Sha256Stream(Sha256);
impl Sha256Stream {
pub fn new() -> Self {
Self(Sha256::new())
}
pub fn update(&mut self, chunk: &[u8]) {
self.0.update(chunk);
}
pub fn finish(self) -> String {
format!("sha256:{}", hex::encode(self.0.finalize()))
}
}
pub fn is_sha256_digest(s: &str) -> bool {
s.strip_prefix("sha256:").is_some_and(|hex| {
hex.len() == 64
&& hex
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
})
}
pub mod ed25519 {
use aws_lc_rs::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
use base64::Engine as _;
pub const KEY_LEN: usize = 32;
pub const SIGNATURE_LEN: usize = 64;
fn b64() -> base64::engine::GeneralPurpose {
base64::engine::general_purpose::STANDARD
}
pub fn parse_public_key(encoded: &str) -> Result<Vec<u8>, String> {
let bytes = b64()
.decode(encoded.trim())
.map_err(|e| format!("not base64: {e}"))?;
if bytes.len() != KEY_LEN {
return Err(format!(
"an Ed25519 public key is {KEY_LEN} bytes, this one decodes to {}",
bytes.len()
));
}
Ok(bytes)
}
pub fn verify(
public_keys: &[String],
digest: &str,
signature: Option<&str>,
) -> Result<(), String> {
if public_keys.is_empty() {
return Ok(());
}
let Some(signature) = signature.map(str::trim).filter(|s| !s.is_empty()) else {
return Err(format!(
"this node requires a signature over the digest ({} trust key(s) configured) \
and none was given",
public_keys.len()
));
};
let sig = b64()
.decode(signature)
.map_err(|e| format!("signature is not base64: {e}"))?;
if sig.len() != SIGNATURE_LEN {
return Err(format!(
"an Ed25519 signature is {SIGNATURE_LEN} bytes, this one decodes to {}",
sig.len()
));
}
for key in public_keys {
let key = parse_public_key(key)?;
if UnparsedPublicKey::new(&ED25519, key)
.verify(digest.as_bytes(), &sig)
.is_ok()
{
return Ok(());
}
}
Err(format!(
"the signature does not verify over {digest} with any of the {} configured key(s)",
public_keys.len()
))
}
pub fn normalize_signature(text: &str) -> Result<String, String> {
let compact: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect();
if compact.is_empty() {
return Err(format!(
"empty — expected the base64 of a {SIGNATURE_LEN}-byte Ed25519 signature"
));
}
let bytes = b64()
.decode(&compact)
.map_err(|e| format!("not base64: {e}"))?;
if bytes.len() != SIGNATURE_LEN {
return Err(format!(
"decodes to {} bytes, an Ed25519 signature is {SIGNATURE_LEN}",
bytes.len()
));
}
Ok(b64().encode(bytes))
}
pub struct SigningKey(Ed25519KeyPair);
impl SigningKey {
pub fn generate() -> Self {
Self(Ed25519KeyPair::generate().expect("Ed25519 key generation cannot fail"))
}
pub fn from_pkcs8_pem(pem: &str) -> Result<Self, String> {
use rustls::pki_types::PrivatePkcs8KeyDer;
use rustls::pki_types::pem::PemObject as _;
let der = PrivatePkcs8KeyDer::from_pem_slice(pem.as_bytes()).map_err(|_| {
"not a PEM \"PRIVATE KEY\" block — an encrypted or OpenSSH-format key is not \
supported; convert it with `openssl pkey -in <key> -out signer.pem`"
.to_string()
})?;
Ed25519KeyPair::from_pkcs8(der.secret_pkcs8_der())
.map(Self)
.map_err(|e| format!("not an Ed25519 key ({e})"))
}
pub fn to_pkcs8_pem(&self) -> String {
let der = self
.0
.to_pkcs8v1()
.expect("an Ed25519 key pair always serialises to PKCS#8 v1");
let body = b64().encode(der.as_ref());
let mut pem = String::from("-----BEGIN PRIVATE KEY-----\n");
let mut rest = body.as_str();
while !rest.is_empty() {
let (line, tail) = rest.split_at(rest.len().min(64));
pem.push_str(line);
pem.push('\n');
rest = tail;
}
pem.push_str("-----END PRIVATE KEY-----\n");
pem
}
pub fn public_key_base64(&self) -> String {
b64().encode(self.0.public_key().as_ref())
}
pub fn sign(&self, digest: &str) -> String {
b64().encode(self.0.sign(digest.as_bytes()).as_ref())
}
}
#[cfg(test)]
mod tests {
use super::*;
const DIGEST: &str =
"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
#[test]
fn a_signature_by_a_configured_key_verifies_and_nothing_else_does() {
let key = SigningKey::generate();
let other = SigningKey::generate();
let keys = vec![other.public_key_base64(), key.public_key_base64()];
let sig = key.sign(DIGEST);
verify(&keys, DIGEST, Some(&sig)).expect("signed by the second configured key");
let err =
verify(&keys, &DIGEST.replace('0', "1"), Some(&sig)).expect_err("other digest");
assert!(err.contains("does not verify"), "{err}");
let err = verify(&[other.public_key_base64()], DIGEST, Some(&sig))
.expect_err("a key that did not sign");
assert!(err.contains("does not verify"), "{err}");
let err = verify(&keys, DIGEST, None).expect_err("no signature");
assert!(err.contains("none was given"), "{err}");
let err = verify(&keys, DIGEST, Some("not base64!")).expect_err("garbage");
assert!(err.contains("not base64"), "{err}");
let err = verify(&keys, DIGEST, Some(&b64().encode([0u8; 10]))).expect_err("short");
assert!(err.contains("64 bytes"), "{err}");
}
const OPENSSL_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\
MC4CAQAwBQYDK2VwBCIEIIpW4b4xhXfqCE3oQChl9i9WwKgVBGt3tl8ug/2dD1R8\n\
-----END PRIVATE KEY-----\n";
const OPENSSL_PUBLIC: &str = "BSJl36HbLWCKvvaOZP/hycrqS0xySEa2UUQAjj2Xpws=";
const OPENSSL_SIGNATURE: &str = "uDv0vIveDKYsMvPL9aSQKt3epQHbc5sG6iDAlQcSFGyzvkQhgcP88/uRELpGAKPnUg7rz8FPTQfkeTmBJaqPDQ==";
#[test]
fn an_openssl_key_loads_and_signs_as_openssl_does() {
let key = SigningKey::from_pkcs8_pem(OPENSSL_PEM).expect("openssl's PKCS#8 v1");
assert_eq!(key.public_key_base64(), OPENSSL_PUBLIC);
assert_eq!(key.sign(DIGEST), OPENSSL_SIGNATURE);
verify(
&[OPENSSL_PUBLIC.to_string()],
DIGEST,
Some(OPENSSL_SIGNATURE),
)
.expect("the golden signature verifies");
assert_eq!(key.to_pkcs8_pem(), OPENSSL_PEM);
}
#[test]
fn a_generated_key_round_trips_through_pem() {
let key = SigningKey::generate();
let pem = key.to_pkcs8_pem();
assert!(pem.starts_with("-----BEGIN PRIVATE KEY-----\n"), "{pem}");
let back = SigningKey::from_pkcs8_pem(&pem).expect("round trip");
assert_eq!(back.public_key_base64(), key.public_key_base64());
assert_eq!(back.sign(DIGEST), key.sign(DIGEST));
}
#[test]
fn a_non_ed25519_or_encrypted_pem_is_refused_with_a_reason() {
let p256 = "-----BEGIN PRIVATE KEY-----\n\
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgU+6kHM8j3fHwZ3uE\n\
4hJHGT5FFyJFtX3eItG0SYKhxlyhRANCAAQ8wczARjrBBPQMdpPzqA1q4hE4eT61\n\
3NB5BrMK3NRcF12Nct5f5uuDK/XIVkCtJMxQCaSmhUc1mnZaGYQQR1+A\n\
-----END PRIVATE KEY-----\n";
let err = SigningKey::from_pkcs8_pem(p256).err().expect("not Ed25519");
assert!(err.contains("not an Ed25519 key"), "{err}");
let encrypted = "-----BEGIN ENCRYPTED PRIVATE KEY-----\nMAA=\n\
-----END ENCRYPTED PRIVATE KEY-----\n";
let err = SigningKey::from_pkcs8_pem(encrypted)
.err()
.expect("encrypted");
assert!(err.contains("encrypted or OpenSSH"), "{err}");
let err = SigningKey::from_pkcs8_pem("hello").err().expect("no PEM");
assert!(err.contains("openssl pkey"), "{err}");
}
#[test]
fn normalize_signature_tolerates_whitespace_and_nothing_else() {
assert_eq!(
normalize_signature(&format!("{OPENSSL_SIGNATURE}\n")).expect("newline"),
OPENSSL_SIGNATURE
);
let (a, b) = OPENSSL_SIGNATURE.split_at(76);
assert_eq!(
normalize_signature(&format!("{a}\r\n{b}\r\n")).expect("wrapped"),
OPENSSL_SIGNATURE
);
let err = normalize_signature("not base64!").expect_err("garbage");
assert!(err.contains("not base64"), "{err}");
let err = normalize_signature(&b64().encode([7u8; 63])).expect_err("63 bytes");
assert!(err.contains("decodes to 63 bytes"), "{err}");
let err = normalize_signature(" \n").expect_err("empty");
assert!(err.contains("empty"), "{err}");
}
#[test]
fn no_configured_key_means_nothing_is_checked() {
verify(&[], DIGEST, None).expect("no keys, no check");
verify(&[], DIGEST, Some("anything")).expect("no keys, no check");
}
#[test]
fn a_public_key_must_decode_to_thirty_two_bytes() {
assert!(parse_public_key("nope").is_err());
let err = parse_public_key(&b64().encode([1u8; 31])).expect_err("31 bytes");
assert!(err.contains("32 bytes"), "{err}");
assert_eq!(
parse_public_key(&SigningKey::generate().public_key_base64())
.expect("valid")
.len(),
KEY_LEN
);
}
}
}
pub fn ensure_provider() {
if rustls::crypto::CryptoProvider::get_default().is_none() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_encoding_table_is_one_table() {
let bytes = b"\xde\xad\xbe\xef\xff";
assert_eq!(encode_bytes(Codec::Hex, bytes), "deadbeefff");
assert_eq!(encode_bytes(Codec::Base64, bytes), "3q2+7/8=");
assert_eq!(encode_bytes(Codec::Base64Url, bytes), "3q2-7_8");
for codec in [Codec::Hex, Codec::Base64, Codec::Base64Url] {
let encoded = encode_bytes(codec, bytes);
assert_eq!(decode_bytes(codec, &encoded).expect("round trip"), bytes);
}
}
#[test]
fn base64_decoding_is_indifferent_to_padding() {
assert_eq!(
decode_bytes(Codec::Base64, "3q2+7/8").expect("unpadded standard"),
b"\xde\xad\xbe\xef\xff"
);
assert_eq!(
decode_bytes(Codec::Base64Url, "3q2-7_8=").expect("padded url-safe"),
b"\xde\xad\xbe\xef\xff"
);
}
#[test]
fn an_unknown_codec_name_is_not_guessed_at() {
assert!(Codec::parse("base32").is_none());
assert!(Codec::parse("BASE64").is_none());
}
#[test]
fn mac_verify_rejects_a_wrong_length_signature() {
type H = hmac::Hmac<sha2::Sha256>;
let key = b"a-webhook-secret";
let data = b"payload";
let good = mac_compute::<H>(key, data);
assert!(mac_verify::<H>(key, data, &good));
assert!(!mac_verify::<H>(key, data, &good[..16]));
assert!(!mac_verify::<H>(key, data, &[]));
assert!(!mac_verify::<H>(b"wrong-secret", data, &good));
}
#[test]
fn the_artifact_digest_is_prefixed_lowercase_hex() {
let digest = sha256_digest(b"hello world");
assert_eq!(
digest,
"sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
);
assert!(is_sha256_digest(&digest));
assert!(!is_sha256_digest(&digest.to_uppercase()));
assert!(!is_sha256_digest("sha256:abc"));
assert!(!is_sha256_digest(&digest["sha256:".len()..]));
}
#[test]
fn random_bytes_are_the_requested_width_and_do_not_repeat() {
assert_eq!(random_bytes(32).len(), 32);
assert_eq!(random_bytes(0).len(), 0);
assert_ne!(random_bytes(32), random_bytes(32));
}
}