use base64ct::{Base64Url, Base64UrlUnpadded, Encoding};
use p256::ecdsa::{Signature, SigningKey, VerifyingKey};
use p256::ecdsa::signature::{Signer, Verifier};
use p256::pkcs8::{DecodePrivateKey, DecodePublicKey};
use sha2::Digest;
use crate::error::Error;
use crate::types::directory::ApiRecord;
pub const JWS_PROTECTED_HEADER: &str =
"eyJhbGciOiJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGRzaWctbW9yZSNlY2RzYS1zaGEyNTYiLCJ0eXAiOiJKV1QifQ";
pub fn sign(record: &ApiRecord, signing_key: &SigningKey) -> Result<String, Error> {
let signing_input = build_signing_input(record)?;
let signature: Signature = signing_key.sign(signing_input.as_bytes());
Ok(Base64UrlUnpadded::encode_string(&signature.to_bytes()))
}
pub fn verify(
record: &ApiRecord,
signature_b64: &str,
verifying_key: &VerifyingKey,
) -> Result<(), Error> {
let signing_input = build_signing_input(record)?;
let sig_bytes = Base64UrlUnpadded::decode_vec(signature_b64)
.map_err(|e| Error::Signature(format!("base64url decode: {e}")))?;
let signature = Signature::from_slice(&sig_bytes)
.map_err(|e| Error::Signature(format!("malformed signature: {e}")))?;
verifying_key
.verify(signing_input.as_bytes(), &signature)
.map_err(|_| Error::Signature("signature verification failed".into()))
}
pub fn signing_key_from_pem(pem: &str) -> Result<SigningKey, Error> {
SigningKey::from_pkcs8_pem(pem)
.map_err(|e| Error::Signature(format!("private key: {e}")))
}
pub fn verifying_key_from_pem(pem: &str) -> Result<VerifyingKey, Error> {
VerifyingKey::from_public_key_pem(pem)
.map_err(|e| Error::Signature(format!("public key: {e}")))
}
fn build_signing_input(record: &ApiRecord) -> Result<String, Error> {
let canonical = canonical_json(record)?;
let payload_b64 = Base64UrlUnpadded::encode_string(canonical.as_bytes());
Ok(format!("{JWS_PROTECTED_HEADER}.{payload_b64}"))
}
fn canonical_json(record: &ApiRecord) -> Result<String, Error> {
serde_jcs::to_string(record).map_err(|e| Error::Signature(format!("JCS serialization: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use time::OffsetDateTime;
use url::Url;
fn sample_record() -> ApiRecord {
ApiRecord {
provider_id: "1234567890123".into(),
api_id: "example".into(),
major_version: 1,
url: Url::parse("https://www.example.org/api/resource/v1").unwrap(),
additional_metadata: None,
last_updated: OffsetDateTime::from_unix_timestamp(1_727_740_800).unwrap(),
revision: 1,
status: crate::types::directory::ApiStatus::Test,
}
}
#[test]
fn sign_and_verify_roundtrip() {
use p256::SecretKey;
let secret = SecretKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
let signing_key = SigningKey::from(&secret);
let verifying_key = VerifyingKey::from(&signing_key);
let record = sample_record();
let sig = sign(&record, &signing_key).expect("sign");
verify(&record, &sig, &verifying_key).expect("verify");
}
#[test]
fn tampered_record_fails_verification() {
use p256::SecretKey;
let secret = SecretKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
let signing_key = SigningKey::from(&secret);
let verifying_key = VerifyingKey::from(&signing_key);
let record = sample_record();
let sig = sign(&record, &signing_key).expect("sign");
let mut tampered = record;
tampered.revision = 999;
assert!(verify(&tampered, &sig, &verifying_key).is_err());
}
}