lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit cf75f8b9d49ca9c0a25c1477ccc16cd26b457679
parent 426c0ab6287f5a8bbcb36eced40eaafa147a2215
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Jul 2026 06:53:35 +0000

outbox: bridge typed publication signing

- preflight claimed Phase 1 authority before invoking the signer
- persist signature-verified event bytes with claim-fenced compare-and-swap
- reuse stable per-target dispatch identity and exact event JSON on retry
- quarantine corrupt durable signed bytes and cover all seven publication leaves

Diffstat:
MCHANGELOG.md | 7+++++++
MCargo.lock | 6++++++
Mcontracts/releases/1.0.0-alpha.1.toml | 12++++++++++++
Mcrates/authority/Cargo.toml | 5++++-
Mcrates/authority/src/authorization.rs | 165++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/authority/src/lib.rs | 4++--
Mcrates/authority/src/local_signer.rs | 32+++++++++++++++++++++++++++++---
Mcrates/authority/src/signer.rs | 16+++++++++++++++-
Mcrates/nostr/src/draft_signing.rs | 36++++++++++++++++++++++++++++++++++++
Mcrates/nostr/src/lib.rs | 4+++-
Mcrates/outbox/contracts/migration_authority_v1.manifest.json | 12++++++------
Mcrates/outbox/contracts/migration_authority_v1.manifest.sha256 | 2+-
Mcrates/outbox/contracts/phase1_publication_v1.descriptor.json | 3+++
Mcrates/outbox/contracts/phase1_publication_v1.manifest.json | 26+++++++++++++-------------
Mcrates/outbox/contracts/phase1_publication_v1.manifest.schema.json | 2+-
Mcrates/outbox/contracts/phase1_publication_v1.manifest.sha256 | 2+-
Mcrates/outbox/src/lib.rs | 9+++++----
Mcrates/outbox/src/phase1_publication.rs | 520+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/transport_nostr/Cargo.toml | 5+++++
Mcrates/transport_nostr/README | 8++++++++
Mcrates/transport_nostr/src/lib.rs | 4+++-
Mcrates/transport_nostr/src/outbox.rs | 48++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/transport_nostr/tests/phase1_outbox_publication.rs | 523+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/contract/outbox_phase1_publication.rs | 6+++---
24 files changed, 1392 insertions(+), 65 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -45,6 +45,13 @@ publish policy both pass for the same source revision. observations, and direct Nostr publication require the verified signed-event typestate; invalid or corrupted signatures fail before durable, event-store, or relay-adapter mutation. +<!-- release-change: phase1-verified-signing-retry-bridge --> +- Phase 1 publication now crosses a claim-fenced, freshly revalidated typed + signing preflight into an authorized signer without reopening generic + `TypedOnly` draft construction. The original signature-verified NIP-01 event + JSON object bytes are persisted once, quarantined on durable corruption, and + reused with the same per-target dispatch identity across retries. Exact-byte + identity applies to the decoded event object, not WebSocket framing. - Event-store schema initialization now uses a transactional, checksummed migration authority with exact legacy-baseline adoption, shared-database catalog scoping, tamper-evident fail-closed managed history, exact catalog diff --git a/Cargo.lock b/Cargo.lock @@ -4595,6 +4595,7 @@ name = "radroots_authority" version = "1.0.0-alpha.1" dependencies = [ "radroots_event", + "radroots_event_codec", "radroots_nostr", "serde_json", ] @@ -5243,14 +5244,19 @@ name = "radroots_transport_nostr" version = "1.0.0-alpha.1" dependencies = [ "futures", + "hex", "nostr", + "radroots_authority", + "radroots_blossom", "radroots_event", + "radroots_event_codec", "radroots_event_store", "radroots_nostr", "radroots_outbox", "radroots_transport", "serde", "serde_json", + "sha2", "thiserror 1.0.69", "tokio", "url", diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml @@ -584,3 +584,15 @@ semver_impacts = [ "change_exported_algorithm_behavior", ] summary = "Require BIP340-verified signed-event typestate at authority, outbox, runtime, inbound-observation, and direct Nostr publication boundaries; reject invalid signatures before durable, event-store, or adapter mutation, including corrupted stored signatures on reload." + +[[changes]] +id = "phase1-verified-signing-retry-bridge" +classification = "breaking" +semver_impacts = [ + "add_exported_type", + "add_exported_function", + "change_exported_function_signature", + "change_exported_constant_value", + "change_exported_algorithm_behavior", +] +summary = "Bridge sealed Phase 1 typed publication preflight through authorized signature verification into immutable exact event-object persistence, terminal corruption quarantine, and stable per-target retry dispatch without reopening generic TypedOnly authoring or claiming WebSocket frame identity." diff --git a/crates/authority/Cargo.toml b/crates/authority/Cargo.toml @@ -12,7 +12,7 @@ homepage.workspace = true [features] default = ["std"] -std = ["radroots_event/std"] +std = ["radroots_event/std", "radroots_event_codec/std"] local_signer = [ "std", "dep:radroots_nostr", @@ -24,6 +24,9 @@ local_signer = [ radroots_event = { workspace = true, default-features = false, features = [ "signature", ] } +radroots_event_codec = { workspace = true, default-features = false, features = [ + "serde_json", +] } radroots_nostr = { workspace = true, optional = true, default-features = false } [dev-dependencies] diff --git a/crates/authority/src/authorization.rs b/crates/authority/src/authorization.rs @@ -1,6 +1,9 @@ #![forbid(unsafe_code)] -use crate::{RadrootsActorContext, RadrootsAuthorityError, RadrootsEventSigner}; +use crate::{ + RadrootsActorContext, RadrootsAuthorityError, RadrootsEventSigner, + RadrootsPhase1PublicationSigner, +}; use radroots_event::contract::{RadrootsEventContract, event_contract}; use radroots_event::draft::{ RadrootsDraftError, RadrootsEventDraft, RadrootsSignedEvent, RadrootsVerifiedSignedEvent, @@ -8,6 +11,7 @@ use radroots_event::draft::{ }; #[cfg(test)] use radroots_event::wire::RadrootsNip01EventWire; +use radroots_event_codec::wire::publication::RadrootsPhase1MediaReadyPublicationArtifact; #[cfg(not(feature = "std"))] use alloc::{borrow::ToOwned, string::ToString}; @@ -82,6 +86,59 @@ where sign_authorized_draft_with_validator(actor, signer, draft, |draft| draft.validate_for_signing()) } +/// Signs one freshly preflighted member of the sealed Phase 1 publication set. +/// +/// Unlike [`sign_authorized_draft`], this entry point deliberately accepts no +/// generic draft. The media-ready artifact is the typed authoring capability; +/// its frozen draft fields are passed intact to the signer and checked again +/// against the returned signed object before signature verification. +pub fn sign_authorized_phase1_publication<S>( + actor: &RadrootsActorContext, + signer: &S, + ready: &RadrootsPhase1MediaReadyPublicationArtifact, +) -> Result<RadrootsVerifiedSignedEvent, RadrootsAuthorityError> +where + S: RadrootsPhase1PublicationSigner + ?Sized, +{ + let artifact = ready.artifact(); + let draft = artifact.draft(); + let contract = event_contract(artifact.event_contract_id()).ok_or_else(|| { + RadrootsAuthorityError::UnknownContract { + contract_id: artifact.event_contract_id().to_owned(), + } + })?; + if contract.kind != draft.kind() { + return Err(RadrootsAuthorityError::DraftKindMismatch { + contract_id: contract.id.to_owned(), + expected_kind: contract.kind, + actual_kind: draft.kind(), + }); + } + authorize_actor_for_contract(actor, contract)?; + if actor.pubkey() != artifact.expected_author() { + return Err(RadrootsAuthorityError::ActorPubkeyMismatch { + expected_pubkey: artifact.expected_author().as_str().to_owned(), + actor_pubkey: actor.pubkey().as_str().to_owned(), + }); + } + if RadrootsEventSigner::pubkey(signer) != artifact.expected_author() { + return Err(RadrootsAuthorityError::SignerPubkeyMismatch { + expected_pubkey: artifact.expected_author().as_str().to_owned(), + signer_pubkey: RadrootsEventSigner::pubkey(signer).as_str().to_owned(), + }); + } + + let signed_event = signer.sign_phase1_publication_draft( + draft, + artifact.expected_author(), + artifact.expected_event_id(), + )?; + validate_signed_event_matches_phase1_publication(&signed_event, ready)?; + signed_event + .verify_signature() + .map_err(RadrootsAuthorityError::SignedEventSignatureVerification) +} + fn sign_authorized_draft_with_validator<S, V>( actor: &RadrootsActorContext, signer: &S, @@ -110,6 +167,51 @@ pub fn validate_signed_event_matches_draft( .map_err(authority_error_from_draft_validation) } +fn validate_signed_event_matches_phase1_publication( + signed_event: &RadrootsSignedEvent, + ready: &RadrootsPhase1MediaReadyPublicationArtifact, +) -> Result<(), RadrootsAuthorityError> { + let artifact = ready.artifact(); + let draft = artifact.draft(); + if signed_event.pubkey_str() != artifact.expected_author().as_str() { + return Err(RadrootsAuthorityError::SignedEventPubkeyMismatch { + expected_pubkey: artifact.expected_author().as_str().to_owned(), + actual_pubkey: signed_event.pubkey_str().to_owned(), + }); + } + if signed_event.id_str() != artifact.expected_event_id().as_str() { + return Err(RadrootsAuthorityError::SignedEventIdMismatch { + expected_event_id: artifact.expected_event_id().as_str().to_owned(), + actual_event_id: signed_event.id_str().to_owned(), + }); + } + if signed_event.created_at() != draft.created_at() { + return Err(RadrootsAuthorityError::SignedEventCreatedAtMismatch { + expected_created_at: draft.created_at(), + actual_created_at: signed_event.created_at(), + }); + } + if signed_event.kind() != draft.kind() { + return Err(RadrootsAuthorityError::SignedEventKindMismatch { + expected_kind: draft.kind(), + actual_kind: signed_event.kind(), + }); + } + if signed_event.tags_as_vec() != draft.tags() { + return Err(RadrootsAuthorityError::SignedEventTagsMismatch { + expected_len: draft.tags().len(), + actual_len: signed_event.tags_as_vec().len(), + }); + } + if signed_event.content() != draft.content() { + return Err(RadrootsAuthorityError::SignedEventContentMismatch { + expected_len: draft.content().len(), + actual_len: signed_event.content().len(), + }); + } + Ok(()) +} + fn authority_error_from_draft_validation(error: RadrootsDraftError) -> RadrootsAuthorityError { match error { RadrootsDraftError::SignedEventPubkeyMismatch { @@ -175,10 +277,17 @@ mod tests { use radroots_event::contract::{ RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, RadrootsActorRole, event_contract, }; - use radroots_event::ids::RadrootsPublicKey; + use radroots_event::ids::{RadrootsEventId, RadrootsPublicKey}; use radroots_event::kinds::{KIND_CLASSIFIED_LISTING, KIND_POST, KIND_TRADE_PROPOSAL}; + use radroots_event::post::RadrootsAuthoredUpdate; + use radroots_event_codec::wire::publication::allowlist::allow_phase1_publication_artifact; + use radroots_event_codec::wire::publication::{ + RadrootsPhase1PublicationArtifact, RadrootsPhase1PublicationDraft, + bind_phase1_publication_media_readiness, + }; use radroots_nostr::prelude::{ RadrootsNostrKeys, RadrootsNostrSecretKey, radroots_nostr_sign_frozen_draft, + radroots_nostr_sign_phase1_publication_draft, }; const FIXTURE_ALICE_SECRET_KEY_HEX: &str = @@ -389,6 +498,25 @@ mod tests { } } + impl RadrootsPhase1PublicationSigner for ValidSigner { + fn sign_phase1_publication_draft( + &self, + draft: &RadrootsPhase1PublicationDraft, + expected_pubkey: &RadrootsPublicKey, + expected_event_id: &RadrootsEventId, + ) -> Result<RadrootsSignedEvent, RadrootsSignerError> { + radroots_nostr_sign_phase1_publication_draft( + &self.keys, + draft, + expected_pubkey, + expected_event_id, + ) + .map_err(|error| RadrootsSignerError::SigningFailed { + message: error.to_string(), + }) + } + } + fn signed_event_from_draft(draft: &RadrootsEventDraft) -> RadrootsSignedEvent { signed_event_from_parts( draft.expected_pubkey_str().to_owned(), @@ -695,6 +823,39 @@ mod tests { } #[test] + fn phase1_publication_signing_uses_sealed_typed_draft() { + let artifact = RadrootsPhase1PublicationArtifact::from_update( + &RadrootsAuthoredUpdate::new("Victoria carrots are ready").unwrap(), + 1_784_347_200, + FIXTURE_ALICE_PUBLIC_KEY_HEX, + ) + .unwrap(); + let ready = bind_phase1_publication_media_readiness( + allow_phase1_publication_artifact(artifact).unwrap(), + Vec::new(), + ) + .unwrap(); + let actor = RadrootsActorContext::test( + FIXTURE_ALICE_PUBLIC_KEY_HEX, + Vec::<RadrootsActorRole>::new(), + ) + .unwrap(); + let signed = sign_authorized_phase1_publication(&actor, &ValidSigner::fixture(), &ready) + .expect("verified Phase 1 publication"); + + assert_eq!( + signed.signed_event().raw_json().as_bytes(), + serde_json::to_string(signed.signed_event().wire()) + .unwrap() + .as_bytes() + ); + assert_eq!( + signed.signed_event().id_str(), + ready.artifact().expected_event_id().as_str() + ); + } + + #[test] fn matching_id_with_invalid_signature_is_rejected() { let pubkey = hex_64('a'); let draft = operational_listing_event_draft(pubkey.as_str()); diff --git a/crates/authority/src/lib.rs b/crates/authority/src/lib.rs @@ -18,9 +18,9 @@ pub use actor::{ }; pub use authorization::{ authorize_actor_for_contract, authorize_actor_for_draft, authorize_signer_for_draft, - sign_authorized_draft, validate_signed_event_matches_draft, + sign_authorized_draft, sign_authorized_phase1_publication, validate_signed_event_matches_draft, }; pub use error::{RadrootsAuthorityError, RadrootsSignerError}; #[cfg(feature = "local_signer")] pub use local_signer::RadrootsLocalEventSigner; -pub use signer::{RadrootsEventSigner, RadrootsSignerIdentity}; +pub use signer::{RadrootsEventSigner, RadrootsPhase1PublicationSigner, RadrootsSignerIdentity}; diff --git a/crates/authority/src/local_signer.rs b/crates/authority/src/local_signer.rs @@ -1,9 +1,16 @@ #![forbid(unsafe_code)] -use crate::{RadrootsAuthorityError, RadrootsEventSigner, RadrootsSignerError}; +use crate::{ + RadrootsAuthorityError, RadrootsEventSigner, RadrootsPhase1PublicationSigner, + RadrootsSignerError, +}; use radroots_event::draft::{RadrootsEventDraft, RadrootsSignedEvent}; -use radroots_event::ids::RadrootsPublicKey; -use radroots_nostr::prelude::{RadrootsNostrKeys, radroots_nostr_sign_frozen_draft}; +use radroots_event::ids::{RadrootsEventId, RadrootsPublicKey}; +use radroots_event_codec::wire::publication::RadrootsPhase1PublicationDraft; +use radroots_nostr::prelude::{ + RadrootsNostrKeys, radroots_nostr_sign_frozen_draft, + radroots_nostr_sign_phase1_publication_draft, +}; pub struct RadrootsLocalEventSigner { keys: RadrootsNostrKeys, @@ -35,6 +42,25 @@ impl RadrootsEventSigner for RadrootsLocalEventSigner { } } +impl RadrootsPhase1PublicationSigner for RadrootsLocalEventSigner { + fn sign_phase1_publication_draft( + &self, + draft: &RadrootsPhase1PublicationDraft, + expected_pubkey: &RadrootsPublicKey, + expected_event_id: &RadrootsEventId, + ) -> Result<RadrootsSignedEvent, RadrootsSignerError> { + radroots_nostr_sign_phase1_publication_draft( + &self.keys, + draft, + expected_pubkey, + expected_event_id, + ) + .map_err(|error| RadrootsSignerError::SigningFailed { + message: error.to_string(), + }) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/authority/src/signer.rs b/crates/authority/src/signer.rs @@ -2,9 +2,10 @@ use crate::{RadrootsAuthorityError, RadrootsSignerError}; use radroots_event::draft::{RadrootsEventDraft, RadrootsSignedEvent}; -use radroots_event::ids::RadrootsPublicKey; +use radroots_event::ids::{RadrootsEventId, RadrootsPublicKey}; #[cfg(test)] use radroots_event::wire::RadrootsNip01EventWire; +use radroots_event_codec::wire::publication::RadrootsPhase1PublicationDraft; #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsSignerIdentity { @@ -32,6 +33,19 @@ pub trait RadrootsEventSigner { ) -> Result<RadrootsSignedEvent, RadrootsSignerError>; } +/// Signing capability for the sealed Phase 1 typed publication set. +/// +/// The draft cannot be constructed from arbitrary wire parts. Authority passes +/// it only after the owning media-ready artifact has been revalidated. +pub trait RadrootsPhase1PublicationSigner: RadrootsEventSigner { + fn sign_phase1_publication_draft( + &self, + draft: &RadrootsPhase1PublicationDraft, + expected_pubkey: &RadrootsPublicKey, + expected_event_id: &RadrootsEventId, + ) -> Result<RadrootsSignedEvent, RadrootsSignerError>; +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/nostr/src/draft_signing.rs b/crates/nostr/src/draft_signing.rs @@ -5,7 +5,9 @@ use crate::events::radroots_nostr_build_event_unchecked; use crate::types::{RadrootsNostrKeys, RadrootsNostrTimestamp}; use nostr::JsonUtil; use radroots_event::draft::{RadrootsEventDraft, RadrootsSignedEvent}; +use radroots_event::ids::{RadrootsEventId, RadrootsPublicKey}; use radroots_event::wire::RadrootsNip01EventWire; +use radroots_event_codec::wire::publication::RadrootsPhase1PublicationDraft; pub fn radroots_nostr_sign_frozen_draft( keys: &RadrootsNostrKeys, @@ -40,6 +42,40 @@ pub fn radroots_nostr_sign_frozen_draft( RadrootsSignedEvent::from_wire_verified_id(wire, raw_json).map_err(Into::into) } +pub fn radroots_nostr_sign_phase1_publication_draft( + keys: &RadrootsNostrKeys, + draft: &RadrootsPhase1PublicationDraft, + expected_pubkey: &RadrootsPublicKey, + expected_event_id: &RadrootsEventId, +) -> Result<RadrootsSignedEvent, RadrootsNostrError> { + let actual_pubkey = keys.public_key().to_hex(); + if actual_pubkey != expected_pubkey.as_str() { + return Err(RadrootsNostrError::FrozenDraftPubkeyMismatch { + expected_pubkey: expected_pubkey.as_str().to_owned(), + actual_pubkey, + }); + } + + let event = radroots_nostr_build_event_unchecked( + draft.kind(), + draft.content().to_owned(), + draft.tags().to_vec(), + )? + .custom_created_at(RadrootsNostrTimestamp::from_secs(draft.created_at())) + .sign_with_keys(keys)?; + let actual_event_id = event.id.to_hex(); + if actual_event_id != expected_event_id.as_str() { + return Err(RadrootsNostrError::FrozenDraftEventIdMismatch { + expected_event_id: expected_event_id.as_str().to_owned(), + actual_event_id, + }); + } + + let raw_json = event.as_json(); + let wire = RadrootsNip01EventWire::parse_json(raw_json.as_str())?; + RadrootsSignedEvent::from_wire_verified_id(wire, raw_json).map_err(Into::into) +} + #[cfg(test)] mod tests { use super::radroots_nostr_sign_frozen_draft; diff --git a/crates/nostr/src/lib.rs b/crates/nostr/src/lib.rs @@ -165,7 +165,9 @@ pub mod prelude { pub use crate::event_adapters::{to_post_event_metadata, to_profile_event_metadata}; #[cfg(feature = "events")] - pub use crate::draft_signing::radroots_nostr_sign_frozen_draft; + pub use crate::draft_signing::{ + radroots_nostr_sign_frozen_draft, radroots_nostr_sign_phase1_publication_draft, + }; #[cfg(feature = "events")] pub use crate::event_convert::{radroots_event_from_nostr, radroots_event_ptr_from_nostr}; diff --git a/crates/outbox/contracts/migration_authority_v1.manifest.json b/crates/outbox/contracts/migration_authority_v1.manifest.json @@ -224,10 +224,10 @@ }, { "file": { - "byte_length": 2604, + "byte_length": 2651, "hash_algorithm": "sha256_bytes_v1", "path": "crates/outbox/src/lib.rs", - "sha256": "a97deba8ec374102ba6b3c243e414cf99f14ab4fa374dd6f245b5339793ca037" + "sha256": "13f1096c4d81ad23078b6fa043e8d616016029109aba4551b756ada436edc91c" }, "role": "outbox_public_surface" }, @@ -350,19 +350,19 @@ }, { "file": { - "byte_length": 26484, + "byte_length": 27060, "hash_algorithm": "sha256_bytes_v1", "path": "contracts/releases/1.0.0-alpha.1.toml", - "sha256": "331a32e5ca7ba615a04e5837a1a06173f9d1d5ba663103255206d9dd39097dd8" + "sha256": "7dc8f9403dd9eb79fa6381f0dd3eb2a233d2f376eda38f6ec501ff29b98a2923" }, "role": "release_record" }, { "file": { - "byte_length": 37356, + "byte_length": 37874, "hash_algorithm": "sha256_bytes_v1", "path": "CHANGELOG.md", - "sha256": "bb26037e84cfb120ba372320dc15b5e1e865f49a8835b8635847df3bf9ab25f5" + "sha256": "290225f031b2b6f7a393f0c6baa50eee06e1e2e5b8cb2f177bca3ae9c0ee581e" }, "role": "release_notes" } diff --git a/crates/outbox/contracts/migration_authority_v1.manifest.sha256 b/crates/outbox/contracts/migration_authority_v1.manifest.sha256 @@ -1 +1 @@ -2494868f0acb947a36e1adb0d3829203de7b54c731e401d705ac7dec41566964 +4603e33423cfbd589bad220067ec0bf0995fb77c42915a02312bebe4188c82f8 diff --git a/crates/outbox/contracts/phase1_publication_v1.descriptor.json b/crates/outbox/contracts/phase1_publication_v1.descriptor.json @@ -68,6 +68,9 @@ { "id": "retry-signing", "scope": "event", "from": "claimed-for-signing", "to": "failed-retryable", "revision_cas": true, "lease_predicate": "matching-live-token", "durable_side_effect": "persist-bounded-error", "retry_class": "retryable", "repair_edge": false, "terminal_destination": false }, { "id": "fail-signing", "scope": "event", "from": "claimed-for-signing", "to": "failed-terminal", "revision_cas": true, "lease_predicate": "matching-live-token", "durable_side_effect": "persist-bounded-error", "retry_class": "terminal", "repair_edge": false, "terminal_destination": true }, { "id": "quarantine-signing", "scope": "event", "from": "claimed-for-signing", "to": "quarantined", "revision_cas": true, "lease_predicate": "matching-live-token", "durable_side_effect": "persist-bounded-error", "retry_class": "terminal", "repair_edge": false, "terminal_destination": true }, + { "id": "quarantine-corrupt-signed-ready", "scope": "event", "from": "signed-ready", "to": "quarantined", "revision_cas": true, "lease_predicate": "revision-cas", "durable_side_effect": "preserve-corrupt-signed-bytes", "retry_class": "terminal", "repair_edge": false, "terminal_destination": true }, + { "id": "quarantine-corrupt-dispatching", "scope": "event", "from": "dispatching", "to": "quarantined", "revision_cas": true, "lease_predicate": "revision-cas", "durable_side_effect": "preserve-corrupt-signed-bytes", "retry_class": "terminal", "repair_edge": false, "terminal_destination": true }, + { "id": "quarantine-corrupt-published", "scope": "event", "from": "published", "to": "quarantined", "revision_cas": true, "lease_predicate": "revision-cas", "durable_side_effect": "preserve-corrupt-signed-bytes", "retry_class": "terminal", "repair_edge": false, "terminal_destination": true }, { "id": "cancel-signing", "scope": "event", "from": "claimed-for-signing", "to": "cancelled", "revision_cas": true, "lease_predicate": "matching-live-token", "durable_side_effect": "clear-claim", "retry_class": "terminal", "repair_edge": false, "terminal_destination": true }, { "id": "begin-dispatch", "scope": "event", "from": "signed-ready", "to": "dispatching", "revision_cas": true, "lease_predicate": "target-matching-live-token", "durable_side_effect": "persist-dispatch-intent", "retry_class": "none", "repair_edge": false, "terminal_destination": false }, { "id": "continue-dispatch", "scope": "event", "from": "dispatching", "to": "dispatching", "revision_cas": true, "lease_predicate": "target-matching-live-token", "durable_side_effect": "persist-dispatch-intent", "retry_class": "retryable", "repair_edge": false, "terminal_destination": false }, diff --git a/crates/outbox/contracts/phase1_publication_v1.manifest.json b/crates/outbox/contracts/phase1_publication_v1.manifest.json @@ -1,14 +1,14 @@ { "contract_id": "radroots_outbox.phase1_publication.v1", "descriptor": { - "byte_length": 10119, + "byte_length": 11023, "path": "crates/outbox/contracts/phase1_publication_v1.descriptor.json", - "sha256": "2a85ff1ff4ef45dcfbac2e255b818000b61a218277b97931dc2af7a489716067" + "sha256": "7c8f1a99e4c7001af5238d164c04acfd70afc6e7133a447602ae5b5d2a1f6622" }, "manifest_schema": { "byte_length": 3806, "path": "crates/outbox/contracts/phase1_publication_v1.manifest.schema.json", - "sha256": "48587292d857b56a97fa053f68712251b16de6e814d3697f13e7e5d5a4497d77" + "sha256": "05a810c798676197608cdfd2bc081fb003e4b878c285a338eed6abe636c0817d" }, "migration": { "down": { @@ -56,17 +56,17 @@ }, { "file": { - "byte_length": 2604, + "byte_length": 2651, "path": "crates/outbox/src/lib.rs", - "sha256": "a97deba8ec374102ba6b3c243e414cf99f14ab4fa374dd6f245b5339793ca037" + "sha256": "13f1096c4d81ad23078b6fa043e8d616016029109aba4551b756ada436edc91c" }, "role": "outbox_public_surface" }, { "file": { - "byte_length": 101112, + "byte_length": 118318, "path": "crates/outbox/src/phase1_publication.rs", - "sha256": "9019a23d593b27d9a0e1b67871d8da53c91b5a0b0a2a37698c64cbbfe9111272" + "sha256": "5ee6745c8eb36fdc18e8578880ca246cc1579e24ae7e7b89559ceaf1ffcbbc73" }, "role": "phase1_publication_runtime" }, @@ -106,7 +106,7 @@ "file": { "byte_length": 24248, "path": "tools/xtask/src/contract/outbox_phase1_publication.rs", - "sha256": "4c81dea379e674066f0822d52f7d90e601a2d5311d5f88b809f760682c42bfa0" + "sha256": "8a2a09944d78bd67b76a7e1857e6815af2c4ebe712bb14ed48eb66e4cb156dd3" }, "role": "contract_governance" }, @@ -128,17 +128,17 @@ }, { "file": { - "byte_length": 26484, + "byte_length": 27060, "path": "contracts/releases/1.0.0-alpha.1.toml", - "sha256": "331a32e5ca7ba615a04e5837a1a06173f9d1d5ba663103255206d9dd39097dd8" + "sha256": "7dc8f9403dd9eb79fa6381f0dd3eb2a233d2f376eda38f6ec501ff29b98a2923" }, "role": "release_record" }, { "file": { - "byte_length": 37356, + "byte_length": 37874, "path": "CHANGELOG.md", - "sha256": "bb26037e84cfb120ba372320dc15b5e1e865f49a8835b8635847df3bf9ab25f5" + "sha256": "290225f031b2b6f7a393f0c6baa50eee06e1e2e5b8cb2f177bca3ae9c0ee581e" }, "role": "release_notes" } @@ -147,6 +147,6 @@ "event_state_count": 9, "stable_error_count": 25, "target_state_count": 8, - "transition_count": 25 + "transition_count": 28 } } diff --git a/crates/outbox/contracts/phase1_publication_v1.manifest.schema.json b/crates/outbox/contracts/phase1_publication_v1.manifest.schema.json @@ -150,7 +150,7 @@ "const": 8 }, "transition_count": { - "const": 25 + "const": 28 } }, "required": [ diff --git a/crates/outbox/contracts/phase1_publication_v1.manifest.sha256 b/crates/outbox/contracts/phase1_publication_v1.manifest.sha256 @@ -1 +1 @@ -b7be8bc95902ad61bdbd7ffcf9022fd28036b94e4a2abeff8119dfb21a1efc74 +ecb9260074e3196e028a62ed683a9126b0d5a70a44eaf4ccd45be9fb97d5a06b diff --git a/crates/outbox/src/lib.rs b/crates/outbox/src/lib.rs @@ -39,10 +39,11 @@ pub use phase1_publication::{ RADROOTS_PHASE1_PUBLICATION_TRANSITIONS, RadrootsPhase1PublicationClaim, RadrootsPhase1PublicationEnqueueReceipt, RadrootsPhase1PublicationEnqueueStatus, RadrootsPhase1PublicationError, RadrootsPhase1PublicationEventState, - RadrootsPhase1PublicationRecord, RadrootsPhase1PublicationTarget, - RadrootsPhase1PublicationTargetClaim, RadrootsPhase1PublicationTargetPolicy, - RadrootsPhase1PublicationTargetState, RadrootsPhase1PublicationTransition, - RadrootsPhase1PublicationTransitionRetryClass, RadrootsPhase1PublicationTransitionScope, + RadrootsPhase1PublicationRecord, RadrootsPhase1PublicationSigningPreflight, + RadrootsPhase1PublicationTarget, RadrootsPhase1PublicationTargetClaim, + RadrootsPhase1PublicationTargetPolicy, RadrootsPhase1PublicationTargetState, + RadrootsPhase1PublicationTransition, RadrootsPhase1PublicationTransitionRetryClass, + RadrootsPhase1PublicationTransitionScope, }; #[cfg(feature = "sqlite")] pub use schema::{RadrootsOutboxSchemaStatus, inspect_outbox_schema_status}; diff --git a/crates/outbox/src/phase1_publication.rs b/crates/outbox/src/phase1_publication.rs @@ -322,6 +322,39 @@ pub const RADROOTS_PHASE1_PUBLICATION_TRANSITIONS: &[RadrootsPhase1PublicationTr true ), transition!( + "quarantine-corrupt-signed-ready", + Event, + "signed-ready", + "quarantined", + "revision-cas", + "preserve-corrupt-signed-bytes", + Terminal, + false, + true + ), + transition!( + "quarantine-corrupt-dispatching", + Event, + "dispatching", + "quarantined", + "revision-cas", + "preserve-corrupt-signed-bytes", + Terminal, + false, + true + ), + transition!( + "quarantine-corrupt-published", + Event, + "published", + "quarantined", + "revision-cas", + "preserve-corrupt-signed-bytes", + Terminal, + false, + true + ), + transition!( "cancel-signing", Event, "claimed-for-signing", @@ -903,6 +936,32 @@ impl RadrootsPhase1PublicationClaim { } #[derive(Clone, Debug)] +pub struct RadrootsPhase1PublicationSigningPreflight { + claim: RadrootsPhase1PublicationClaim, + ready_artifact: RadrootsPhase1MediaReadyPublicationArtifact, + operation_digest: [u8; 32], + target_policy_digest: [u8; 32], +} + +impl RadrootsPhase1PublicationSigningPreflight { + pub const fn publication_id(&self) -> i64 { + self.claim.publication_id + } + + pub const fn revision(&self) -> u64 { + self.claim.revision + } + + pub const fn expires_at_ms(&self) -> i64 { + self.claim.expires_at_ms + } + + pub const fn ready_artifact(&self) -> &RadrootsPhase1MediaReadyPublicationArtifact { + &self.ready_artifact + } +} + +#[derive(Clone, Debug)] pub struct RadrootsPhase1PublicationTargetClaim { publication_id: i64, publication_revision: u64, @@ -910,6 +969,10 @@ pub struct RadrootsPhase1PublicationTargetClaim { target_revision: u64, token: [u8; 32], expires_at_ms: i64, + signed_event: RadrootsVerifiedSignedEvent, + endpoint_uri: String, + endpoint_fingerprint: [u8; 32], + dispatch_digest: [u8; 32], } impl RadrootsPhase1PublicationTargetClaim { @@ -932,6 +995,22 @@ impl RadrootsPhase1PublicationTargetClaim { pub const fn expires_at_ms(&self) -> i64 { self.expires_at_ms } + + pub const fn signed_event(&self) -> &RadrootsVerifiedSignedEvent { + &self.signed_event + } + + pub fn endpoint_uri(&self) -> &str { + &self.endpoint_uri + } + + pub const fn endpoint_fingerprint(&self) -> &[u8; 32] { + &self.endpoint_fingerprint + } + + pub const fn dispatch_digest(&self) -> &[u8; 32] { + &self.dispatch_digest + } } struct PreparedPublication { @@ -1049,6 +1128,16 @@ impl RadrootsOutbox { &self, publication_id: i64, ) -> Result<RadrootsPhase1PublicationRecord, RadrootsPhase1PublicationError> { + let mut transaction = self.pool.begin().await?; + let record = Self::load_phase1_publication_tx(&mut transaction, publication_id).await?; + transaction.commit().await?; + Ok(record) + } + + async fn load_phase1_publication_tx( + transaction: &mut Transaction<'_, Sqlite>, + publication_id: i64, + ) -> Result<RadrootsPhase1PublicationRecord, RadrootsPhase1PublicationError> { let row = sqlx::query( "SELECT publication_id, operation_digest, length(artifact_json) AS artifact_bytes, @@ -1071,7 +1160,7 @@ impl RadrootsOutbox { ) .bind(i64::try_from(RADROOTS_PHASE1_PUBLICATION_SIGNED_EVENT_MAX_BYTES).unwrap_or(i64::MAX)) .bind(publication_id) - .fetch_optional(&self.pool) + .fetch_optional(&mut **transaction) .await? .ok_or(RadrootsPhase1PublicationError::PublicationNotFound { publication_id })?; let artifact_json = bounded_blob( @@ -1109,7 +1198,7 @@ impl RadrootsOutbox { } })?; - let targets = load_targets(&self.pool, publication_id).await?; + let targets = load_targets(transaction, publication_id).await?; let required_target_count = usize_from_i64( row.try_get("required_target_count")?, "required_target_count", @@ -1193,6 +1282,46 @@ impl RadrootsOutbox { }) } + async fn load_phase1_publication_for_dispatch( + &self, + publication_id: i64, + now_ms: i64, + ) -> Result<RadrootsPhase1PublicationRecord, RadrootsPhase1PublicationError> { + match self.load_phase1_publication(publication_id).await { + Ok(record) => Ok(record), + Err(error) if is_persisted_authority_error(&error) => { + let diagnostic = error.public_diagnostic(); + let observed_revision = sqlx::query_scalar::<_, i64>( + "SELECT state_revision FROM outbox_phase1_publication WHERE publication_id = ?", + ) + .bind(publication_id) + .fetch_optional(&self.pool) + .await?; + let Some(observed_revision) = observed_revision else { + return Err(error); + }; + sqlx::query( + "UPDATE outbox_phase1_publication + SET state = 'quarantined', state_revision = state_revision + 1, + claim_token = NULL, claim_expires_at_ms = NULL, + last_error = ?, next_attempt_after_ms = ?, updated_at_ms = ? + WHERE publication_id = ? AND signed_event_json IS NOT NULL + AND state IN ('signed-ready', 'dispatching', 'published') + AND state_revision = ?", + ) + .bind(diagnostic) + .bind(now_ms) + .bind(now_ms) + .bind(publication_id) + .bind(observed_revision) + .execute(&self.pool) + .await?; + Err(error) + } + Err(error) => Err(error), + } + } + pub async fn claim_phase1_publication_for_signing( &self, publication_id: i64, @@ -1234,6 +1363,51 @@ impl RadrootsOutbox { }) } + pub async fn preflight_phase1_publication_signing( + &self, + claim: &RadrootsPhase1PublicationClaim, + now_ms: i64, + ) -> Result<RadrootsPhase1PublicationSigningPreflight, RadrootsPhase1PublicationError> { + validate_time(now_ms)?; + let mut transaction = self.pool.begin().await?; + let claim_row = sqlx::query( + "SELECT state, state_revision, claim_token, claim_expires_at_ms + FROM outbox_phase1_publication WHERE publication_id = ?", + ) + .bind(claim.publication_id) + .fetch_optional(&mut *transaction) + .await? + .ok_or(RadrootsPhase1PublicationError::PublicationNotFound { + publication_id: claim.publication_id, + })?; + let stored_token: Option<Vec<u8>> = claim_row.try_get("claim_token")?; + let stored_expiry: Option<i64> = claim_row.try_get("claim_expires_at_ms")?; + if claim_row.try_get::<String, _>("state")? != "claimed-for-signing" + || u64_from_i64(claim_row.try_get("state_revision")?, "state_revision")? + != claim.revision + || stored_token.as_deref() != Some(claim.token.as_slice()) + || stored_expiry != Some(claim.expires_at_ms) + || claim.expires_at_ms <= now_ms + { + return Err(RadrootsPhase1PublicationError::ClaimInvalid); + } + let record = + Self::load_phase1_publication_tx(&mut transaction, claim.publication_id).await?; + if record.revision != claim.revision + || record.state != RadrootsPhase1PublicationEventState::ClaimedForSigning + || record.signed_event.is_some() + { + return Err(RadrootsPhase1PublicationError::ClaimInvalid); + } + transaction.commit().await?; + Ok(RadrootsPhase1PublicationSigningPreflight { + claim: claim.clone(), + operation_digest: record.operation_digest, + target_policy_digest: record.target_policy.digest, + ready_artifact: record.ready_artifact, + }) + } + pub async fn renew_phase1_publication_claim( &self, claim: &RadrootsPhase1PublicationClaim, @@ -1334,13 +1508,13 @@ impl RadrootsOutbox { pub async fn complete_phase1_publication_signing( &self, - claim: &RadrootsPhase1PublicationClaim, + preflight: &RadrootsPhase1PublicationSigningPreflight, verified: &RadrootsVerifiedSignedEvent, now_ms: i64, ) -> Result<RadrootsPhase1PublicationRecord, RadrootsPhase1PublicationError> { validate_time(now_ms)?; - let record = self.load_phase1_publication(claim.publication_id).await?; - validate_signed_matches_artifact(verified.signed_event(), &record.ready_artifact)?; + let claim = &preflight.claim; + validate_signed_matches_artifact(verified.signed_event(), &preflight.ready_artifact)?; let signed_json = verified.signed_event().raw_json().as_bytes(); if signed_json.is_empty() || signed_json.len() > RADROOTS_PHASE1_PUBLICATION_SIGNED_EVENT_MAX_BYTES @@ -1349,6 +1523,11 @@ impl RadrootsOutbox { } let signed_digest: [u8; 32] = Sha256::digest(signed_json).into(); let signed_event_id = decode_hex32(verified.signed_event().id_str(), "signed_event_id")?; + let artifact = preflight.ready_artifact.artifact(); + let artifact_json = artifact.to_canonical_json(); + let expected_author = decode_hex32(artifact.expected_author().as_str(), "expected_author")?; + let expected_event_id = + decode_hex32(artifact.expected_event_id().as_str(), "expected_event_id")?; let affected = sqlx::query( "UPDATE outbox_phase1_publication SET state = 'signed-ready', state_revision = state_revision + 1, @@ -1356,7 +1535,10 @@ impl RadrootsOutbox { signed_event_json = ?, signed_event_digest = ?, signed_event_id = ?, last_error = NULL, next_attempt_after_ms = ?, updated_at_ms = ? WHERE publication_id = ? AND state = 'claimed-for-signing' AND state_revision = ? - AND claim_token = ? AND claim_expires_at_ms > ? AND signed_event_json IS NULL", + AND claim_token = ? AND claim_expires_at_ms > ? AND signed_event_json IS NULL + AND operation_digest = ? AND artifact_json = ? AND artifact_digest = ? + AND readiness_json = ? AND readiness_digest = ? AND expected_author = ? + AND expected_event_id = ? AND target_policy_digest = ?", ) .bind(signed_json) .bind(signed_digest.as_slice()) @@ -1367,6 +1549,20 @@ impl RadrootsOutbox { .bind(i64_from_u64(claim.revision, "state_revision")?) .bind(claim.token.as_slice()) .bind(now_ms) + .bind(preflight.operation_digest.as_slice()) + .bind(artifact_json.as_slice()) + .bind(artifact.artifact_digest().as_bytes().as_slice()) + .bind(preflight.ready_artifact.canonical_json()) + .bind( + preflight + .ready_artifact + .binding_digest() + .as_bytes() + .as_slice(), + ) + .bind(expected_author.as_slice()) + .bind(expected_event_id.as_slice()) + .bind(preflight.target_policy_digest.as_slice()) .execute(&self.pool) .await? .rows_affected(); @@ -1385,7 +1581,10 @@ impl RadrootsOutbox { now_ms: i64, lease_millis: i64, ) -> Result<RadrootsPhase1PublicationTargetClaim, RadrootsPhase1PublicationError> { - let record = self.load_phase1_publication(publication_id).await?; + validate_time(now_ms)?; + let record = self + .load_phase1_publication_for_dispatch(publication_id, now_ms) + .await?; if record.revision != observed_publication_revision || record.signed_event.is_none() || !matches!( @@ -1404,18 +1603,18 @@ impl RadrootsOutbox { if target.revision != observed_target_revision { return Err(RadrootsPhase1PublicationError::RevisionConflict); } + let signed_event = record + .signed_event + .as_ref() + .expect("checked signed event") + .clone(); + let endpoint_uri = target.endpoint_uri.clone(); + let endpoint_fingerprint = target.endpoint_fingerprint; + let dispatch_digest = target.dispatch_digest; let expires_at_ms = validated_expiry(now_ms, lease_millis)?; let token = new_claim_token()?; - let signed_digest: [u8; 32] = Sha256::digest( - record - .signed_event - .as_ref() - .expect("checked signed event") - .signed_event() - .raw_json() - .as_bytes(), - ) - .into(); + let signed_digest: [u8; 32] = + Sha256::digest(signed_event.signed_event().raw_json().as_bytes()).into(); let mut transaction = self.pool.begin().await?; let target_affected = sqlx::query( "UPDATE outbox_phase1_delivery_target @@ -1423,7 +1622,8 @@ impl RadrootsOutbox { claim_token = ?, claim_expires_at_ms = ?, updated_at_ms = ? WHERE target_id = ? AND publication_id = ? AND state_revision = ? AND state IN ('pending', 'failed-retryable', 'uncertain') - AND (claim_token IS NULL OR claim_expires_at_ms <= ?)", + AND (claim_token IS NULL OR claim_expires_at_ms <= ?) + AND endpoint_uri = ? AND endpoint_fingerprint = ? AND dispatch_digest = ?", ) .bind(token.as_slice()) .bind(expires_at_ms) @@ -1432,6 +1632,9 @@ impl RadrootsOutbox { .bind(publication_id) .bind(i64_from_u64(observed_target_revision, "target_revision")?) .bind(now_ms) + .bind(endpoint_uri.as_str()) + .bind(endpoint_fingerprint.as_slice()) + .bind(dispatch_digest.as_slice()) .execute(&mut *transaction) .await? .rows_affected(); @@ -1441,7 +1644,9 @@ impl RadrootsOutbox { let publication_affected = sqlx::query( "UPDATE outbox_phase1_publication SET state = 'dispatching', state_revision = state_revision + 1, updated_at_ms = ? - WHERE publication_id = ? AND state_revision = ? AND state IN ('signed-ready', 'dispatching')", + WHERE publication_id = ? AND state_revision = ? + AND state IN ('signed-ready', 'dispatching') + AND signed_event_json = ? AND signed_event_digest = ?", ) .bind(now_ms) .bind(publication_id) @@ -1449,6 +1654,8 @@ impl RadrootsOutbox { observed_publication_revision, "publication_revision", )?) + .bind(signed_event.signed_event().raw_json().as_bytes()) + .bind(signed_digest.as_slice()) .execute(&mut *transaction) .await? .rows_affected(); @@ -1464,7 +1671,7 @@ impl RadrootsOutbox { WHERE outbox_phase1_dispatch_intent.target_id = excluded.target_id AND outbox_phase1_dispatch_intent.signed_event_digest = excluded.signed_event_digest", ) - .bind(target.dispatch_digest.as_slice()) + .bind(dispatch_digest.as_slice()) .bind(target_id) .bind(signed_digest.as_slice()) .bind(now_ms) @@ -1479,6 +1686,10 @@ impl RadrootsOutbox { target_revision: observed_target_revision + 1, token, expires_at_ms, + signed_event, + endpoint_uri, + endpoint_fingerprint, + dispatch_digest, }) } @@ -1853,7 +2064,7 @@ async fn aggregate_publication_state( } async fn load_targets( - pool: &sqlx::SqlitePool, + transaction: &mut Transaction<'_, Sqlite>, publication_id: i64, ) -> Result<Vec<RadrootsPhase1PublicationTarget>, RadrootsPhase1PublicationError> { let rows = sqlx::query( @@ -1866,7 +2077,7 @@ async fn load_targets( ) .bind(i64::try_from(RADROOTS_PHASE1_PUBLICATION_TARGET_URI_MAX_BYTES).unwrap_or(i64::MAX)) .bind(publication_id) - .fetch_all(pool) + .fetch_all(&mut **transaction) .await?; if rows.len() > RADROOTS_PHASE1_PUBLICATION_TARGET_MAX_COUNT { return Err(RadrootsPhase1PublicationError::TargetCount { @@ -2173,6 +2384,26 @@ fn validate_diagnostic(value: &str) -> Result<(), RadrootsPhase1PublicationError } } +fn is_persisted_authority_error(error: &RadrootsPhase1PublicationError) -> bool { + matches!( + error, + RadrootsPhase1PublicationError::ArtifactInvalid { .. } + | RadrootsPhase1PublicationError::ReadinessInvalid { .. } + | RadrootsPhase1PublicationError::TargetCount { .. } + | RadrootsPhase1PublicationError::RequiredTargetCount { .. } + | RadrootsPhase1PublicationError::TargetUriTooLarge { .. } + | RadrootsPhase1PublicationError::TargetUriInvalid + | RadrootsPhase1PublicationError::DuplicateTarget + | RadrootsPhase1PublicationError::SignedEventMismatch + | RadrootsPhase1PublicationError::SignedEventInvalid + | RadrootsPhase1PublicationError::StoredValueTooLarge { .. } + | RadrootsPhase1PublicationError::StoredDigestInvalid { .. } + | RadrootsPhase1PublicationError::StoredStateInvalid + | RadrootsPhase1PublicationError::StoredAuthorityInvalid + | RadrootsPhase1PublicationError::IntegerRange { .. } + ) +} + fn decode_hex32( value: &str, field: &'static str, @@ -2322,7 +2553,7 @@ mod tests { assert!(!transition.lease_predicate.is_empty()); assert!(!transition.durable_side_effect.is_empty()); } - assert_eq!(ids.len(), 25); + assert_eq!(ids.len(), 28); } #[test] @@ -2658,6 +2889,230 @@ mod tests { } #[tokio::test] + async fn phase1_publication_signing_preflight_seals_exact_authority_and_claim() { + let outbox = RadrootsOutbox::open_memory().await.unwrap(); + let ready = ready_update(); + let receipt = outbox + .enqueue_phase1_publication( + &ready, + &RadrootsPhase1PublicationTargetPolicy::new(["wss://relay.example"], 1).unwrap(), + 10, + ) + .await + .unwrap(); + let claim = outbox + .claim_phase1_publication_for_signing( + receipt.record().publication_id(), + receipt.record().revision(), + 20, + 100, + ) + .await + .unwrap(); + let preflight = outbox + .preflight_phase1_publication_signing(&claim, 21) + .await + .unwrap(); + let artifact = ready.artifact(); + assert_eq!(preflight.publication_id(), claim.publication_id()); + assert_eq!(preflight.revision(), claim.revision()); + assert_eq!(preflight.expires_at_ms(), claim.expires_at_ms()); + assert_eq!(preflight.ready_artifact(), &ready); + assert_eq!( + preflight.ready_artifact().artifact().event_contract_id(), + artifact.event_contract_id() + ); + assert_eq!( + preflight.ready_artifact().artifact().expected_author(), + artifact.expected_author() + ); + assert_eq!( + preflight.ready_artifact().artifact().expected_event_id(), + artifact.expected_event_id() + ); + + assert_eq!( + outbox + .preflight_phase1_publication_signing(&claim, claim.expires_at_ms()) + .await + .unwrap_err() + .code(), + "phase1_publication_claim_invalid" + ); + sqlx::query( + "UPDATE outbox_phase1_publication SET readiness_digest = zeroblob(32) WHERE publication_id = ?", + ) + .bind(claim.publication_id()) + .execute(outbox.pool()) + .await + .unwrap(); + assert_eq!( + outbox + .preflight_phase1_publication_signing(&claim, 22) + .await + .unwrap_err() + .code(), + "phase1_publication_stored_authority_invalid" + ); + } + + #[tokio::test] + async fn phase1_publication_signing_completion_is_claim_fenced_after_preflight() { + let outbox = RadrootsOutbox::open_memory().await.unwrap(); + let ready = ready_update(); + let receipt = outbox + .enqueue_phase1_publication( + &ready, + &RadrootsPhase1PublicationTargetPolicy::new(["wss://relay.example"], 1).unwrap(), + 10, + ) + .await + .unwrap(); + let claim = outbox + .claim_phase1_publication_for_signing( + receipt.record().publication_id(), + receipt.record().revision(), + 20, + 100, + ) + .await + .unwrap(); + let preflight = outbox + .preflight_phase1_publication_signing(&claim, 21) + .await + .unwrap(); + let renewed = outbox + .renew_phase1_publication_claim(&claim, 22, 100) + .await + .unwrap(); + assert_eq!( + outbox + .complete_phase1_publication_signing(&preflight, &signed_update(&ready), 23) + .await + .unwrap_err() + .code(), + "phase1_publication_claim_invalid" + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM outbox_phase1_publication WHERE publication_id = ? AND signed_event_json IS NOT NULL", + ) + .bind(renewed.publication_id()) + .fetch_one(outbox.pool()) + .await + .unwrap(), + 0 + ); + + let renewed_preflight = outbox + .preflight_phase1_publication_signing(&renewed, 24) + .await + .unwrap(); + sqlx::query( + "UPDATE outbox_phase1_publication SET readiness_digest = zeroblob(32) WHERE publication_id = ?", + ) + .bind(renewed.publication_id()) + .execute(outbox.pool()) + .await + .unwrap(); + assert_eq!( + outbox + .complete_phase1_publication_signing( + &renewed_preflight, + &signed_update(&ready), + 25, + ) + .await + .unwrap_err() + .code(), + "phase1_publication_claim_invalid" + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM outbox_phase1_publication WHERE publication_id = ? AND signed_event_json IS NOT NULL", + ) + .bind(renewed.publication_id()) + .fetch_one(outbox.pool()) + .await + .unwrap(), + 0 + ); + } + + #[tokio::test] + async fn phase1_publication_signing_corruption_quarantines_before_target_claim() { + let outbox = RadrootsOutbox::open_memory().await.unwrap(); + let ready = ready_update(); + let receipt = outbox + .enqueue_phase1_publication( + &ready, + &RadrootsPhase1PublicationTargetPolicy::new(["wss://relay.example"], 1).unwrap(), + 10, + ) + .await + .unwrap(); + let claim = outbox + .claim_phase1_publication_for_signing( + receipt.record().publication_id(), + receipt.record().revision(), + 20, + 100, + ) + .await + .unwrap(); + let preflight = outbox + .preflight_phase1_publication_signing(&claim, 21) + .await + .unwrap(); + let signed = outbox + .complete_phase1_publication_signing(&preflight, &signed_update(&ready), 22) + .await + .unwrap(); + let target = &signed.targets()[0]; + let mut corrupted = signed + .signed_event() + .unwrap() + .signed_event() + .raw_json() + .as_bytes() + .to_vec(); + corrupted[0] = b'['; + sqlx::query( + "UPDATE outbox_phase1_publication SET signed_event_json = ? WHERE publication_id = ?", + ) + .bind(&corrupted) + .bind(signed.publication_id()) + .execute(outbox.pool()) + .await + .unwrap(); + + assert_eq!( + outbox + .claim_phase1_publication_target( + signed.publication_id(), + signed.revision(), + target.target_id(), + target.revision(), + 23, + 100, + ) + .await + .unwrap_err() + .code(), + "phase1_publication_stored_authority_invalid" + ); + let persisted: (String, Vec<u8>) = sqlx::query_as( + "SELECT state, signed_event_json FROM outbox_phase1_publication WHERE publication_id = ?", + ) + .bind(signed.publication_id()) + .fetch_one(outbox.pool()) + .await + .unwrap(); + assert_eq!(persisted.0, "quarantined"); + assert_eq!(persisted.1, corrupted); + } + + #[tokio::test] async fn phase1_publication_signed_dispatch_and_observation_repair_are_durable() { let outbox = RadrootsOutbox::open_memory().await.unwrap(); let ready = ready_update(); @@ -2682,9 +3137,14 @@ mod tests { ) .await .unwrap(); - let signed = signed_update(&ready); + let preflight = outbox + .preflight_phase1_publication_signing(&claim, 101) + .await + .unwrap(); + assert_eq!(preflight.ready_artifact(), &ready); + let signed = signed_update(preflight.ready_artifact()); let signed_record = outbox - .complete_phase1_publication_signing(&claim, &signed, 102) + .complete_phase1_publication_signing(&preflight, &signed, 102) .await .unwrap(); assert_eq!( @@ -2711,6 +3171,16 @@ mod tests { ) .await .unwrap(); + assert_eq!( + target_claim.signed_event().signed_event().raw_json(), + signed.signed_event().raw_json() + ); + assert_eq!(target_claim.endpoint_uri(), target.endpoint_uri()); + assert_eq!( + target_claim.endpoint_fingerprint(), + target.endpoint_fingerprint() + ); + assert_eq!(target_claim.dispatch_digest(), target.dispatch_digest()); let pending = outbox .complete_phase1_target_accepted_pending(&target_claim, 104) .await diff --git a/crates/transport_nostr/Cargo.toml b/crates/transport_nostr/Cargo.toml @@ -54,6 +54,7 @@ radroots_outbox = { workspace = true, optional = true, default-features = false, ] } radroots_transport = { workspace = true, default-features = false } futures = { workspace = true } +hex = { workspace = true } nostr = { workspace = true } serde = { workspace = true, features = ["derive", "std"] } serde_json = { workspace = true, features = ["std"] } @@ -62,6 +63,10 @@ tokio = { workspace = true, optional = true, features = ["rt"] } url = { workspace = true } [dev-dependencies] +radroots_authority = { workspace = true, features = ["local_signer"] } +radroots_blossom = { workspace = true, features = ["serde", "std"] } +radroots_event_codec = { workspace = true, features = ["serde_json"] } +sha2 = { workspace = true } tokio = { workspace = true, features = ["macros", "rt"] } [lints.rust] diff --git a/crates/transport_nostr/README b/crates/transport_nostr/README @@ -3,6 +3,14 @@ Deterministic Nostr relay transport substrate for exact signed-event publish, fetch ingest, and outbox delivery target coordination. +Phase 1 outbox publication preserves and retries the exact signature-verified +NIP-01 event JSON object bytes persisted by signing. Its stable dispatch +identity excludes attempts, leases, clocks, and process identity. This byte +identity does not extend to WebSocket frames: an adapter may reserialize the +event while constructing `["EVENT", <event>]`, and WebSocket fragmentation is +not protocol identity. Captured-adapter conformance therefore proves exact +decoded event-object bytes, not identical frame bytes. + Every fetch path verifies the NIP-01 id and signature before filter matching, unique-event budgeting, or returning an event. Repeated event ids preserve per-relay observation evidence without consuming the unique-event limit. The diff --git a/crates/transport_nostr/src/lib.rs b/crates/transport_nostr/src/lib.rs @@ -28,7 +28,9 @@ pub use fetch::{ #[cfg(feature = "storage")] pub use outbox::{ RadrootsOutboxPublishPolicy, RadrootsOutboxPublishReceipt, RadrootsOutboxPublishTargetReceipt, - publish_claimed_outbox_event, publish_claimed_outbox_event_with_transport, + phase1_publication_delivery_request, publish_claimed_outbox_event, + publish_claimed_outbox_event_with_transport, + publish_claimed_phase1_publication_target_with_transport, }; pub use outcome::{RadrootsRelayOutcome, RadrootsRelayOutcomeKind}; #[cfg(feature = "client")] diff --git a/crates/transport_nostr/src/outbox.rs b/crates/transport_nostr/src/outbox.rs @@ -15,6 +15,7 @@ use radroots_event_store::{ use radroots_outbox::{ RadrootsOutbox, RadrootsOutboxClaimedEvent, RadrootsOutboxDeliveryTargetRecord, RadrootsOutboxDeliveryTargetStatus, RadrootsOutboxEventStoreIngestReceipt, + RadrootsPhase1PublicationTargetClaim, }; use radroots_transport::{ RadrootsTransport, RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest, @@ -77,6 +78,53 @@ pub struct RadrootsOutboxPublishTargetReceipt { pub outcome: RadrootsRelayOutcome, } +pub fn phase1_publication_delivery_request( + claim: &RadrootsPhase1PublicationTargetClaim, + now_ms: i64, +) -> Result<RadrootsTransportDeliveryRequest, RadrootsRelayTransportError> { + ensure_nonnegative_timestamp("now_ms", now_ms)?; + let payload = verified_signed_event_payload(claim.signed_event()) + .map_err(transport_error_to_relay_error)?; + let target = RadrootsTransportTarget::nostr_relay(claim.endpoint_uri()) + .map_err(transport_error_to_relay_error)?; + let target_set = + RadrootsTransportTargetSet::new(vec![target]).map_err(transport_error_to_relay_error)?; + RadrootsTransportDeliveryRequest::new( + hex::encode(claim.dispatch_digest()), + payload, + target_set, + RadrootsTransportSatisfactionPolicy::all_accepted(), + ) + .and_then(|request| request.try_with_now_ms(now_ms)) + .map_err(transport_error_to_relay_error) +} + +pub async fn publish_claimed_phase1_publication_target_with_transport<T>( + transport: &T, + claim: &RadrootsPhase1PublicationTargetClaim, + now_ms: i64, +) -> Result<RadrootsTransportDeliveryReceipt, RadrootsRelayTransportError> +where + T: RadrootsTransport + ?Sized, +{ + let transport_kind = transport.transport_kind(); + if transport_kind != RadrootsTransportKind::Nostr { + return Err(RadrootsRelayTransportError::UnexpectedTransportKind { + expected: "nostr", + actual: transport_kind.canonical_label(), + }); + } + let request = phase1_publication_delivery_request(claim, now_ms)?; + let receipt = transport + .deliver(request.clone()) + .await + .map_err(transport_error_to_relay_error)?; + receipt + .validate_for_request(&request) + .map_err(transport_error_to_relay_error)?; + Ok(receipt) +} + pub async fn publish_claimed_outbox_event<A>( outbox: &RadrootsOutbox, event_store: &RadrootsEventStore, diff --git a/crates/transport_nostr/tests/phase1_outbox_publication.rs b/crates/transport_nostr/tests/phase1_outbox_publication.rs @@ -0,0 +1,523 @@ +#![cfg(all(feature = "storage", feature = "runtime-tokio"))] + +use core::cell::Cell; + +use radroots_authority::{ + RadrootsActorContext, RadrootsEventSigner, RadrootsLocalEventSigner, + RadrootsPhase1PublicationSigner, RadrootsSignerError, sign_authorized_phase1_publication, +}; +use radroots_blossom::{ + RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomByteVerifiedDescriptor, + RadrootsBlossomMediaType, RadrootsBlossomPublicationReadinessEvidence, RadrootsBlossomSha256, +}; +use radroots_event::{ + RadrootsAuthoredImage, + calendar::{ + RadrootsAuthoredCalendarDateEvent, RadrootsAuthoredCalendarTimeEvent, RadrootsCalendarDate, + }, + contract::event_contract, + draft::{RadrootsEventDraft, RadrootsSignedEvent}, + food_availability::{ + RadrootsFoodAvailabilityDetails, RadrootsFoodAvailabilityDetailsParts, + RadrootsFoodAvailabilityImage, RadrootsFoodAvailabilityStatus, RadrootsFoodContent, + RadrootsFoodCurrency, RadrootsFoodIdentifier, RadrootsFoodImageDimensions, + RadrootsFoodPrice, RadrootsFoodPublishedAt, RadrootsFoodQuantity, RadrootsFoodText, + RadrootsFoodUnit, + }, + ids::{RadrootsEventId, RadrootsPublicKey}, + post::{ + RadrootsAuthoredAsk, RadrootsAuthoredPhotoUpdate, RadrootsAuthoredPostImage, + RadrootsAuthoredUpdate, RadrootsPostImageDimensions, + }, + profile::{RadrootsAuthoredProfile, RadrootsNip05Identifier}, +}; +use radroots_event_codec::wire::publication::{ + RadrootsPhase1MediaReadyPublicationArtifact, RadrootsPhase1PublicationArtifact, + RadrootsPhase1PublicationDraft, RadrootsPhase1PublicationMediaReference, + allowlist::allow_phase1_publication_artifact, bind_phase1_publication_media_readiness, +}; +use radroots_nostr::prelude::{RadrootsNostrKeys, RadrootsNostrSecretKey}; +use radroots_outbox::{RadrootsOutbox, RadrootsPhase1PublicationTargetPolicy}; +use radroots_transport::{RadrootsTransportDeliveryTargetStatus, RadrootsTransportPayload}; +use radroots_transport_nostr::{ + RadrootsMockRelayPublishAdapter, RadrootsNostrTransport, RadrootsRelayOutcome, + phase1_publication_delivery_request, publish_claimed_phase1_publication_target_with_transport, +}; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +const SECRET_KEY: &str = "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; +const PUBLIC_KEY: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; +const CREATED_AT: u64 = 1_784_347_200; +const RELAY: &str = "wss://relay.example"; + +struct CountingPhase1Signer { + inner: RadrootsLocalEventSigner, + invocations: Cell<usize>, +} + +impl CountingPhase1Signer { + fn fixture() -> Self { + let secret = RadrootsNostrSecretKey::from_hex(SECRET_KEY).unwrap(); + Self { + inner: RadrootsLocalEventSigner::new(RadrootsNostrKeys::new(secret)).unwrap(), + invocations: Cell::new(0), + } + } + + fn invocations(&self) -> usize { + self.invocations.get() + } +} + +impl RadrootsEventSigner for CountingPhase1Signer { + fn pubkey(&self) -> &RadrootsPublicKey { + self.inner.pubkey() + } + + fn sign_frozen_draft( + &self, + draft: &RadrootsEventDraft, + ) -> Result<RadrootsSignedEvent, RadrootsSignerError> { + self.inner.sign_frozen_draft(draft) + } +} + +impl RadrootsPhase1PublicationSigner for CountingPhase1Signer { + fn sign_phase1_publication_draft( + &self, + draft: &RadrootsPhase1PublicationDraft, + expected_pubkey: &RadrootsPublicKey, + expected_event_id: &RadrootsEventId, + ) -> Result<RadrootsSignedEvent, RadrootsSignerError> { + self.invocations + .set(self.invocations.get().saturating_add(1)); + self.inner + .sign_phase1_publication_draft(draft, expected_pubkey, expected_event_id) + } +} + +#[tokio::test] +async fn outbox_publication_all_seven_leaves_reuse_exact_bytes_and_dispatch_identity() { + let outbox = RadrootsOutbox::open_memory().await.unwrap(); + let signer = CountingPhase1Signer::fixture(); + let policy = RadrootsPhase1PublicationTargetPolicy::new([RELAY], 1).unwrap(); + let ready_artifacts = all_ready_artifacts(); + assert_eq!( + ready_artifacts + .iter() + .map(|ready| ready.artifact().semantic_variant().as_str()) + .collect::<Vec<_>>(), + [ + "profile", + "update", + "photo_update", + "ask", + "event_date", + "event_time", + "food_availability", + ] + ); + + for (index, ready) in ready_artifacts.iter().enumerate() { + let base = 1_000_i64 + i64::try_from(index).unwrap() * 1_000; + let enqueue = outbox + .enqueue_phase1_publication(ready, &policy, base) + .await + .unwrap(); + let signing_claim = outbox + .claim_phase1_publication_for_signing( + enqueue.record().publication_id(), + enqueue.record().revision(), + base + 1, + 100, + ) + .await + .unwrap(); + let preflight = outbox + .preflight_phase1_publication_signing(&signing_claim, base + 2) + .await + .unwrap(); + let contract = event_contract(ready.artifact().event_contract_id()).unwrap(); + let actor = RadrootsActorContext::test(PUBLIC_KEY, [contract.author_role]).unwrap(); + let verified = + sign_authorized_phase1_publication(&actor, &signer, preflight.ready_artifact()) + .unwrap(); + assert_eq!(signer.invocations(), index + 1); + let exact_raw = verified.signed_event().raw_json().to_owned(); + let signed = outbox + .complete_phase1_publication_signing(&preflight, &verified, base + 3) + .await + .unwrap(); + assert_eq!( + signed.signed_event().unwrap().signed_event().raw_json(), + exact_raw + ); + + let target = &signed.targets()[0]; + let first_claim = outbox + .claim_phase1_publication_target( + signed.publication_id(), + signed.revision(), + target.target_id(), + target.revision(), + base + 4, + 100, + ) + .await + .unwrap(); + let first_request = phase1_publication_delivery_request(&first_claim, base + 5).unwrap(); + assert_eq!( + first_request.request_id(), + hex::encode(first_claim.dispatch_digest()) + ); + assert_payload_exact(first_request.payload(), &exact_raw); + + let first_adapter = RadrootsMockRelayPublishAdapter::new().with_outcome( + RELAY, + RadrootsRelayOutcome::connection_failed("relay unavailable"), + ); + let first_transport = RadrootsNostrTransport::new(first_adapter.clone()); + let first_receipt = publish_claimed_phase1_publication_target_with_transport( + &first_transport, + &first_claim, + base + 5, + ) + .await + .unwrap(); + assert_eq!( + first_receipt.target_receipts()[0].status, + RadrootsTransportDeliveryTargetStatus::FailedRetryable + ); + assert_eq!( + first_adapter.captured_raw_events().as_slice(), + core::slice::from_ref(&exact_raw) + ); + + let retryable = outbox + .fail_phase1_target_retryable(&first_claim, base + 6, base + 7, "relay unavailable") + .await + .unwrap(); + let retry_target = retryable + .targets() + .iter() + .find(|candidate| candidate.target_id() == first_claim.target_id()) + .unwrap(); + let retry_claim = outbox + .claim_phase1_publication_target( + retryable.publication_id(), + retryable.revision(), + retry_target.target_id(), + retry_target.revision(), + base + 7, + 100, + ) + .await + .unwrap(); + let retry_request = phase1_publication_delivery_request(&retry_claim, base + 8).unwrap(); + assert_eq!(retry_request.request_id(), first_request.request_id()); + assert_eq!(retry_claim.dispatch_digest(), first_claim.dispatch_digest()); + assert_payload_exact(retry_request.payload(), &exact_raw); + + let retry_adapter = RadrootsMockRelayPublishAdapter::new(); + let retry_transport = RadrootsNostrTransport::new(retry_adapter.clone()); + let retry_receipt = publish_claimed_phase1_publication_target_with_transport( + &retry_transport, + &retry_claim, + base + 8, + ) + .await + .unwrap(); + assert_eq!( + retry_receipt.target_receipts()[0].status, + RadrootsTransportDeliveryTargetStatus::Accepted + ); + assert_eq!( + retry_adapter.captured_raw_events().as_slice(), + core::slice::from_ref(&exact_raw) + ); + outbox + .complete_phase1_target_accepted_observed(&retry_claim, base + 9) + .await + .unwrap(); + assert_eq!(signer.invocations(), index + 1, "retry must not re-sign"); + } +} + +fn assert_payload_exact(payload: &RadrootsTransportPayload, expected_raw: &str) { + let RadrootsTransportPayload::SignedEventJson { raw_json, .. } = payload else { + panic!("Phase 1 Nostr dispatch must carry signed event JSON"); + }; + assert_eq!(raw_json.as_bytes(), expected_raw.as_bytes()); +} + +fn all_ready_artifacts() -> Vec<RadrootsPhase1MediaReadyPublicationArtifact> { + all_artifacts() + .into_iter() + .map(|artifact| { + let dimensions = expected_dimensions(artifact.semantic_variant().as_str()); + let evidence = artifact + .media_references() + .iter() + .zip(dimensions) + .map(|(reference, dimensions)| evidence_for_reference(reference, dimensions)) + .collect::<Vec<_>>(); + bind_phase1_publication_media_readiness( + allow_phase1_publication_artifact(artifact).unwrap(), + evidence, + ) + .unwrap() + }) + .collect() +} + +fn all_artifacts() -> Vec<RadrootsPhase1PublicationArtifact> { + let picture = authored_image(b"profile-picture", "media.example", "png", "image/png"); + let banner = authored_image(b"profile-banner", "media.example", "webp", "image/webp"); + let profile = RadrootsAuthoredProfile::new("victoria-farm") + .unwrap() + .with_display_name("Victoria Farm") + .with_about("Seasonal produce from the Saanich Peninsula") + .with_picture(picture) + .with_banner(banner) + .with_nip05(RadrootsNip05Identifier::parse("farm@example.com").unwrap()) + .with_bot(false); + + let post_image = authored_post_image(b"ask-and-photo"); + let post_url = post_image.url().to_string(); + let photo = RadrootsAuthoredPhotoUpdate::new( + format!("Strawberries at the farm stand {post_url}"), + vec![post_image.clone()], + ) + .unwrap(); + let ask = RadrootsAuthoredAsk::new( + format!("When will strawberries be ready? {post_url}"), + vec![post_image], + ) + .unwrap(); + + let event_image = authored_image(b"farm-event", "events.example", "jpeg", "image/jpeg"); + let date = RadrootsAuthoredCalendarDateEvent::new( + "farmers-market-2026", + "Moss Street Farmers Market", + RadrootsCalendarDate::parse("2026-07-25").unwrap(), + ) + .unwrap() + .with_end(RadrootsCalendarDate::parse("2026-07-26").unwrap()) + .unwrap() + .with_description("Saturday market in Victoria") + .unwrap() + .with_locations(vec!["Victoria, BC".to_owned()]) + .unwrap() + .with_image(event_image.clone()) + .unwrap(); + let time = RadrootsAuthoredCalendarTimeEvent::new( + "farm-tour-2026", + "Saanich Farm Tour", + 1_785_003_600, + ) + .unwrap() + .with_end(1_785_007_200) + .unwrap() + .with_start_tzid("America/Vancouver") + .unwrap() + .with_description("A one-hour farm tour") + .unwrap() + .with_image(event_image) + .unwrap(); + + vec![ + RadrootsPhase1PublicationArtifact::from_profile(&profile, CREATED_AT, PUBLIC_KEY).unwrap(), + RadrootsPhase1PublicationArtifact::from_update( + &RadrootsAuthoredUpdate::new("Carrots harvested today").unwrap(), + CREATED_AT, + PUBLIC_KEY, + ) + .unwrap(), + RadrootsPhase1PublicationArtifact::from_photo_update(&photo, CREATED_AT, PUBLIC_KEY) + .unwrap(), + RadrootsPhase1PublicationArtifact::from_ask(&ask, CREATED_AT, PUBLIC_KEY).unwrap(), + RadrootsPhase1PublicationArtifact::from_calendar_date_event(&date, CREATED_AT, PUBLIC_KEY) + .unwrap(), + RadrootsPhase1PublicationArtifact::from_calendar_time_event(&time, CREATED_AT, PUBLIC_KEY) + .unwrap(), + RadrootsPhase1PublicationArtifact::from_food_availability( + &food_details(), + CREATED_AT, + PUBLIC_KEY, + ) + .unwrap(), + ] +} + +fn authored_post_image(bytes: &[u8]) -> RadrootsAuthoredPostImage { + let image = authored_image(bytes, "media.example", "webp", "image/webp"); + let hash = image.descriptor().sha256(); + let fallback = RadrootsBlossomBlobUrl::parse(&format!("https://backup.example/{hash}.webp")) + .unwrap() + .approve() + .unwrap(); + RadrootsAuthoredPostImage::new( + image, + RadrootsPostImageDimensions::new(1_200, 900).unwrap(), + "Fresh strawberries", + ) + .unwrap() + .try_with_fallback(fallback) + .unwrap() +} + +fn food_details() -> RadrootsFoodAvailabilityDetails { + let image = RadrootsFoodAvailabilityImage::new( + authored_image(b"nantes-carrots", "food.example", "png", "image/png"), + RadrootsFoodImageDimensions::new(1_200, 800).unwrap(), + ); + RadrootsFoodAvailabilityDetails::new(RadrootsFoodAvailabilityDetailsParts { + content: RadrootsFoodContent::new("Fresh Nantes carrots available this week.").unwrap(), + identifier: RadrootsFoodIdentifier::parse("nantes-carrots").unwrap(), + title: RadrootsFoodText::new("Nantes Carrots").unwrap(), + summary: RadrootsFoodText::new("Fresh bunches").unwrap(), + published_at: RadrootsFoodPublishedAt::new(CREATED_AT - 60).unwrap(), + location: RadrootsFoodText::new("Central Saanich, BC").unwrap(), + price: RadrootsFoodPrice::new( + "3", + RadrootsFoodCurrency::parse("CAD").unwrap(), + RadrootsFoodUnit::Pound, + ) + .unwrap(), + quantity: Some(RadrootsFoodQuantity::new("24", RadrootsFoodUnit::Pound).unwrap()), + status: RadrootsFoodAvailabilityStatus::Active, + images: vec![image], + }) + .unwrap() +} + +fn authored_image( + bytes: &[u8], + host: &str, + extension: &str, + media_type: &str, +) -> RadrootsAuthoredImage { + RadrootsAuthoredImage::try_from(verified_descriptor(bytes, host, extension, media_type)) + .unwrap() +} + +fn verified_descriptor( + bytes: &[u8], + host: &str, + extension: &str, + media_type: &str, +) -> RadrootsBlossomByteVerifiedDescriptor { + let sha256 = RadrootsBlossomSha256::digest(bytes); + let media_type = RadrootsBlossomMediaType::parse(media_type).unwrap(); + RadrootsBlossomBlobDescriptor::new( + RadrootsBlossomBlobUrl::parse(&format!("https://{host}/{sha256}.{extension}")).unwrap(), + sha256, + u64::try_from(bytes.len()).unwrap(), + media_type.clone(), + CREATED_AT, + ) + .unwrap() + .approve_reference() + .unwrap() + .verify_bytes(bytes, &media_type) + .unwrap() +} + +fn expected_dimensions(variant: &str) -> Vec<(u32, u32)> { + match variant { + "profile" => vec![(640, 640), (1_600, 600)], + "update" => Vec::new(), + "photo_update" | "ask" => vec![(1_200, 900), (1_200, 900)], + "event_date" | "event_time" => vec![(640, 480)], + "food_availability" => vec![(1_200, 800)], + _ => panic!("unknown Phase 1 publication variant {variant}"), + } +} + +#[derive(Serialize)] +struct EvidenceDimensionsWire { + width: u32, + height: u32, +} + +#[derive(Serialize)] +struct EvidenceWire<'a> { + schema_version: u32, + policy_version: u16, + url: &'a str, + sha256: String, + size: u64, + media_type: &'a str, + raster_format: &'a str, + dimensions: EvidenceDimensionsWire, + bud02_status: u16, + bud01_head_status: u16, + bud01_get_status: u16, + uploaded: u64, + evidence_digest: String, +} + +fn evidence_for_reference( + reference: &RadrootsPhase1PublicationMediaReference, + dimensions: (u32, u32), +) -> RadrootsBlossomPublicationReadinessEvidence { + let media_type = reference.media_type().as_str(); + let (raster_format, format_code) = match media_type { + "image/jpeg" => ("jpeg", 1), + "image/png" => ("png", 2), + "image/webp" => ("still_webp", 3), + _ => panic!("unsupported test MIME {media_type}"), + }; + let uploaded = 1_800_000_001_u64; + let wire = EvidenceWire { + schema_version: 1, + policy_version: 1, + url: reference.url().as_str(), + sha256: reference.sha256().to_hex(), + size: reference.size(), + media_type, + raster_format, + dimensions: EvidenceDimensionsWire { + width: dimensions.0, + height: dimensions.1, + }, + bud02_status: 201, + bud01_head_status: 200, + bud01_get_status: 200, + uploaded, + evidence_digest: evidence_digest(reference, format_code, dimensions, uploaded), + }; + RadrootsBlossomPublicationReadinessEvidence::from_canonical_json( + &serde_json::to_vec(&wire).unwrap(), + ) + .unwrap() +} + +fn evidence_digest( + reference: &RadrootsPhase1PublicationMediaReference, + format_code: u8, + dimensions: (u32, u32), + uploaded: u64, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"radroots.blossom.publication-readiness-evidence.v1\0"); + hasher.update(1_u16.to_be_bytes()); + update_length_prefixed(&mut hasher, reference.url().as_str().as_bytes()); + hasher.update(reference.sha256().as_bytes()); + hasher.update(reference.size().to_be_bytes()); + update_length_prefixed(&mut hasher, reference.media_type().as_str().as_bytes()); + hasher.update([format_code]); + hasher.update(dimensions.0.to_be_bytes()); + hasher.update(dimensions.1.to_be_bytes()); + hasher.update(201_u16.to_be_bytes()); + hasher.update(200_u16.to_be_bytes()); + hasher.update(200_u16.to_be_bytes()); + hasher.update(uploaded.to_be_bytes()); + hex::encode(hasher.finalize()) +} + +fn update_length_prefixed(hasher: &mut Sha256, bytes: &[u8]) { + hasher.update(u64::try_from(bytes.len()).unwrap().to_be_bytes()); + hasher.update(bytes); +} diff --git a/tools/xtask/src/contract/outbox_phase1_publication.rs b/tools/xtask/src/contract/outbox_phase1_publication.rs @@ -391,8 +391,8 @@ fn validate_identity(identity: &Identity, domain: &str, preimage: &[&str]) -> Re } fn validate_transitions(descriptor: &Descriptor) -> Result<(), String> { - if descriptor.transitions.len() != 25 { - return Err("Phase 1 publication transition inventory must contain 25 entries".to_owned()); + if descriptor.transitions.len() != 28 { + return Err("Phase 1 publication transition inventory must contain 28 entries".to_owned()); } let event_states = descriptor.event_states.iter().collect::<BTreeSet<_>>(); let target_states = descriptor.target_states.iter().collect::<BTreeSet<_>>(); @@ -565,7 +565,7 @@ fn manifest_schema() -> Value { "properties": { "event_state_count": { "const": 9 }, "target_state_count": { "const": 8 }, - "transition_count": { "const": 25 }, + "transition_count": { "const": 28 }, "stable_error_count": { "const": 25 } } },