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)))
}
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 struct SigningKey(Ed25519KeyPair);
impl SigningKey {
pub fn generate() -> Self {
Self(Ed25519KeyPair::generate().expect("Ed25519 key generation cannot fail"))
}
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}");
}
#[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));
}
}