use crate::coordinate::Coordinate;
use crate::event::EventKind;
use crate::store::append::{signing_downgrade_extension_key, SigningDowngradeBody};
use crate::store::{
AppendReceipt, DenialReceipt, ExtensionKey, ReceiptVerification, ReceiptVerificationError,
StoreError,
};
use ed25519_compact::{KeyPair, PublicKey, Seed, Signature};
use std::collections::BTreeMap;
use std::sync::Arc;
use zeroize::Zeroizing;
const COVER_VERSION_V1: u8 = 0x01;
#[derive(Clone)]
pub struct SigningKey {
seed: Zeroizing<[u8; 32]>,
}
impl SigningKey {
#[must_use]
pub fn from_bytes(bytes: [u8; 32]) -> Self {
Self {
seed: Zeroizing::new(bytes),
}
}
pub(crate) fn key_id(&self) -> [u8; 32] {
match self.public_key_bytes() {
Some(bytes) => key_id_for_public_key(&bytes),
None => [0; 32],
}
}
fn key_pair(&self) -> KeyPair {
KeyPair::from_seed(Seed::new(*self.seed))
}
fn public_key_bytes(&self) -> Option<[u8; 32]> {
<[u8; 32]>::try_from(self.key_pair().pk.as_ref()).ok()
}
fn sign_cover(&self, cover: [u8; 32]) -> [u8; 64] {
let signature = self.key_pair().sk.sign(cover, None);
let mut bytes = [0u8; 64];
bytes.copy_from_slice(signature.as_ref());
bytes
}
}
impl std::fmt::Debug for SigningKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SigningKey")
.field("key_id", &self.key_id())
.finish()
}
}
#[derive(Clone, Default)]
pub(crate) struct ReceiptSigningRegistry {
current: Option<Arc<SigningKey>>,
verifying_keys: Arc<BTreeMap<[u8; 32], [u8; 32]>>,
allow_downgrade: bool,
}
impl ReceiptSigningRegistry {
pub(crate) fn from_keys(keys: &[SigningKey], allow_downgrade: bool) -> Self {
let mut verifying_keys = BTreeMap::new();
let mut current = None;
for key in keys {
let key = Arc::new(key.clone());
if let Some(public_key_bytes) = key.public_key_bytes() {
verifying_keys.insert(key.key_id(), public_key_bytes);
current = Some(key);
}
}
Self {
current,
verifying_keys: Arc::new(verifying_keys),
allow_downgrade,
}
}
pub(crate) fn sign_append_receipt(
&self,
receipt: &mut AppendReceipt,
coord: &Coordinate,
kind: EventKind,
prev_hash: [u8; 32],
) -> Result<(), StoreError> {
let Some(current) = &self.current else {
receipt.key_id = [0; 32];
receipt.signature = None;
return Ok(());
};
let cover = match cover_bytes(
{
use crate::id::EntityIdType;
receipt.event_id.as_u128()
},
receipt.global_sequence,
coord,
kind,
prev_hash,
receipt.content_hash,
&receipt.extensions,
) {
Ok(cover) => cover,
Err(error) => {
if cover_failure_fails_closed(self.allow_downgrade) {
return Err(StoreError::ser_msg(&format!(
"receipt signature cover could not be built: {error}"
)));
}
tracing::error!(error = %error, "receipt signing downgraded to unsigned (signing_downgrade_allowed)");
downgrade_receipt_signing(receipt, error.to_string());
return Ok(());
}
};
receipt.key_id = current.key_id();
receipt.signature = Some(current.sign_cover(cover));
Ok(())
}
pub(crate) fn verify_append_receipt(
&self,
receipt: &AppendReceipt,
coord: &Coordinate,
kind: EventKind,
prev_hash: [u8; 32],
) -> ReceiptVerification {
if receipt.signature.is_none() && receipt.key_id == [0; 32] {
return if self.verifying_keys.is_empty() {
ReceiptVerification::UnsignedAccepted
} else {
ReceiptVerification::Invalid(ReceiptVerificationError::UnsignedReceiptRejected)
};
}
let cover = match cover_bytes(
{
use crate::id::EntityIdType;
receipt.event_id.as_u128()
},
receipt.global_sequence,
coord,
kind,
prev_hash,
receipt.content_hash,
&receipt.extensions,
) {
Ok(cover) => cover,
Err(error) => {
tracing::error!(error = %error, "failed to rebuild append receipt signature cover");
return ReceiptVerification::Invalid(ReceiptVerificationError::CoverBuildFailed {
reason: error.to_string(),
});
}
};
self.verify_signature(receipt.key_id, receipt.signature, cover)
}
pub(crate) fn verify_denial_receipt(
&self,
receipt: &DenialReceipt,
coord: &Coordinate,
kind: EventKind,
prev_hash: [u8; 32],
) -> ReceiptVerification {
if receipt.signature.is_none() && receipt.key_id == [0; 32] {
return if self.verifying_keys.is_empty() {
ReceiptVerification::UnsignedAccepted
} else {
ReceiptVerification::Invalid(ReceiptVerificationError::UnsignedReceiptRejected)
};
}
let cover = match cover_bytes(
{
use crate::id::EntityIdType;
receipt.event_id.as_u128()
},
receipt.global_sequence,
coord,
kind,
prev_hash,
receipt.content_hash,
&receipt.extensions,
) {
Ok(cover) => cover,
Err(error) => {
tracing::error!(error = %error, "failed to rebuild denial receipt signature cover");
return ReceiptVerification::Invalid(ReceiptVerificationError::CoverBuildFailed {
reason: error.to_string(),
});
}
};
self.verify_signature(receipt.key_id, receipt.signature, cover)
}
fn verify_signature(
&self,
key_id: [u8; 32],
signature: Option<[u8; 64]>,
cover: [u8; 32],
) -> ReceiptVerification {
let Some(signature_bytes) = signature else {
return if key_id == [0; 32] && self.verifying_keys.is_empty() {
ReceiptVerification::UnsignedAccepted
} else if key_id == [0; 32] {
ReceiptVerification::Invalid(ReceiptVerificationError::UnsignedReceiptRejected)
} else {
ReceiptVerification::Invalid(ReceiptVerificationError::MissingSignature)
};
};
if key_id == [0; 32] {
return ReceiptVerification::Invalid(ReceiptVerificationError::ZeroKeyWithSignature);
};
let Some(public_key_bytes) = self.verifying_keys.get(&key_id) else {
return ReceiptVerification::Invalid(ReceiptVerificationError::UnknownSigningKey);
};
let signature = Signature::new(signature_bytes);
if PublicKey::new(*public_key_bytes)
.verify(cover, &signature)
.is_ok()
{
ReceiptVerification::Signed
} else {
ReceiptVerification::Invalid(ReceiptVerificationError::InvalidSignature)
}
}
}
const fn cover_failure_fails_closed(allow_downgrade: bool) -> bool {
!allow_downgrade
}
fn downgrade_receipt_signing(receipt: &mut AppendReceipt, error: impl Into<String>) {
let body = SigningDowngradeBody::cover_build_failed(error);
match body.encode_extension() {
Ok(bytes) => {
receipt
.extensions
.insert(signing_downgrade_extension_key(), bytes);
}
Err(error) => {
tracing::error!(
error = %error,
"failed to encode signing downgrade receipt extension"
);
}
}
receipt.key_id = [0; 32];
receipt.signature = None;
}
#[derive(Debug)]
enum CoverBuildError {
CoordinateEncoding(rmp_serde::encode::Error),
ExtensionsEncoding(rmp_serde::encode::Error),
}
impl std::fmt::Display for CoverBuildError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::CoordinateEncoding(error) => {
write!(
f,
"coordinate encoding failed while building receipt cover: {error}"
)
}
Self::ExtensionsEncoding(error) => {
write!(
f,
"extension encoding failed while building receipt cover: {error}"
)
}
}
}
}
impl std::error::Error for CoverBuildError {}
fn key_id_for_public_key(public_key: &[u8; 32]) -> [u8; 32] {
crate::event::hash::compute_hash(public_key)
}
fn cover_bytes(
event_id: u128,
sequence: u64,
coord: &Coordinate,
kind: EventKind,
prev_hash: [u8; 32],
content_hash: [u8; 32],
extensions: &BTreeMap<ExtensionKey, Vec<u8>>,
) -> Result<[u8; 32], CoverBuildError> {
let mut cover = Vec::new();
cover.push(COVER_VERSION_V1);
cover.extend_from_slice(&event_id.to_le_bytes());
cover.extend_from_slice(&sequence.to_le_bytes());
let coord_bytes =
crate::canonical::to_bytes(coord).map_err(CoverBuildError::CoordinateEncoding)?;
cover.extend_from_slice(&coord_bytes);
let raw_kind = kind.as_raw_u16();
cover.extend_from_slice(&raw_kind.to_le_bytes());
cover.extend_from_slice(&prev_hash);
cover.extend_from_slice(&content_hash);
let extension_bytes =
crate::canonical::to_bytes(extensions).map_err(CoverBuildError::ExtensionsEncoding)?;
cover.extend_from_slice(&extension_bytes);
Ok(crate::event::hash::compute_hash(&cover))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cover_failure_is_fatal_unless_downgrade_allowed() {
assert!(cover_failure_fails_closed(false));
assert!(!cover_failure_fails_closed(true));
}
#[test]
fn cover_bytes_separates_event_kind_category_and_type_bits() {
let coord = Coordinate::new("receipt:cover", "scope:test").expect("coordinate");
let extensions = BTreeMap::new();
let cover_a = cover_bytes(
1,
1,
&coord,
EventKind::custom(0xF, 0x055),
[0x11; 32],
[0x22; 32],
&extensions,
)
.expect("cover A");
let cover_b = cover_bytes(
1,
1,
&coord,
EventKind::custom(0xE, 0x055),
[0x11; 32],
[0x22; 32],
&extensions,
)
.expect("cover B");
let cover_c = cover_bytes(
1,
1,
&coord,
EventKind::custom(0xF, 0x056),
[0x11; 32],
[0x22; 32],
&extensions,
)
.expect("cover C");
assert_ne!(
cover_a, cover_b,
"PROPERTY: receipt signature cover must include the EventKind category bits"
);
assert_ne!(
cover_a, cover_c,
"PROPERTY: receipt signature cover must include the EventKind type-id bits"
);
}
#[test]
fn cover_build_failure_adds_signing_downgrade_extension() {
let mut receipt = AppendReceipt {
event_id: crate::id::EventId::from(7u128),
global_sequence: 9,
disk_pos: crate::store::index::DiskPos {
segment_id: 1,
offset: 2,
length: 3,
},
content_hash: [0x22; 32],
key_id: [0xAA; 32],
signature: Some([0xBB; 64]),
extensions: BTreeMap::new(),
};
downgrade_receipt_signing(&mut receipt, "synthetic cover failure");
assert_eq!(receipt.key_id, [0; 32]);
assert!(receipt.signature.is_none());
let downgrade = receipt
.signing_downgrade()
.expect("downgrade extension should decode");
assert!(matches!(
downgrade.reason,
crate::store::SigningDowngradeReason::CoverBuildFailed { ref encoding_error }
if encoding_error == "synthetic cover failure"
));
}
}
#[cfg(test)]
mod verify_cure_tests {
use super::*;
use crate::store::index::DiskPos;
fn receipt(key_id: [u8; 32], signature: Option<[u8; 64]>) -> AppendReceipt {
AppendReceipt {
event_id: crate::id::EventId::from(3u128),
global_sequence: 1,
disk_pos: DiskPos::new(1, 0, 1),
content_hash: [0x11; 32],
key_id,
signature,
extensions: BTreeMap::new(),
}
}
fn denial(key_id: [u8; 32], signature: Option<[u8; 64]>) -> DenialReceipt {
DenialReceipt {
event_id: crate::id::EventId::from(4u128),
global_sequence: 2,
disk_pos: DiskPos::new(1, 0, 1),
content_hash: [0x22; 32],
key_id,
signature,
extensions: BTreeMap::new(),
}
}
#[test]
fn verify_append_receipt_unsigned_with_nonsentinel_key_is_missing_signature() {
let registry = ReceiptSigningRegistry::from_keys(&[], false);
let coord = Coordinate::new("entity:sig", "scope:sig").expect("coord");
assert_eq!(
registry.verify_append_receipt(
&receipt([0xAA; 32], None),
&coord,
EventKind::custom(0xF, 1),
[0; 32],
),
ReceiptVerification::Invalid(ReceiptVerificationError::MissingSignature),
);
}
#[test]
fn verify_denial_receipt_unsigned_with_nonsentinel_key_is_missing_signature() {
let registry = ReceiptSigningRegistry::from_keys(&[], false);
let coord = Coordinate::new("entity:sig-d", "scope:sig").expect("coord");
assert_eq!(
registry.verify_denial_receipt(
&denial([0xBB; 32], None),
&coord,
EventKind::custom(0xF, 2),
[0; 32],
),
ReceiptVerification::Invalid(ReceiptVerificationError::MissingSignature),
);
}
#[test]
fn verify_signature_unsigned_dispositions_match_key_and_registry_state() {
let empty = ReceiptSigningRegistry::from_keys(&[], false);
let keyed = ReceiptSigningRegistry::from_keys(&[SigningKey::from_bytes([7u8; 32])], false);
let cover = [0u8; 32];
assert_eq!(
empty.verify_signature([0; 32], None, cover),
ReceiptVerification::UnsignedAccepted,
);
assert_eq!(
empty.verify_signature([0xAA; 32], None, cover),
ReceiptVerification::Invalid(ReceiptVerificationError::MissingSignature),
);
assert_eq!(
keyed.verify_signature([0; 32], None, cover),
ReceiptVerification::Invalid(ReceiptVerificationError::UnsignedReceiptRejected),
);
}
#[test]
fn cover_build_error_display_renders_the_stage_and_is_never_empty() {
use serde::ser::Error as _;
let encode_err = rmp_serde::encode::Error::custom("boom");
let display = format!("{}", CoverBuildError::CoordinateEncoding(encode_err));
assert!(
display.contains("coordinate encoding failed while building receipt cover"),
"Display must render the coordinate-encoding cover-build failure, got {display:?}"
);
}
#[test]
fn signing_key_debug_names_the_struct_and_key_id() {
let debug = format!("{:?}", SigningKey::from_bytes([7u8; 32]));
assert!(
debug.contains("SigningKey") && debug.contains("key_id"),
"Debug must render the SigningKey struct with its key_id field, got {debug:?}"
);
}
}