use std::fmt;
use hmac::{Hmac, KeyInit, Mac};
use sha2::{Digest, Sha256};
use crate::{
AdapterConformanceManifestHash, ApplicationExportManifestHash, ApplicationExportPageHash,
ApplicationExportReceiptHash, ApplicationInstallationPlanHash,
ApplicationInstallationReceiptHash, ApplicationLockHash, ApplicationManifestHash,
ApplicationPortabilityManifestHash, ApplicationReimportReceiptHash,
ApplicationRoleDefinitionHash, ApplicationRoleHash, ApplicationSourceHash, CanonicalInputHash,
CanonicalValueHash, CapabilityTokenDigest, ColumnarDefinitionSemanticsHashV1,
ColumnarProjectionSpecHashV1, ConflictKeyHash, ContractBundleHash, ContractMigrationInputHash,
ContractMigrationJournalHash, ContractMigrationValidationDigest, ContractPlanRootHash,
DigestKey, DigestKeyId, EntityKeyHash, EntityRecordHash, EntityTransitionHash,
EventConsumerIdentityHash, EventHash, GeneratedArtifactHash, MigrationBundleHash,
MigrationSourceHash, OfflineMaintenanceInputHash, PartitionKeyHash,
PartitionSetObservationHash, PlanHash, ProjectionApplyHash, ProjectionPlanHash,
ProjectionProviderDescriptorHash, QueryModuleHash, QueryParameterHash, QueryPlanHash,
QuerySourceHash, ReactiveModuleHash, ReactiveOperationHash, ReactiveSourceHash,
ScheduledAttemptHash, SchemaHash, SourceHash,
};
pub const DIGEST_SCHEME_V1: u8 = 0x01;
const HASH_PREFIX: &[u8] = b"RIFFDB-HASH\0";
const HMAC_PREFIX: &[u8] = b"RIFFDB-HMAC\0";
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum HashDomain {
CanonicalValue,
Source,
MigrationSource,
ContractBundle,
MigrationBundle,
Plan,
ProjectionPlan,
ProjectionProviderDescriptor,
QueryPlan,
PartitionSetObservation,
QueryModule,
ReactiveSource,
ReactiveOperation,
ReactiveModule,
EventConsumerIdentity,
ApplicationManifest,
ApplicationSource,
ApplicationLock,
GeneratedArtifact,
ApplicationRoleDefinition,
ApplicationRole,
QuerySource,
QueryParameters,
ContractPlanRoot,
CommandInput,
Event,
EntityRecord,
EntityTransition,
EntityKey,
ConflictKey,
PartitionKey,
Schema,
ProjectionApply,
OfflineMaintenanceInput,
ContractMigrationInput,
ContractMigrationJournal,
ContractMigrationValidation,
CommandBatch,
ContextualReaction,
ReactiveWakeup,
ScheduledAttempt,
ApplicationInstallationPlan,
ApplicationInstallationReceipt,
AdapterConformanceManifest,
ApplicationExportPage,
ApplicationExportManifest,
ApplicationExportReceipt,
ApplicationExportCursor,
ApplicationExportLedger,
ApplicationPortabilityManifest,
ApplicationReimportReceipt,
ExactResultCheckpoint,
ColumnarProjectionSpec,
ProjectionSourceRows,
ProjectionSourceDescriptor,
ProjectionSourceOrigin,
ProjectionSourceCopyProgress,
}
impl HashDomain {
pub const ALL: [Self; 57] = [
Self::CanonicalValue,
Self::Source,
Self::MigrationSource,
Self::ContractBundle,
Self::MigrationBundle,
Self::Plan,
Self::ProjectionPlan,
Self::ProjectionProviderDescriptor,
Self::QueryPlan,
Self::PartitionSetObservation,
Self::QueryModule,
Self::ReactiveSource,
Self::ReactiveOperation,
Self::ReactiveModule,
Self::EventConsumerIdentity,
Self::ApplicationManifest,
Self::ApplicationSource,
Self::ApplicationLock,
Self::GeneratedArtifact,
Self::ApplicationRoleDefinition,
Self::ApplicationRole,
Self::QuerySource,
Self::QueryParameters,
Self::ContractPlanRoot,
Self::CommandInput,
Self::Event,
Self::EntityRecord,
Self::EntityTransition,
Self::EntityKey,
Self::ConflictKey,
Self::PartitionKey,
Self::Schema,
Self::ProjectionApply,
Self::OfflineMaintenanceInput,
Self::ContractMigrationInput,
Self::ContractMigrationJournal,
Self::ContractMigrationValidation,
Self::CommandBatch,
Self::ContextualReaction,
Self::ReactiveWakeup,
Self::ScheduledAttempt,
Self::ApplicationInstallationPlan,
Self::ApplicationInstallationReceipt,
Self::AdapterConformanceManifest,
Self::ApplicationExportPage,
Self::ApplicationExportManifest,
Self::ApplicationExportReceipt,
Self::ApplicationExportCursor,
Self::ApplicationExportLedger,
Self::ApplicationPortabilityManifest,
Self::ApplicationReimportReceipt,
Self::ExactResultCheckpoint,
Self::ColumnarProjectionSpec,
Self::ProjectionSourceRows,
Self::ProjectionSourceDescriptor,
Self::ProjectionSourceOrigin,
Self::ProjectionSourceCopyProgress,
];
pub const fn label(self) -> &'static str {
match self {
Self::CanonicalValue => "riffdb.canonical-value/v1",
Self::Source => "riffdb.source/v1",
Self::MigrationSource => "riffdb.migration-source/v1",
Self::ContractBundle => "riffdb.contract-bundle/v1",
Self::MigrationBundle => "riffdb.migration-bundle/v1",
Self::Plan => "riffdb.plan/v1",
Self::ProjectionPlan => "riffdb.projection-plan/v1",
Self::ProjectionProviderDescriptor => "riffdb.projection-provider-descriptor/v1",
Self::QueryPlan => "riffdb.query-plan/v1",
Self::PartitionSetObservation => "riffdb.partition-set-observation/v1",
Self::QueryModule => "riffdb.query-module/v1",
Self::ReactiveSource => "riffdb.reactive-source/v1",
Self::ReactiveOperation => "riffdb.reactive-operation/v1",
Self::ReactiveModule => "riffdb.reactive-module/v1",
Self::EventConsumerIdentity => "riffdb.event-consumer-identity/v1",
Self::ApplicationManifest => "riffdb.application-manifest/v1",
Self::ApplicationSource => "riffdb.application-source/v1",
Self::ApplicationLock => "riffdb.application-lock/v1",
Self::GeneratedArtifact => "riffdb.generated-artifact/v1",
Self::ApplicationRoleDefinition => "riffdb.application-role-definition/v1",
Self::ApplicationRole => "riffdb.application-role/v1",
Self::QuerySource => "riffdb.query-source/v1",
Self::QueryParameters => "riffdb.query-parameters/v1",
Self::ContractPlanRoot => "riffdb.contract-plan-root/v1",
Self::CommandInput => "riffdb.command-input/v1",
Self::Event => "riffdb.event/v1",
Self::EntityRecord => "riffdb.entity-record/v1",
Self::EntityTransition => "riffdb.entity-transition/v1",
Self::EntityKey => "riffdb.entity-key/v1",
Self::ConflictKey => "riffdb.conflict-key/v1",
Self::PartitionKey => "riffdb.partition-key/v1",
Self::Schema => "riffdb.schema/v1",
Self::ProjectionApply => "riffdb.projection-apply/v1",
Self::OfflineMaintenanceInput => "riffdb.offline-maintenance-input/v1",
Self::ContractMigrationInput => "riffdb.contract-migration-input/v1",
Self::ContractMigrationJournal => "riffdb.contract-migration-journal/v1",
Self::ContractMigrationValidation => "riffdb.contract-migration-validation/v1",
Self::CommandBatch => "riffdb.command-batch/v1",
Self::ContextualReaction => "riffdb.contextual-reaction/v1",
Self::ReactiveWakeup => "riffdb.reactive-wakeup/v1",
Self::ScheduledAttempt => "riffdb.scheduled-attempt/v1",
Self::ApplicationInstallationPlan => "riffdb.application-installation-plan/v1",
Self::ApplicationInstallationReceipt => "riffdb.application-installation-receipt/v1",
Self::AdapterConformanceManifest => "riffdb.adapter-conformance-manifest/v1",
Self::ApplicationExportPage => "riffdb.application-export-page/v1",
Self::ApplicationExportManifest => "riffdb.application-export-manifest/v1",
Self::ApplicationExportReceipt => "riffdb.application-export-receipt/v1",
Self::ApplicationExportCursor => "riffdb.application-export-cursor/v1",
Self::ApplicationExportLedger => "riffdb.application-export-ledger/v1",
Self::ApplicationPortabilityManifest => "riffdb.application-portability-manifest/v1",
Self::ApplicationReimportReceipt => "riffdb.application-reimport-receipt/v1",
Self::ExactResultCheckpoint => "riffdb.exact-result-checkpoint/v1",
Self::ColumnarProjectionSpec => "riffdb.columnar-projection-spec/v1",
Self::ProjectionSourceRows => "riffdb.projection-source-rows/v1",
Self::ProjectionSourceDescriptor => "riffdb.projection-source-descriptor/v1",
Self::ProjectionSourceOrigin => "riffdb.projection-source-origin/v1",
Self::ProjectionSourceCopyProgress => "riffdb.projection-source-copy-progress/v1",
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum KeyedHashDomain {
IdempotencyKey,
CapabilityToken,
ContextualCausation,
}
impl KeyedHashDomain {
pub const ALL: [Self; 3] = [
Self::IdempotencyKey,
Self::CapabilityToken,
Self::ContextualCausation,
];
pub const fn label(self) -> &'static str {
match self {
Self::IdempotencyKey => "riffdb.idempotency-key/v1",
Self::CapabilityToken => "riffdb.capability-token/v1",
Self::ContextualCausation => "riffdb.contextual-causation/v1",
}
}
}
#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct ContentDigest {
scheme: u8,
domain: HashDomain,
bytes: [u8; 32],
}
impl ContentDigest {
const fn new(domain: HashDomain, bytes: [u8; 32]) -> Self {
Self {
scheme: DIGEST_SCHEME_V1,
domain,
bytes,
}
}
#[must_use]
pub const fn from_stored_parts(
scheme: u8,
domain: HashDomain,
bytes: [u8; 32],
) -> Option<Self> {
if scheme != DIGEST_SCHEME_V1 {
return None;
}
Some(Self::new(domain, bytes))
}
pub const fn scheme(self) -> u8 {
self.scheme
}
pub const fn domain(self) -> HashDomain {
self.domain
}
pub const fn as_bytes(&self) -> &[u8; 32] {
&self.bytes
}
}
impl fmt::Debug for ContentDigest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ContentDigest")
.field("scheme", &self.scheme)
.field("domain", &self.domain)
.field("bytes", &HexDigest(&self.bytes))
.finish()
}
}
#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct KeyedDigest {
scheme: u8,
key_id: DigestKeyId,
bytes: [u8; 32],
}
impl KeyedDigest {
pub const fn new(key_id: DigestKeyId, bytes: [u8; 32]) -> Self {
Self {
scheme: DIGEST_SCHEME_V1,
key_id,
bytes,
}
}
pub const fn scheme(self) -> u8 {
self.scheme
}
pub const fn key_id(self) -> DigestKeyId {
self.key_id
}
pub const fn as_bytes(&self) -> &[u8; 32] {
&self.bytes
}
}
impl fmt::Debug for KeyedDigest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("KeyedDigest")
.field("scheme", &self.scheme)
.field("key_id", &self.key_id)
.field("bytes", &"[REDACTED]")
.finish()
}
}
pub fn hash(domain: HashDomain, payload: &[u8]) -> ContentDigest {
let mut hasher = Sha256::new();
write_frame(&mut hasher, HASH_PREFIX, domain.label(), payload);
ContentDigest::new(domain, hasher.finalize().into())
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct HashLengthMismatch;
impl fmt::Display for HashLengthMismatch {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("content hash payload length mismatch")
}
}
impl std::error::Error for HashLengthMismatch {}
pub struct ContentHasher {
hasher: Sha256,
domain: HashDomain,
remaining: Option<u64>,
}
impl fmt::Debug for ContentHasher {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("ContentHasher([REDACTED])")
}
}
impl ContentHasher {
#[must_use]
pub fn new(domain: HashDomain, payload_length: u64) -> Self {
let mut hasher = Sha256::new();
write_frame_header(&mut hasher, HASH_PREFIX, domain.label(), payload_length);
Self {
hasher,
domain,
remaining: Some(payload_length),
}
}
pub fn update(&mut self, payload: &[u8]) -> Result<(), HashLengthMismatch> {
self.remaining = self.remaining.and_then(|remaining| {
u64::try_from(payload.len())
.ok()
.and_then(|length| remaining.checked_sub(length))
});
if self.remaining.is_none() {
return Err(HashLengthMismatch);
}
Digest::update(&mut self.hasher, payload);
Ok(())
}
pub fn finish(self) -> Result<ContentDigest, HashLengthMismatch> {
if self.remaining != Some(0) {
return Err(HashLengthMismatch);
}
Ok(ContentDigest::new(
self.domain,
self.hasher.finalize().into(),
))
}
}
#[must_use]
pub fn hash_command_batch_document(payload: &[u8]) -> ContentDigest {
hash(HashDomain::CommandBatch, payload)
}
macro_rules! typed_hash_function {
($(#[$meta:meta])* $name:ident, $domain:ident, $output:ident) => {
$(#[$meta])*
#[must_use]
pub fn $name(payload: &[u8]) -> $output {
$output::from_bytes(*hash(HashDomain::$domain, payload).as_bytes())
}
};
}
typed_hash_function!(
hash_canonical_value,
CanonicalValue,
CanonicalValueHash
);
typed_hash_function!(
hash_columnar_definition_semantics,
ColumnarProjectionSpec,
ColumnarDefinitionSemanticsHashV1
);
typed_hash_function!(
hash_columnar_projection_spec,
ColumnarProjectionSpec,
ColumnarProjectionSpecHashV1
);
typed_hash_function!(
hash_source,
Source,
SourceHash
);
typed_hash_function!(
hash_migration_source,
MigrationSource,
MigrationSourceHash
);
typed_hash_function!(
hash_contract_bundle,
ContractBundle,
ContractBundleHash
);
typed_hash_function!(
hash_migration_bundle,
MigrationBundle,
MigrationBundleHash
);
typed_hash_function!(
hash_plan,
Plan,
PlanHash
);
typed_hash_function!(
hash_projection_plan,
ProjectionPlan,
ProjectionPlanHash
);
typed_hash_function!(
hash_projection_provider_descriptor,
ProjectionProviderDescriptor,
ProjectionProviderDescriptorHash
);
typed_hash_function!(
hash_query_plan,
QueryPlan,
QueryPlanHash
);
typed_hash_function!(
hash_partition_set_observation,
PartitionSetObservation,
PartitionSetObservationHash
);
typed_hash_function!(
hash_query_module,
QueryModule,
QueryModuleHash
);
typed_hash_function!(
hash_reactive_source,
ReactiveSource,
ReactiveSourceHash
);
typed_hash_function!(
hash_reactive_operation,
ReactiveOperation,
ReactiveOperationHash
);
typed_hash_function!(
hash_reactive_module,
ReactiveModule,
ReactiveModuleHash
);
typed_hash_function!(
hash_event_consumer_identity,
EventConsumerIdentity,
EventConsumerIdentityHash
);
typed_hash_function!(
hash_scheduled_attempt,
ScheduledAttempt,
ScheduledAttemptHash
);
typed_hash_function!(
hash_application_installation_plan,
ApplicationInstallationPlan,
ApplicationInstallationPlanHash
);
typed_hash_function!(
hash_application_installation_receipt,
ApplicationInstallationReceipt,
ApplicationInstallationReceiptHash
);
typed_hash_function!(
hash_adapter_conformance_manifest,
AdapterConformanceManifest,
AdapterConformanceManifestHash
);
typed_hash_function!(
hash_application_export_page,
ApplicationExportPage,
ApplicationExportPageHash
);
typed_hash_function!(
hash_application_export_manifest,
ApplicationExportManifest,
ApplicationExportManifestHash
);
typed_hash_function!(
hash_application_export_receipt,
ApplicationExportReceipt,
ApplicationExportReceiptHash
);
typed_hash_function!(
hash_application_portability_manifest,
ApplicationPortabilityManifest,
ApplicationPortabilityManifestHash
);
typed_hash_function!(
hash_application_reimport_receipt,
ApplicationReimportReceipt,
ApplicationReimportReceiptHash
);
typed_hash_function!(
hash_application_manifest,
ApplicationManifest,
ApplicationManifestHash
);
typed_hash_function!(
hash_application_source,
ApplicationSource,
ApplicationSourceHash
);
typed_hash_function!(
hash_application_lock,
ApplicationLock,
ApplicationLockHash
);
typed_hash_function!(
hash_generated_artifact,
GeneratedArtifact,
GeneratedArtifactHash
);
typed_hash_function!(
hash_application_role_definition,
ApplicationRoleDefinition,
ApplicationRoleDefinitionHash
);
typed_hash_function!(
hash_application_role,
ApplicationRole,
ApplicationRoleHash
);
typed_hash_function!(
hash_query_source,
QuerySource,
QuerySourceHash
);
typed_hash_function!(
hash_query_parameters,
QueryParameters,
QueryParameterHash
);
typed_hash_function!(
hash_contract_plan_root,
ContractPlanRoot,
ContractPlanRootHash
);
typed_hash_function!(
hash_command_input,
CommandInput,
CanonicalInputHash
);
typed_hash_function!(
hash_event,
Event,
EventHash
);
typed_hash_function!(
hash_entity_record,
EntityRecord,
EntityRecordHash
);
typed_hash_function!(
hash_entity_transition,
EntityTransition,
EntityTransitionHash
);
typed_hash_function!(
hash_entity_key,
EntityKey,
EntityKeyHash
);
typed_hash_function!(
hash_conflict_key,
ConflictKey,
ConflictKeyHash
);
typed_hash_function!(
hash_partition_key,
PartitionKey,
PartitionKeyHash
);
typed_hash_function!(
hash_schema,
Schema,
SchemaHash
);
typed_hash_function!(
hash_projection_apply,
ProjectionApply,
ProjectionApplyHash
);
typed_hash_function!(
hash_offline_maintenance_input,
OfflineMaintenanceInput,
OfflineMaintenanceInputHash
);
typed_hash_function!(
hash_contract_migration_input,
ContractMigrationInput,
ContractMigrationInputHash
);
typed_hash_function!(
hash_contract_migration_journal,
ContractMigrationJournal,
ContractMigrationJournalHash
);
typed_hash_function!(
hash_contract_migration_validation,
ContractMigrationValidation,
ContractMigrationValidationDigest
);
pub fn keyed_hash(
domain: KeyedHashDomain,
key_id: DigestKeyId,
key: &DigestKey,
payload: &[u8],
) -> KeyedDigest {
keyed_hash_secret(domain, key_id, key.expose_secret(), payload)
}
pub fn keyed_hash_secret(
domain: KeyedHashDomain,
key_id: DigestKeyId,
key_bytes: &[u8; 32],
payload: &[u8],
) -> KeyedDigest {
let mut mac = Hmac::<Sha256>::new_from_slice(key_bytes)
.expect("HMAC-SHA-256 accepts keys of every length");
write_frame(&mut mac, HMAC_PREFIX, domain.label(), payload);
KeyedDigest::new(key_id, mac.finalize().into_bytes().into())
}
#[must_use]
pub fn hash_capability_token(
key_id: DigestKeyId,
key: &DigestKey,
raw_token: &[u8; 32],
) -> CapabilityTokenDigest {
hash_capability_token_secret(key_id, key.expose_secret(), raw_token)
}
#[must_use]
pub fn hash_capability_token_secret(
key_id: DigestKeyId,
key_bytes: &[u8; 32],
raw_token: &[u8; 32],
) -> CapabilityTokenDigest {
let digest = keyed_hash_secret(
KeyedHashDomain::CapabilityToken,
key_id,
key_bytes,
raw_token,
);
CapabilityTokenDigest::from_hmac_bytes(key_id, *digest.as_bytes())
}
fn write_frame<T: sha2::digest::Update>(
target: &mut T,
prefix: &[u8],
domain: &str,
payload: &[u8],
) {
write_frame_header(target, prefix, domain, payload.len() as u64);
target.update(payload);
}
fn write_frame_header<T: sha2::digest::Update>(
target: &mut T,
prefix: &[u8],
domain: &str,
payload_length: u64,
) {
let domain = domain.as_bytes();
debug_assert!(u16::try_from(domain.len()).is_ok());
let domain_length = domain.len() as u16;
target.update(prefix);
target.update(&[DIGEST_SCHEME_V1]);
target.update(&domain_length.to_be_bytes());
target.update(domain);
target.update(&payload_length.to_be_bytes());
}
struct HexDigest<'a>(&'a [u8; 32]);
impl fmt::Debug for HexDigest<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
for byte in self.0 {
write!(formatter, "{byte:02x}")?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use super::*;
#[test]
fn streaming_hash_preserves_v1_framing_across_chunk_boundaries() {
let payload: Vec<u8> = (0..=255).cycle().take(1025).collect();
for domain in HashDomain::ALL {
for length in [0, 1, 55, 56, 63, 64, 65, 1025] {
for chunk_size in [1, 7, 64, 129] {
let mut streamed = ContentHasher::new(domain, length as u64);
for chunk in payload[..length].chunks(chunk_size) {
streamed.update(chunk).expect("within declared length");
}
assert_eq!(
streamed.finish().expect("complete"),
hash(domain, &payload[..length])
);
}
}
}
}
#[test]
fn streaming_hash_refuses_short_extra_and_poisoned_input() {
let mut short = ContentHasher::new(HashDomain::Schema, 2);
short.update(b"a").expect("prefix");
assert_eq!(short.finish(), Err(HashLengthMismatch));
let mut extra = ContentHasher::new(HashDomain::Schema, 1);
assert_eq!(extra.update(b"secret"), Err(HashLengthMismatch));
assert_eq!(extra.update(b"a"), Err(HashLengthMismatch));
assert_eq!(extra.finish(), Err(HashLengthMismatch));
let mut complete = ContentHasher::new(HashDomain::Schema, 1);
complete.update(b"a").expect("complete");
assert_eq!(complete.update(b"b"), Err(HashLengthMismatch));
assert_eq!(complete.finish(), Err(HashLengthMismatch));
assert_eq!(
ContentHasher::new(HashDomain::Schema, u64::MAX).finish(),
Err(HashLengthMismatch)
);
}
#[test]
fn domain_registry_has_no_duplicate_labels() {
let mut labels = BTreeSet::new();
for domain in HashDomain::ALL {
assert!(labels.insert(domain.label()));
}
for domain in KeyedHashDomain::ALL {
assert!(labels.insert(domain.label()));
}
}
#[test]
fn identical_payloads_are_separated_by_domain() {
let values = HashDomain::ALL
.map(|domain| hash(domain, b"same payload"))
.into_iter()
.collect::<BTreeSet<_>>();
assert_eq!(values.len(), HashDomain::ALL.len());
}
#[test]
fn content_digest_retains_its_domain() {
for domain in HashDomain::ALL {
assert_eq!(hash(domain, b"payload").domain(), domain);
}
}
}