use subtle::ConstantTimeEq;
use zeroize::Zeroize;
use crate::cbor::{decode_canonical_cbor, encode_canonical_cbor, CanonicalCborError, CborValue};
pub const CARDANO_POE_SIG_DOMAIN_PREFIX: &str = "cardano-poe-record-sig-v1";
const ED25519_PUBLIC_KEY_LENGTH: usize = 32;
const ED25519_SIGNATURE_LENGTH: usize = 64;
const COSE_HEADER_LABEL_ALG: i64 = 1;
const COSE_HEADER_LABEL_KID: i64 = 4;
const COSE_ALG_EDDSA: i64 = -8;
const HASHED_MODE_HEADER_KEY: &str = "hashed";
const _: () = assert!(CARDANO_POE_SIG_DOMAIN_PREFIX.len() == 25);
mod ed25519 {
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
pub(super) fn public_key_from_seed(seed: &[u8; 32]) -> [u8; 32] {
SigningKey::from_bytes(seed).verifying_key().to_bytes()
}
pub(super) fn sign(seed: &[u8; 32], message: &[u8]) -> [u8; 64] {
SigningKey::from_bytes(seed).sign(message).to_bytes()
}
pub(super) fn verify(public_key: &[u8], message: &[u8], signature: &[u8]) -> bool {
let pk_bytes: [u8; 32] = match public_key.try_into() {
Ok(b) => b,
Err(_) => return false,
};
let sig_bytes: [u8; 64] = match signature.try_into() {
Ok(b) => b,
Err(_) => return false,
};
let Ok(verifying_key) = VerifyingKey::from_bytes(&pk_bytes) else {
return false;
};
verifying_key
.verify_strict(message, &Signature::from_bytes(&sig_bytes))
.is_ok()
}
}
#[must_use]
pub fn ed25519_public_key_from_seed(seed: &[u8; 32]) -> [u8; 32] {
let mut local = *seed;
let pk = ed25519::public_key_from_seed(&local);
local.zeroize();
pk
}
#[must_use]
pub fn ed25519_sign(seed: &[u8; 32], message: &[u8]) -> [u8; 64] {
let mut local = *seed;
let sig = ed25519::sign(&local, message);
local.zeroize();
sig
}
#[must_use]
pub fn ed25519_verify(public_key: &[u8], message: &[u8], signature: &[u8]) -> bool {
ed25519::verify(public_key, message, signature)
}
fn blake2b224(input: &[u8]) -> [u8; 28] {
use blake2::digest::consts::U28;
use blake2::digest::Digest;
use blake2::Blake2b;
Blake2b::<U28>::digest(input).into()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CoseLabel {
Int(i128),
Text(String),
}
impl CoseLabel {
fn to_cbor(&self) -> CborValue {
match self {
CoseLabel::Int(v) => cbor_int_from_i128(*v),
CoseLabel::Text(s) => CborValue::text(s.clone()),
}
}
}
fn cbor_int_from_i128(v: i128) -> CborValue {
if v >= 0 {
CborValue::Unsigned(v as u64)
} else {
CborValue::Negative((-1 - v) as u64)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CoseHeader {
pairs: Vec<(CoseLabel, CborValue)>,
}
impl CoseHeader {
#[must_use]
pub fn new() -> Self {
Self { pairs: Vec::new() }
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.pairs.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.pairs.len()
}
#[must_use]
pub fn with_int(mut self, label: i64, value: CborValue) -> Self {
self.set(CoseLabel::Int(i128::from(label)), value);
self
}
#[must_use]
pub fn with_text(mut self, label: impl Into<String>, value: CborValue) -> Self {
self.set(CoseLabel::Text(label.into()), value);
self
}
fn set(&mut self, label: CoseLabel, value: CborValue) {
if let Some(slot) = self.pairs.iter_mut().find(|(l, _)| *l == label) {
slot.1 = value;
} else {
self.pairs.push((label, value));
}
}
fn get_int(&self, label: i64) -> Option<&CborValue> {
let label = i128::from(label);
self.pairs
.iter()
.find(|(l, _)| matches!(l, CoseLabel::Int(n) if *n == label))
.map(|(_, v)| v)
}
fn get_text(&self, label: &str) -> Option<&CborValue> {
self.pairs
.iter()
.find(|(l, _)| matches!(l, CoseLabel::Text(s) if s == label))
.map(|(_, v)| v)
}
#[must_use]
pub fn alg(&self) -> Option<i64> {
cbor_int_value(self.get_int(COSE_HEADER_LABEL_ALG))
}
#[must_use]
pub fn kid(&self) -> Option<[u8; 32]> {
match self.get_int(COSE_HEADER_LABEL_KID) {
Some(CborValue::Bytes(b)) if b.len() == ED25519_PUBLIC_KEY_LENGTH => {
b.as_slice().try_into().ok()
}
_ => None,
}
}
#[must_use]
pub fn to_cbor(&self) -> CborValue {
CborValue::Map(
self.pairs
.iter()
.map(|(label, value)| (label.to_cbor(), value.clone()))
.collect(),
)
}
pub fn encode_protected(&self) -> Result<Vec<u8>, CanonicalCborError> {
if self.is_empty() {
Ok(Vec::new())
} else {
encode_canonical_cbor(&self.to_cbor())
}
}
fn from_cbor_map(value: &CborValue) -> Option<Self> {
let CborValue::Map(pairs) = value else {
return None;
};
let mut header = CoseHeader::new();
for (key, val) in pairs {
let label = match key {
CborValue::Unsigned(n) => CoseLabel::Int(i128::from(*n)),
CborValue::Negative(m) => CoseLabel::Int(-1_i128 - i128::from(*m)),
CborValue::Text(s) => CoseLabel::Text(s.clone()),
_ => return None,
};
header.pairs.push((label, val.clone()));
}
Some(header)
}
}
#[must_use]
pub fn build_sig_structure(
body_protected_bytes: &[u8],
external_aad: &[u8],
payload: &[u8],
) -> Vec<u8> {
let structure = CborValue::Array(vec![
CborValue::text("Signature1"),
CborValue::bytes(body_protected_bytes.to_vec()),
CborValue::bytes(external_aad.to_vec()),
CborValue::bytes(payload.to_vec()),
]);
encode_canonical_cbor(&structure).expect("Sig_structure encodes")
}
fn cip309_to_sign(record_body_cbor: &[u8]) -> Vec<u8> {
let mut to_sign =
Vec::with_capacity(CARDANO_POE_SIG_DOMAIN_PREFIX.len() + record_body_cbor.len());
to_sign.extend_from_slice(CARDANO_POE_SIG_DOMAIN_PREFIX.as_bytes());
to_sign.extend_from_slice(record_body_cbor);
to_sign
}
#[must_use]
pub fn build_cip309_sig_structure(body_protected_bytes: &[u8], record_body_cbor: &[u8]) -> Vec<u8> {
let to_sign = cip309_to_sign(record_body_cbor);
build_sig_structure(body_protected_bytes, &[], &to_sign)
}
pub fn encode_cose_sign1(
protected_header: &CoseHeader,
unprotected_header: &CoseHeader,
payload: Option<&[u8]>,
signature: &[u8],
) -> Result<Vec<u8>, CanonicalCborError> {
let protected_bytes = protected_header.encode_protected()?;
let payload_value = match payload {
Some(bytes) => CborValue::bytes(bytes.to_vec()),
None => CborValue::Null,
};
let array = CborValue::Array(vec![
CborValue::bytes(protected_bytes),
unprotected_header.to_cbor(),
payload_value,
CborValue::bytes(signature.to_vec()),
]);
encode_canonical_cbor(&array)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoseSign1Decoded {
pub protected_header: CoseHeader,
pub protected_bytes: Vec<u8>,
pub unprotected_header: CoseHeader,
pub payload: Option<Vec<u8>>,
pub signature: Vec<u8>,
}
pub fn decode_cose_sign1(bytes: &[u8]) -> Result<CoseSign1Decoded, CoseDecodeError> {
let value =
decode_canonical_cbor(bytes).map_err(|_| CoseDecodeError::new("cose decode failed"))?;
let CborValue::Array(items) = value else {
return Err(CoseDecodeError::new("expected 4-element array"));
};
if items.len() != 4 {
return Err(CoseDecodeError::new("expected 4-element array"));
}
let CborValue::Bytes(protected_bytes) = &items[0] else {
return Err(CoseDecodeError::new("protected_bytes must be bytes"));
};
let unprotected_header = CoseHeader::from_cbor_map(&items[1])
.ok_or_else(|| CoseDecodeError::new("unprotected header must be map"))?;
let payload = match &items[2] {
CborValue::Null => None,
CborValue::Bytes(b) => Some(b.clone()),
_ => return Err(CoseDecodeError::new("payload must be bytes or null")),
};
let CborValue::Bytes(signature) = &items[3] else {
return Err(CoseDecodeError::new("signature must be 64 bytes"));
};
if signature.len() != ED25519_SIGNATURE_LENGTH {
return Err(CoseDecodeError::new("signature must be 64 bytes"));
}
let protected_header = if protected_bytes.is_empty() {
CoseHeader::new()
} else {
let decoded = decode_canonical_cbor(protected_bytes)
.map_err(|_| CoseDecodeError::new("protected header decode failed"))?;
let header = CoseHeader::from_cbor_map(&decoded)
.ok_or_else(|| CoseDecodeError::new("protected header must decode to map"))?;
if header.is_empty() {
return Err(CoseDecodeError::new(
"empty protected header must encode as 0x40 (zero-length bstr), not as an empty map",
));
}
header
};
Ok(CoseSign1Decoded {
protected_header,
protected_bytes: protected_bytes.clone(),
unprotected_header,
payload,
signature: signature.clone(),
})
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("MALFORMED_SIG_COSE: {detail}")]
pub struct CoseDecodeError {
pub detail: String,
}
impl CoseDecodeError {
fn new(detail: impl Into<String>) -> Self {
Self {
detail: detail.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CoseVerifyErrorCode {
MalformedSigCose,
MalformedSigCoseSign1,
UnsupportedSigAlg,
KidUnresolved,
SignatureInvalid,
}
impl CoseVerifyErrorCode {
#[must_use]
pub const fn code(&self) -> &'static str {
match self {
CoseVerifyErrorCode::MalformedSigCose => "MALFORMED_SIG_COSE",
CoseVerifyErrorCode::MalformedSigCoseSign1 => "MALFORMED_SIG_COSE_SIGN1",
CoseVerifyErrorCode::UnsupportedSigAlg => "UNSUPPORTED_SIG_ALG",
CoseVerifyErrorCode::KidUnresolved => "KID_UNRESOLVED",
CoseVerifyErrorCode::SignatureInvalid => "SIGNATURE_INVALID",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CoseVerifyResult {
Ok {
signer_key: [u8; 32],
alg: i64,
},
Err(CoseVerifyErrorCode),
}
impl CoseVerifyResult {
#[must_use]
pub fn is_ok(&self) -> bool {
matches!(self, CoseVerifyResult::Ok { .. })
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum CoseSign1BuildError {
#[error("SIGNER_NOT_PROVIDED: {0}")]
SignerNotProvided(String),
#[error("SIGNER_AND_SEED_BOTH_PROVIDED: {0}")]
SignerAndSeedBothProvided(String),
}
impl CoseSign1BuildError {
#[must_use]
pub const fn code(&self) -> &'static str {
match self {
CoseSign1BuildError::SignerNotProvided(_) => "SIGNER_NOT_PROVIDED",
CoseSign1BuildError::SignerAndSeedBothProvided(_) => "SIGNER_AND_SEED_BOTH_PROVIDED",
}
}
}
pub enum Cip309Signer<'a> {
Seed(&'a [u8; 32]),
Closure(&'a dyn Fn(&[u8]) -> Vec<u8>),
}
pub fn cose_sign1_cip309_build(
protected_header: &CoseHeader,
unprotected_header: &CoseHeader,
record_body_cbor: &[u8],
signer: Cip309Signer<'_>,
) -> Result<Vec<u8>, CoseSign1BuildError> {
let protected_bytes = encode_protected_bytes(protected_header)?;
let sig_structure = build_cip309_sig_structure(&protected_bytes, record_body_cbor);
let signature = match signer {
Cip309Signer::Seed(seed) => ed25519_sign(seed, &sig_structure).to_vec(),
Cip309Signer::Closure(closure) => {
let sig = closure(&sig_structure);
if sig.len() != ED25519_SIGNATURE_LENGTH {
return Err(CoseSign1BuildError::SignerNotProvided(format!(
"injected signer must return a 64-byte value; got {} bytes",
sig.len()
)));
}
sig
}
};
encode_cose_sign1(protected_header, unprotected_header, None, &signature)
.map_err(|e| CoseSign1BuildError::SignerNotProvided(e.to_string()))
}
fn encode_protected_bytes(protected_header: &CoseHeader) -> Result<Vec<u8>, CoseSign1BuildError> {
protected_header
.encode_protected()
.map_err(|e| CoseSign1BuildError::SignerNotProvided(e.to_string()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cip309Prepared {
pub sig_structure: Vec<u8>,
pub protected_header: CoseHeader,
pub unprotected_header: CoseHeader,
}
pub fn cose_sign1_cip309_prepare(
protected_header: &CoseHeader,
unprotected_header: &CoseHeader,
record_body_cbor: &[u8],
) -> Result<Cip309Prepared, CanonicalCborError> {
let protected_bytes = protected_header.encode_protected()?;
Ok(Cip309Prepared {
sig_structure: build_cip309_sig_structure(&protected_bytes, record_body_cbor),
protected_header: protected_header.clone(),
unprotected_header: unprotected_header.clone(),
})
}
pub fn cose_sign1_cip309_assemble(
prepared: &Cip309Prepared,
signature: &[u8],
) -> Result<Vec<u8>, CoseSign1BuildError> {
if signature.len() != ED25519_SIGNATURE_LENGTH {
return Err(CoseSign1BuildError::SignerNotProvided(format!(
"external signature must be 64 bytes; got {} bytes",
signature.len()
)));
}
encode_cose_sign1(
&prepared.protected_header,
&prepared.unprotected_header,
None,
signature,
)
.map_err(|e| CoseSign1BuildError::SignerNotProvided(e.to_string()))
}
#[must_use]
pub fn cose_sign1_cip309_verify(
message: &[u8],
detached_record_body_cbor: &[u8],
expected_signer_key: Option<&[u8]>,
) -> CoseVerifyResult {
let decoded = match decode_cose_sign1(message) {
Ok(d) => d,
Err(_) => return CoseVerifyResult::Err(CoseVerifyErrorCode::MalformedSigCose),
};
if decoded.payload.is_some() {
return CoseVerifyResult::Err(CoseVerifyErrorCode::MalformedSigCoseSign1);
}
if decoded.protected_header.alg() != Some(COSE_ALG_EDDSA) {
return CoseVerifyResult::Err(CoseVerifyErrorCode::UnsupportedSigAlg);
}
let kid: Option<[u8; 32]> = decoded.protected_header.kid();
let expected: Option<[u8; 32]> = match expected_signer_key {
Some(k) if k.len() == ED25519_PUBLIC_KEY_LENGTH => k.try_into().ok(),
_ => None,
};
if let (Some(kid_bytes), Some(expected_bytes)) = (&kid, &expected) {
if kid_bytes.ct_eq(expected_bytes).unwrap_u8() != 1 {
return CoseVerifyResult::Err(CoseVerifyErrorCode::KidUnresolved);
}
}
let signer_key = match kid.or(expected) {
Some(k) => k,
None => return CoseVerifyResult::Err(CoseVerifyErrorCode::KidUnresolved),
};
let hashed = matches!(
decoded.unprotected_header.get_text(HASHED_MODE_HEADER_KEY),
Some(CborValue::Bool(true))
);
let sig_structure = if hashed {
let to_sign = cip309_to_sign(detached_record_body_cbor);
let digest = blake2b224(&to_sign);
build_sig_structure(&decoded.protected_bytes, &[], &digest)
} else {
build_cip309_sig_structure(&decoded.protected_bytes, detached_record_body_cbor)
};
if ed25519_verify(&signer_key, &sig_structure, &decoded.signature) {
CoseVerifyResult::Ok {
signer_key,
alg: COSE_ALG_EDDSA,
}
} else {
CoseVerifyResult::Err(CoseVerifyErrorCode::SignatureInvalid)
}
}
fn cbor_int_value(value: Option<&CborValue>) -> Option<i64> {
match value {
Some(CborValue::Unsigned(n)) => i64::try_from(*n).ok(),
Some(CborValue::Negative(m)) => {
let signed = -1_i128 - i128::from(*m);
i64::try_from(signed).ok()
}
_ => None,
}
}
#[must_use]
pub fn parse_cose_key_ed25519(blob: &[u8]) -> Option<[u8; 32]> {
const LABEL_KTY: i64 = 1;
const LABEL_ALG: i64 = 3;
const LABEL_CRV: i64 = -1;
const LABEL_X: i64 = -2;
const KTY_OKP: i64 = 1;
const ALG_EDDSA: i64 = -8;
const CRV_ED25519: i64 = 6;
let decoded = decode_canonical_cbor(blob).ok()?;
let header = CoseHeader::from_cbor_map(&decoded)?;
if cbor_int_value(header.get_int(LABEL_KTY)) != Some(KTY_OKP) {
return None;
}
if cbor_int_value(header.get_int(LABEL_CRV)) != Some(CRV_ED25519) {
return None;
}
if let Some(alg) = header.get_int(LABEL_ALG) {
if cbor_int_value(Some(alg)) != Some(ALG_EDDSA) {
return None;
}
}
match header.get_int(LABEL_X) {
Some(CborValue::Bytes(x)) if x.len() == ED25519_PUBLIC_KEY_LENGTH => {
Some(x.as_slice().try_into().ok()?)
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn domain_prefix_is_pinned_25_bytes() {
assert_eq!(CARDANO_POE_SIG_DOMAIN_PREFIX, "cardano-poe-record-sig-v1");
assert_eq!(CARDANO_POE_SIG_DOMAIN_PREFIX.len(), 25);
assert_eq!(
crate::hex::encode(CARDANO_POE_SIG_DOMAIN_PREFIX.as_bytes()),
"63617264616e6f2d706f652d7265636f72642d7369672d7631"
);
}
#[test]
fn empty_protected_header_encodes_as_0x40() {
let cose = encode_cose_sign1(
&CoseHeader::new(),
&CoseHeader::new(),
None,
&[0u8; ED25519_SIGNATURE_LENGTH],
)
.unwrap();
assert_eq!(cose[0], 0x84);
assert_eq!(cose[1], 0x40);
}
#[test]
fn blake2b224_is_parameterized_28_bytes() {
let digest = blake2b224(b"");
assert_eq!(digest.len(), 28);
assert_eq!(
crate::hex::encode(&digest),
"836cc68931c2e4e3e838602eca1902591d216837bafddfe6f0c8cb07"
);
}
#[test]
fn ed25519_strict_rejects_low_order_pubkey() {
let pk =
crate::hex::decode("0000000000000000000000000000000000000000000000000000000000000000")
.unwrap();
let msg = crate::hex::decode("65643235353139766563746f72732033").unwrap();
let sig = crate::hex::decode(
"36684ea91032ba5b1dbab2d02f4debc74c3327f2b3802e2e4d371aa42b12b56b05ba9a796274d80437afa36f1236563f2f3b0aa84cecddc3d20914615ba4fe02",
)
.unwrap();
assert!(!ed25519_verify(&pk, &msg, &sig));
}
#[test]
fn prepare_sig_structure_equals_build_signed_bytes() {
let pk = ed25519_public_key_from_seed(&[0x11; 32]);
let protected = CoseHeader::new()
.with_int(COSE_HEADER_LABEL_ALG, CborValue::int(COSE_ALG_EDDSA))
.with_int(COSE_HEADER_LABEL_KID, CborValue::bytes(pk.to_vec()));
let body = crate::hex::decode("a16161182a").unwrap();
let prepared = cose_sign1_cip309_prepare(&protected, &CoseHeader::new(), &body).unwrap();
let protected_bytes = encode_canonical_cbor(&protected.to_cbor()).unwrap();
let direct = build_cip309_sig_structure(&protected_bytes, &body);
assert_eq!(prepared.sig_structure, direct);
}
}