myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

commit 3ed323e64ca64a8973e06623a583e1ca8f17b9e3
parent c0771042391d582f8318fcc44078376ef609d3e9
Author: triesap <tyson@radroots.org>
Date:   Sun, 23 Aug 2026 09:22:56 +0000

state: add offline configuration lifecycle

- add the immutable schema-v10 configuration binding ledger
- make offline apply atomic, bounded, and replay-safe
- revoke stale connection authority on governed policy changes
- bind release metadata, source lock, tests, docs, and public API

Diffstat:
MAGENTS.md | 9+++++++++
MCargo.toml | 2+-
MREADME | 22+++++++++++++++++++---
Mcontracts/api_baselines/myc.txt | 32++++++++++++++++++++++++++++++++
Mcontracts/services_hardening/native_release.v1.json | 2+-
Mradroots.service.source-lock.v2.toml | 2+-
Msrc/lib.rs | 11+++++++++--
Msrc/state_catalog.rs | 205+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Asrc/state_config.rs | 608+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/state_host.rs | 25+++++++++++++++----------
Msrc/state_metadata.rs | 4++--
Msrc/state_repository.rs | 179++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mtests/build_policy.rs | 2+-
Mtests/package_boundary.rs | 14+++++++++++++-
Atests/services_hardening_config_lifecycle.rs | 826+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/services_hardening_native_release.rs | 4++--
Mtests/services_hardening_state_catalog.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++------------
Mtests/services_hardening_state_repository.rs | 11+++++++++--
18 files changed, 1949 insertions(+), 69 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -132,6 +132,15 @@ task. Keep connection-count keys and identity roles closed, preserve the last valid snapshot on any failed publication, admit only the fixed twelve status reasons, and keep detailed status on the permissioned Unix-admin boundary. +- Step 159 unit 10 owns schema-v10 offline configuration lifecycle. Keep the + configuration-binding ledger append-only and capped at 1,024 generations; + seed one post-migration generation without rewriting the immutable birth + record. Startup must match the latest config/public-identity binding. Identity + changes revoke live connection/challenge authority, permission narrowing + revokes affected sessions only, and an existing relay referenced by + nonterminal delivery work cannot be removed or changed. Do not persist relay + URLs, paths, credentials, provider envelopes, or protected values in the + binding history. - Treat checked-in source, tests, and prototype behavior as implementation evidence, not permission to preserve behavior that the active requirement removes. diff --git a/Cargo.toml b/Cargo.toml @@ -18,7 +18,7 @@ service = "myc" host_feature_profile = "service-host" nix_material = "deferred" config_contract_version = 1 -state_contract_version = 9 +state_contract_version = 10 admin_contract_version = 1 status_contract_version = 1 provider_contract_version = 1 diff --git a/README b/README @@ -151,11 +151,27 @@ without changing the canonical common artifact inventory. Create-new initialization reserves the shared schema-v1 metadata and migration ledger, retains exclusive writer authority, applies the exact Myc schema-v2 -through schema-v9 migrations, binds the normalized configuration, expected +through schema-v10 migrations, binds the normalized configuration, expected identity roles, and policy versions through a sealed typed repository, and explicitly closes the host before reporting success. Existing writable open can -resume any exact v1 through v8 prefix; read-only inspection requires the current -catalog and exact immutable Myc binding. +resume any exact v1 through v9 prefix; read-only inspection requires the current +catalog and exact latest Myc binding. + +Schema v10 adds an append-only configuration-binding history capped at exactly +1,024 generations. Generation 1 is seeded only after the v10 migration commits, +including for an upgraded v9 database, while the immutable original birth +record remains unchanged. `apply_configuration` is admitted only through an +exclusive writable host and independently validates the retained current and +candidate documents before one atomic append. A changed identity expires live +sessions and pending challenges; permission narrowing expires only sessions +whose retained grants are removed. Removing or changing a relay that is still +referenced by nonterminal delivery work fails closed, while safe relay additions +remain admissible. Exact replay returns the retained generation without another +append or revocation, including after an ambiguous caller result. Future startup +must present the latest normalized config and public-identity binding. The +history stores only digests, public identities, +closed contract versions, injected application evidence, and safe build +identity; it stores no credentials, provider envelopes, paths, or relay URLs. Schema v8 adds immutable NIP-46 operation-completion evidence. The Step 147 integration checkpoint binds each durable request to its stable operation and diff --git a/contracts/api_baselines/myc.txt b/contracts/api_baselines/myc.txt @@ -137,6 +137,16 @@ pub myc::MycCommandV1::Identity(myc::MycIdentityCommandV1) pub myc::MycCommandV1::Run pub myc::MycCommandV1::State(myc::MycStateCommandV1) pub myc::MycCommandV1::Status +pub enum myc::MycConfigApplyErrorKind +pub myc::MycConfigApplyErrorKind::Binding +pub myc::MycConfigApplyErrorKind::CommitOutcomeUnknown +pub myc::MycConfigApplyErrorKind::InvalidInput +pub myc::MycConfigApplyErrorKind::InvalidMode +pub myc::MycConfigApplyErrorKind::PolicyConflict +pub myc::MycConfigApplyErrorKind::ResourceExhausted +pub myc::MycConfigApplyErrorKind::Transaction +impl myc::MycConfigApplyErrorKind +pub const fn myc::MycConfigApplyErrorKind::code(self) -> &'static str pub enum myc::MycConfigCommandV1 pub myc::MycConfigCommandV1::Init pub myc::MycConfigCommandV1::Schema @@ -920,6 +930,22 @@ impl core::fmt::Debug for myc::MycCliV1Error pub fn myc::MycCliV1Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::fmt::Display for myc::MycCliV1Error pub fn myc::MycCliV1Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycConfigApplyError +impl myc::MycConfigApplyError +pub const fn myc::MycConfigApplyError::code(self) -> &'static str +pub const fn myc::MycConfigApplyError::kind(self) -> myc::MycConfigApplyErrorKind +impl core::error::Error for myc::MycConfigApplyError +impl core::fmt::Debug for myc::MycConfigApplyError +pub fn myc::MycConfigApplyError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for myc::MycConfigApplyError +pub fn myc::MycConfigApplyError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct myc::MycConfigApplyOutcome +impl myc::MycConfigApplyOutcome +pub const fn myc::MycConfigApplyOutcome::generation(self) -> u16 +pub const fn myc::MycConfigApplyOutcome::revoked_challenge_count(self) -> u64 +pub const fn myc::MycConfigApplyOutcome::revoked_connection_count(self) -> u64 +impl core::fmt::Debug for myc::MycConfigApplyOutcome +pub fn myc::MycConfigApplyOutcome::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct myc::MycConfigDocumentV1 impl myc::MycConfigDocumentV1 pub const fn myc::MycConfigDocumentV1::effective(&self) -> &myc::MycEffectiveConfigV1 @@ -1847,6 +1873,8 @@ pub async fn myc::MycStateRepository<'_>::read_connection_decision(&self, myc::M impl myc::MycStateRepository<'_> pub async fn myc::MycStateRepository<'_>::admit_signer_request(&self, &myc::MycSignerRequest) -> core::result::Result<myc::MycSignerRequestAdmission, myc::MycStateRepositoryError> impl myc::MycStateRepository<'_> +pub async fn myc::MycStateRepository<'_>::apply_configuration(&self, &myc::MycConfigDocumentV1, &myc::MycConfigDocumentV1, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<myc::MycConfigApplyOutcome, myc::MycConfigApplyError> +impl myc::MycStateRepository<'_> pub async fn myc::MycStateRepository<'_>::claim_delivery_target(&self, myc::MycDeliveryJobId, &myc::MycDeliveryRelayId, myc::MycDeliveryAttemptNonce, myc::MycDeliveryTimeUnixMs) -> core::result::Result<myc::MycDeliveryClaim, myc::MycStateRepositoryError> pub async fn myc::MycStateRepository<'_>::mark_delivery_attempt_submitted(&self, myc::MycDeliveryJobId, &myc::MycDeliveryRelayId, myc::MycDeliveryAttemptId, myc::MycDeliveryTimeUnixMs) -> core::result::Result<myc::MycDeliveryAttemptRecord, myc::MycStateRepositoryError> pub async fn myc::MycStateRepository<'_>::read_delivery_attempts(&self, myc::MycDeliveryJobId, &myc::MycDeliveryRelayId) -> core::result::Result<alloc::boxed::Box<[myc::MycDeliveryAttemptRecord]>, myc::MycStateRepositoryError> @@ -2005,6 +2033,7 @@ pub const myc::MYC_AUDIT_PAGE_MAX_ITEMS: u16 pub const myc::MYC_AUDIT_RETENTION_MAX_MS: u64 pub const myc::MYC_AUTHORIZATION_CHALLENGE_URL_MAX_BYTES: usize pub const myc::MYC_COMPACTION_MAX_ROWS: u16 +pub const myc::MYC_CONFIG_BINDING_MAX_GENERATIONS: u16 pub const myc::MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES: usize pub const myc::MYC_CONFIG_SCHEMA: &str pub const myc::MYC_CONFIG_SCHEMA_VERSION: u32 @@ -2060,6 +2089,9 @@ pub const myc::MYC_STATE_APPLICATION_ID: u32 pub const myc::MYC_STATE_BASE_SCHEMA_VERSION: u32 pub const myc::MYC_STATE_SCHEMA_CATALOG_SHA256: [u8; 32] pub const myc::MYC_STATE_SCHEMA_VERSION: u32 +pub const myc::MYC_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256: [u8; 32] +pub const myc::MYC_STATE_SCHEMA_VERSION_10_OBJECT_COUNT: u32 +pub const myc::MYC_STATE_SCHEMA_VERSION_10_SHA256: [u8; 32] pub const myc::MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT: u32 pub const myc::MYC_STATE_SCHEMA_VERSION_1_SHA256: [u8; 32] pub const myc::MYC_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256: [u8; 32] diff --git a/contracts/services_hardening/native_release.v1.json b/contracts/services_hardening/native_release.v1.json @@ -32,7 +32,7 @@ }, "contract_versions": { "config": 1, - "state": 9, + "state": 10, "admin": 1, "status": 1, "provider": 1 diff --git a/radroots.service.source-lock.v2.toml b/radroots.service.source-lock.v2.toml @@ -18,7 +18,7 @@ flake_lock_sha256 = "90a03f6f0794f3f6556b1f2bf6700812d48ce8dc1cee7c4f8bc62cea69b [contract_versions] config = 1 -state = 9 +state = 10 admin = 1 status = 1 provider = 1 diff --git a/src/lib.rs b/src/lib.rs @@ -29,6 +29,7 @@ mod runtime_foundation; mod runtime_supervision; mod state_catalog; mod state_completion; +mod state_config; mod state_connection; mod state_delivery; mod state_discovery; @@ -162,13 +163,19 @@ pub use state_catalog::{ MYC_STATE_SCHEMA_VERSION_7_SHA256, MYC_STATE_SCHEMA_VERSION_8_MIGRATION_SHA256, MYC_STATE_SCHEMA_VERSION_8_OBJECT_COUNT, MYC_STATE_SCHEMA_VERSION_8_SHA256, MYC_STATE_SCHEMA_VERSION_9_MIGRATION_SHA256, MYC_STATE_SCHEMA_VERSION_9_OBJECT_COUNT, - MYC_STATE_SCHEMA_VERSION_9_SHA256, MycStateCatalogError, MycStateCatalogErrorKind, - myc_migration_catalog, myc_schema_catalog, validate_myc_state_catalogs, + MYC_STATE_SCHEMA_VERSION_9_SHA256, MYC_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256, + MYC_STATE_SCHEMA_VERSION_10_OBJECT_COUNT, MYC_STATE_SCHEMA_VERSION_10_SHA256, + MycStateCatalogError, MycStateCatalogErrorKind, myc_migration_catalog, myc_schema_catalog, + validate_myc_state_catalogs, }; pub use state_completion::{ MycNip46CommitAdmission, MycNip46CommitError, MycNip46CommitErrorKind, MycNip46CommitRecord, MycNip46CommitRequest, MycNip46SessionEffect, }; +pub use state_config::{ + MYC_CONFIG_BINDING_MAX_GENERATIONS, MycConfigApplyError, MycConfigApplyErrorKind, + MycConfigApplyOutcome, +}; pub use state_connection::{ MYC_AUTHORIZATION_CHALLENGE_URL_MAX_BYTES, MYC_CONNECTION_PERMISSION_MAX_COUNT, MycAuthorizationChallengeAdmission, MycAuthorizationChallengeAuthorization, diff --git a/src/state_catalog.rs b/src/state_catalog.rs @@ -12,7 +12,7 @@ use radroots_service_sqlite::{ pub const MYC_STATE_BASE_SCHEMA_VERSION: u32 = 1; /// The newest governed Myc state schema understood by this binary. -pub const MYC_STATE_SCHEMA_VERSION: u32 = 9; +pub const MYC_STATE_SCHEMA_VERSION: u32 = 10; /// The shared metadata and migration-ledger objects present at schema v1. pub const MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT: u32 = 6; @@ -41,6 +41,9 @@ pub const MYC_STATE_SCHEMA_VERSION_8_OBJECT_COUNT: u32 = 56; /// The shared objects plus immutable exact NIP-46 response authority. pub const MYC_STATE_SCHEMA_VERSION_9_OBJECT_COUNT: u32 = 59; +/// The shared objects plus the append-only configuration-binding history. +pub const MYC_STATE_SCHEMA_VERSION_10_OBJECT_COUNT: u32 = 63; + /// SHA-256 identity of the exact schema-v1 object snapshot. pub const MYC_STATE_SCHEMA_VERSION_1_SHA256: [u8; 32] = [ 0x94, 0xdc, 0x66, 0xfb, 0xca, 0x60, 0x16, 0x79, 0x61, 0x5c, 0x05, 0x52, 0x29, 0xdc, 0x0d, 0xb6, @@ -55,14 +58,14 @@ pub const MYC_STATE_SCHEMA_VERSION_2_SHA256: [u8; 32] = [ /// SHA-256 identity of the ordered Myc migration catalog. pub const MYC_MIGRATION_CATALOG_SHA256: [u8; 32] = [ - 0x86, 0x04, 0xc5, 0x1e, 0xa6, 0xf1, 0x6f, 0x0a, 0xa3, 0x7a, 0x63, 0xee, 0xe0, 0x9c, 0x60, 0x0a, - 0x0b, 0x70, 0x31, 0xef, 0xbc, 0x8e, 0x5e, 0x0e, 0x41, 0xb5, 0xf0, 0xf5, 0x3c, 0x48, 0xe7, 0x0b, + 0x5d, 0x6e, 0x0c, 0x8b, 0x83, 0x2e, 0x66, 0x71, 0x5a, 0xbe, 0x17, 0x61, 0x33, 0xd4, 0x43, 0x3d, + 0x52, 0xe6, 0xd1, 0x81, 0x25, 0xae, 0xfd, 0xbc, 0x9d, 0x55, 0x05, 0x15, 0xfd, 0x21, 0x21, 0xf2, ]; /// SHA-256 identity of the schema catalog bound to the migration catalog. pub const MYC_STATE_SCHEMA_CATALOG_SHA256: [u8; 32] = [ - 0x63, 0x2c, 0x8d, 0x52, 0x16, 0xd2, 0xa5, 0x41, 0xfd, 0x28, 0xae, 0x3a, 0x2c, 0xae, 0x80, 0x69, - 0xc5, 0x8b, 0xef, 0x11, 0xfe, 0x81, 0xd2, 0xf0, 0x43, 0x99, 0xa7, 0x7c, 0xf8, 0x35, 0x9e, 0xa7, + 0x1f, 0x88, 0xc7, 0x9f, 0x86, 0xae, 0x47, 0x24, 0x89, 0xf6, 0x2d, 0xdc, 0x96, 0x61, 0xa6, 0x81, + 0xdc, 0xfb, 0x1e, 0xd7, 0xff, 0x72, 0x3a, 0x07, 0xd9, 0x7b, 0xa7, 0x76, 0xb0, 0x36, 0xa2, 0x5a, ]; /// SHA-256 identity of the schema-v2 migration content. @@ -155,6 +158,18 @@ pub const MYC_STATE_SCHEMA_VERSION_9_SHA256: [u8; 32] = [ 0xc5, 0xf5, 0xef, 0xc4, 0xb6, 0xed, 0xd0, 0xac, 0x7f, 0x73, 0xca, 0xd8, 0x3a, 0x99, 0x1f, 0xf7, ]; +/// SHA-256 identity of the schema-v10 configuration-binding migration. +pub const MYC_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256: [u8; 32] = [ + 0x28, 0x42, 0x3e, 0xbb, 0x59, 0xf4, 0xb2, 0x62, 0x23, 0x30, 0x7b, 0x74, 0xa7, 0xe1, 0x29, 0x05, + 0xca, 0x48, 0x18, 0xc0, 0x1a, 0x65, 0x52, 0x03, 0xee, 0x8d, 0x63, 0xc9, 0x11, 0xf4, 0x44, 0x89, +]; + +/// SHA-256 identity of the schema-v10 object snapshot. +pub const MYC_STATE_SCHEMA_VERSION_10_SHA256: [u8; 32] = [ + 0xb7, 0x7e, 0xd2, 0x3a, 0xfa, 0x39, 0xff, 0x45, 0xdd, 0xa2, 0x50, 0xfa, 0xa1, 0xeb, 0xfc, 0x05, + 0x87, 0xc3, 0x44, 0x62, 0x65, 0x8f, 0xc4, 0x9f, 0xb7, 0xb2, 0xfb, 0xc8, 0x90, 0x9b, 0xa3, 0x03, +]; + /// SHA-256 identity of the Myc metadata table definition. const MYC_STATE_METADATA_TABLE_SHA256: [u8; 32] = [ 0x16, 0x17, 0x46, 0xa2, 0x26, 0x42, 0x46, 0x2f, 0x2b, 0xdb, 0x08, 0x5b, 0xae, 0xde, 0xb2, 0x3b, @@ -1716,6 +1731,118 @@ const CREATE_NIP46_ATOMIC_RESPONSE_MIGRATION_SQL: &str = concat!( nip46_signed_responses_no_delete_sql!(), ); +macro_rules! myc_config_bindings_table_sql { + () => { + r#"CREATE TABLE myc_config_bindings ( + generation INTEGER NOT NULL PRIMARY KEY CHECK (generation BETWEEN 1 AND 1024), + normalized_config_sha256 BLOB NOT NULL CHECK (length(normalized_config_sha256) = 32), + transport_public_key TEXT NOT NULL + CHECK (length(CAST(transport_public_key AS BLOB)) = 64) + CHECK (transport_public_key NOT GLOB '*[^0-9a-f]*'), + user_public_key TEXT NOT NULL + CHECK (length(CAST(user_public_key AS BLOB)) = 64) + CHECK (user_public_key NOT GLOB '*[^0-9a-f]*'), + discovery_public_key TEXT + CHECK (discovery_public_key IS NULL OR + (length(CAST(discovery_public_key AS BLOB)) = 64 + AND discovery_public_key NOT GLOB '*[^0-9a-f]*')), + config_contract_version INTEGER NOT NULL + CHECK (config_contract_version BETWEEN 1 AND 4294967295), + state_contract_version INTEGER NOT NULL + CHECK (state_contract_version BETWEEN 1 AND 4294967295), + operator_contract_version INTEGER NOT NULL + CHECK (operator_contract_version BETWEEN 1 AND 4294967295), + status_contract_version INTEGER NOT NULL + CHECK (status_contract_version BETWEEN 1 AND 4294967295), + applied_at_unix_s INTEGER NOT NULL + CHECK (applied_at_unix_s BETWEEN 0 AND 9223372036854775807), + service_version TEXT NOT NULL + CHECK (length(CAST(service_version AS BLOB)) BETWEEN 1 AND 128), + service_commit TEXT NOT NULL + CHECK (length(CAST(service_commit AS BLOB)) = 40), + lib_revision TEXT NOT NULL + CHECK (length(CAST(lib_revision AS BLOB)) = 40), + rust_version TEXT NOT NULL + CHECK (length(CAST(rust_version AS BLOB)) BETWEEN 1 AND 128), + target TEXT NOT NULL CHECK (length(CAST(target AS BLOB)) BETWEEN 1 AND 128), + feature_profile TEXT NOT NULL + CHECK (length(CAST(feature_profile AS BLOB)) BETWEEN 1 AND 128), + provider_contract_version INTEGER NOT NULL + CHECK (provider_contract_version BETWEEN 1 AND 4294967295) +) STRICT"# + }; +} + +macro_rules! myc_config_bindings_guard_insert_sql { + () => { + r#"CREATE TRIGGER myc_config_bindings_guard_insert +BEFORE INSERT ON myc_config_bindings +WHEN NEW.generation != COALESCE( + (SELECT MAX(generation) + 1 FROM myc_config_bindings), 1 + ) + OR (SELECT COUNT(*) FROM myc_config_bindings) >= 1024 + OR NEW.applied_at_unix_s < COALESCE( + (SELECT MAX(applied_at_unix_s) FROM myc_config_bindings), 0 + ) +BEGIN + SELECT RAISE(ABORT, 'configuration binding sequence is invalid'); +END"# + }; +} + +macro_rules! myc_config_bindings_no_update_sql { + () => { + r#"CREATE TRIGGER myc_config_bindings_no_update +BEFORE UPDATE ON myc_config_bindings +BEGIN + SELECT RAISE(ABORT, 'configuration binding history is immutable'); +END"# + }; +} + +macro_rules! myc_config_bindings_no_delete_sql { + () => { + r#"CREATE TRIGGER myc_config_bindings_no_delete +BEFORE DELETE ON myc_config_bindings +BEGIN + SELECT RAISE(ABORT, 'configuration binding history is retained'); +END"# + }; +} + +const CREATE_MYC_CONFIG_BINDINGS_TABLE_SQL: &str = myc_config_bindings_table_sql!(); +const CREATE_MYC_CONFIG_BINDINGS_GUARD_INSERT_SQL: &str = myc_config_bindings_guard_insert_sql!(); +const CREATE_MYC_CONFIG_BINDINGS_NO_UPDATE_SQL: &str = myc_config_bindings_no_update_sql!(); +const CREATE_MYC_CONFIG_BINDINGS_NO_DELETE_SQL: &str = myc_config_bindings_no_delete_sql!(); + +const CREATE_MYC_CONFIG_BINDINGS_MIGRATION_SQL: &str = concat!( + myc_config_bindings_table_sql!(), + ";\n", + myc_config_bindings_guard_insert_sql!(), + ";\n", + myc_config_bindings_no_update_sql!(), + ";\n", + myc_config_bindings_no_delete_sql!(), + ";", +); + +const MYC_CONFIG_BINDINGS_TABLE_SHA256: [u8; 32] = [ + 0xf7, 0x7a, 0x0b, 0xc4, 0x4a, 0xb0, 0xf9, 0x18, 0x09, 0xed, 0x6a, 0xb1, 0xaa, 0x0c, 0x9e, 0xd8, + 0x80, 0x4c, 0xac, 0x8f, 0x12, 0x15, 0xf1, 0x15, 0xd5, 0x7e, 0x1b, 0x42, 0xe4, 0x20, 0xf2, 0x60, +]; +const MYC_CONFIG_BINDINGS_GUARD_INSERT_SHA256: [u8; 32] = [ + 0x28, 0x4c, 0xc3, 0xa6, 0xe6, 0xb2, 0x42, 0x2c, 0x71, 0x42, 0xb9, 0x43, 0x2f, 0x67, 0x29, 0x20, + 0x4d, 0xa7, 0x03, 0xde, 0x21, 0xec, 0x8e, 0xbc, 0xe1, 0xc4, 0xda, 0x24, 0x60, 0x43, 0x5d, 0xce, +]; +const MYC_CONFIG_BINDINGS_NO_UPDATE_SHA256: [u8; 32] = [ + 0xba, 0xcc, 0x52, 0x41, 0x3e, 0x4b, 0xc2, 0x68, 0x01, 0xbc, 0xfd, 0xa0, 0x8b, 0xda, 0xdb, 0x1f, + 0x24, 0xd0, 0x6d, 0x86, 0xa1, 0x79, 0x72, 0xd2, 0x93, 0x6b, 0xcf, 0xfb, 0xdd, 0xc7, 0xa0, 0xe5, +]; +const MYC_CONFIG_BINDINGS_NO_DELETE_SHA256: [u8; 32] = [ + 0xb5, 0x2b, 0x12, 0xde, 0xec, 0xc3, 0x2b, 0xe8, 0x5e, 0x48, 0xa9, 0x91, 0xc1, 0x4b, 0xff, 0xc0, + 0x0d, 0xe9, 0xfc, 0x24, 0x44, 0x62, 0x83, 0xf3, 0x7c, 0x05, 0xa3, 0x52, 0xdf, 0xfa, 0xcf, 0x5c, +]; + const CONNECTIONS_TABLE_SHA256: [u8; 32] = [ 0x72, 0xd5, 0xd8, 0xba, 0x24, 0x68, 0x9c, 0x93, 0x34, 0xb3, 0x8f, 0xbf, 0x64, 0x21, 0xe1, 0x65, 0xfd, 0xc3, 0x80, 0x46, 0xf1, 0x3f, 0x56, 0x49, 0x3a, 0xef, 0xd7, 0x42, 0xc4, 0xe6, 0x49, 0x85, @@ -2064,6 +2191,13 @@ pub fn myc_migration_catalog() -> Result<MigrationCatalog, MycStateCatalogError> MigrationChecksum::from_bytes(MYC_STATE_SCHEMA_VERSION_9_MIGRATION_SHA256), ) .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::MigrationCatalog))?; + let configuration = MigrationDescriptor::sql( + 10, + "create_configuration_binding_history", + CREATE_MYC_CONFIG_BINDINGS_MIGRATION_SQL, + MigrationChecksum::from_bytes(MYC_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256), + ) + .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::MigrationCatalog))?; let catalog = MigrationCatalog::new([ metadata, requests, @@ -2073,10 +2207,11 @@ pub fn myc_migration_catalog() -> Result<MigrationCatalog, MycStateCatalogError> discovery, completion, response, + configuration, ]) .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::MigrationCatalog))?; if catalog.current_version() != MYC_STATE_SCHEMA_VERSION - || catalog.descriptors().len() != 8 + || catalog.descriptors().len() != 9 || catalog.digest().as_bytes() != &MYC_MIGRATION_CATALOG_SHA256 { return Err(MycStateCatalogError::new( @@ -2143,6 +2278,12 @@ pub fn myc_schema_catalog() -> Result<SchemaCatalog, MycStateCatalogError> { SchemaDigest::from_bytes(MYC_STATE_SCHEMA_VERSION_9_SHA256), ) .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?; + let version_ten = SchemaVersionCatalog::new( + 10, + myc_state_config_binding_objects()?, + SchemaDigest::from_bytes(MYC_STATE_SCHEMA_VERSION_10_SHA256), + ) + .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?; let catalog = SchemaCatalog::new( &migrations, [ @@ -2155,6 +2296,7 @@ pub fn myc_schema_catalog() -> Result<SchemaCatalog, MycStateCatalogError> { version_seven, version_eight, version_nine, + version_ten, ], ) .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog))?; @@ -2710,6 +2852,47 @@ fn myc_state_response_objects() -> Result<Vec<SchemaObject>, MycStateCatalogErro Ok(objects) } +fn myc_state_config_binding_objects() -> Result<Vec<SchemaObject>, MycStateCatalogError> { + let mut objects = myc_state_response_objects()?; + let object = |kind, name, sql, digest| { + SchemaObject::new( + kind, + name, + "myc_config_bindings", + sql, + SchemaDigest::from_bytes(digest), + ) + .map_err(|_| MycStateCatalogError::new(MycStateCatalogErrorKind::SchemaCatalog)) + }; + objects.extend([ + object( + SchemaObjectKind::Table, + "myc_config_bindings", + CREATE_MYC_CONFIG_BINDINGS_TABLE_SQL, + MYC_CONFIG_BINDINGS_TABLE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "myc_config_bindings_guard_insert", + CREATE_MYC_CONFIG_BINDINGS_GUARD_INSERT_SQL, + MYC_CONFIG_BINDINGS_GUARD_INSERT_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "myc_config_bindings_no_update", + CREATE_MYC_CONFIG_BINDINGS_NO_UPDATE_SQL, + MYC_CONFIG_BINDINGS_NO_UPDATE_SHA256, + )?, + object( + SchemaObjectKind::Trigger, + "myc_config_bindings_no_delete", + CREATE_MYC_CONFIG_BINDINGS_NO_DELETE_SQL, + MYC_CONFIG_BINDINGS_NO_DELETE_SHA256, + )?, + ]); + Ok(objects) +} + /// Independently validates exact catalog versions, counts, and digests. pub fn validate_myc_state_catalogs( migrations: &MigrationCatalog, @@ -2718,7 +2901,7 @@ pub fn validate_myc_state_catalogs( let versions = schema.versions(); let descriptors = migrations.descriptors(); let valid = migrations.current_version() == MYC_STATE_SCHEMA_VERSION - && descriptors.len() == 8 + && descriptors.len() == 9 && descriptors[0].target_version() == 2 && descriptors[0].name().as_str() == "create_myc_state_metadata" && descriptors[0].checksum().as_bytes() == &MYC_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256 @@ -2743,9 +2926,12 @@ pub fn validate_myc_state_catalogs( && descriptors[7].target_version() == 9 && descriptors[7].name().as_str() == "create_nip46_atomic_response" && descriptors[7].checksum().as_bytes() == &MYC_STATE_SCHEMA_VERSION_9_MIGRATION_SHA256 + && descriptors[8].target_version() == 10 + && descriptors[8].name().as_str() == "create_configuration_binding_history" + && descriptors[8].checksum().as_bytes() == &MYC_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256 && migrations.digest().as_bytes() == &MYC_MIGRATION_CATALOG_SHA256 && schema.migration_catalog_digest() == migrations.digest() - && versions.len() == 9 + && versions.len() == 10 && versions[0].version() == MYC_STATE_BASE_SCHEMA_VERSION && versions[0].object_count() == MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT && versions[0].digest().as_bytes() == &MYC_STATE_SCHEMA_VERSION_1_SHA256 @@ -2773,6 +2959,9 @@ pub fn validate_myc_state_catalogs( && versions[8].version() == 9 && versions[8].object_count() == MYC_STATE_SCHEMA_VERSION_9_OBJECT_COUNT && versions[8].digest().as_bytes() == &MYC_STATE_SCHEMA_VERSION_9_SHA256 + && versions[9].version() == 10 + && versions[9].object_count() == MYC_STATE_SCHEMA_VERSION_10_OBJECT_COUNT + && versions[9].digest().as_bytes() == &MYC_STATE_SCHEMA_VERSION_10_SHA256 && schema.digest().as_bytes() == &MYC_STATE_SCHEMA_CATALOG_SHA256; if valid { Ok(()) diff --git a/src/state_config.rs b/src/state_config.rs @@ -0,0 +1,608 @@ +//! Offline append-only configuration-binding lifecycle. + +use core::fmt; +use std::{ + collections::{BTreeMap, BTreeSet}, + error::Error, +}; + +use radroots_service_sqlite::{ + MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, ServiceSqliteTransaction, + ServiceSqliteTransactionError, ServiceSqliteTransactionErrorKind, +}; +use serde_json::Value; +use sqlx::{QueryBuilder, Row, Sqlite}; + +use crate::{ + MYC_PROVIDER_CONTRACT_VERSION, MycConfigDocumentV1, MycStateRepository, + state_repository::{ + PersistedMetadata, RepositoryOperationError, read_latest_config_binding, + require_expected_metadata, + }, +}; + +/// Maximum number of immutable configuration generations retained by one instance. +pub const MYC_CONFIG_BINDING_MAX_GENERATIONS: u16 = 1024; + +const READ_LATEST_HEADER_SQL: &str = r#"SELECT generation, applied_at_unix_s +FROM myc_config_bindings +ORDER BY generation DESC +LIMIT 1"#; + +const RELAY_HAS_NONTERMINAL_JOB_SQL: &str = r#"SELECT EXISTS ( + SELECT 1 + FROM delivery_targets AS target + JOIN delivery_jobs AS job ON job.job_id = target.job_id + WHERE target.relay_id = ? AND job.status IN ('pending', 'active') + LIMIT 1 +) AS is_blocked"#; + +const REVOKE_ACTIVE_CONNECTIONS_SQL: &str = r#"UPDATE connections +SET status = 'expired', updated_at_unix_ms = MAX(updated_at_unix_ms, ?), + authorized_until_unix_ms = NULL +WHERE status = 'active'"#; + +const DENY_PENDING_CONNECTIONS_SQL: &str = r#"UPDATE connections +SET status = 'denied', updated_at_unix_ms = MAX(updated_at_unix_ms, ?), + authorized_until_unix_ms = NULL +WHERE status = 'pending'"#; + +const EXPIRE_ALL_PENDING_CHALLENGES_SQL: &str = r#"UPDATE connection_auth_challenges +SET state = 'expired', + resolved_at_unix_ms = MAX(issued_at_unix_ms, ?) +WHERE state = 'pending'"#; + +const INSERT_CONFIG_BINDING_SQL: &str = r#"INSERT INTO myc_config_bindings ( + generation, normalized_config_sha256, transport_public_key, user_public_key, + discovery_public_key, config_contract_version, state_contract_version, + operator_contract_version, status_contract_version, applied_at_unix_s, + service_version, service_commit, lib_revision, rust_version, target, + feature_profile, provider_contract_version +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#; + +/// Stable offline configuration-application failure classes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycConfigApplyErrorKind { + InvalidMode, + InvalidInput, + Binding, + PolicyConflict, + ResourceExhausted, + Transaction, + CommitOutcomeUnknown, +} + +impl MycConfigApplyErrorKind { + /// Returns the stable machine-readable failure code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::InvalidMode => "config_apply_mode_invalid", + Self::InvalidInput => "config_apply_input_invalid", + Self::Binding => "config_apply_binding_invalid", + Self::PolicyConflict => "config_apply_policy_conflict", + Self::ResourceExhausted => "resource_exhausted", + Self::Transaction => "config_apply_transaction_failed", + Self::CommitOutcomeUnknown => "config_apply_commit_outcome_unknown", + } + } +} + +/// Source-free offline configuration-application failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycConfigApplyError { + kind: MycConfigApplyErrorKind, +} + +impl MycConfigApplyError { + const fn new(kind: MycConfigApplyErrorKind) -> Self { + Self { kind } + } + + /// Returns the stable failure class. + #[must_use] + pub const fn kind(self) -> MycConfigApplyErrorKind { + self.kind + } + + /// Returns the stable machine-readable failure code. + #[must_use] + pub const fn code(self) -> &'static str { + self.kind.code() + } +} + +impl fmt::Display for MycConfigApplyError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + MycConfigApplyErrorKind::InvalidMode => { + "Myc configuration apply requires an offline writable state host" + } + MycConfigApplyErrorKind::InvalidInput => "Myc configuration apply evidence is invalid", + MycConfigApplyErrorKind::Binding => "Myc configuration history binding is invalid", + MycConfigApplyErrorKind::PolicyConflict => { + "Myc configuration change conflicts with retained work" + } + MycConfigApplyErrorKind::ResourceExhausted => { + "Myc configuration history capacity is exhausted" + } + MycConfigApplyErrorKind::Transaction => "Myc configuration apply transaction failed", + MycConfigApplyErrorKind::CommitOutcomeUnknown => { + "Myc configuration apply commit outcome is unknown" + } + }) + } +} + +impl fmt::Debug for MycConfigApplyError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycConfigApplyError") + .field("kind", &self.kind) + .finish() + } +} + +impl Error for MycConfigApplyError {} + +/// Committed immutable configuration-generation evidence. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycConfigApplyOutcome { + generation: u16, + revoked_connections: u64, + revoked_challenges: u64, +} + +impl MycConfigApplyOutcome { + /// Returns the committed consecutive configuration generation. + #[must_use] + pub const fn generation(self) -> u16 { + self.generation + } + + /// Returns the number of connection records revoked by the apply. + #[must_use] + pub const fn revoked_connection_count(self) -> u64 { + self.revoked_connections + } + + /// Returns the number of pending challenges revoked by the apply. + #[must_use] + pub const fn revoked_challenge_count(self) -> u64 { + self.revoked_challenges + } +} + +impl fmt::Debug for MycConfigApplyOutcome { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycConfigApplyOutcome") + .field("generation", &self.generation) + .field("revoked_connections", &self.revoked_connections) + .field("revoked_challenges", &self.revoked_challenges) + .finish() + } +} + +impl MycStateRepository<'_> { + /// Atomically applies one complete candidate configuration while offline. + /// + /// The current document must match the latest durable binding. The candidate + /// is already structurally and semantically admitted by its sealed type. + /// Unsafe relay changes are rejected while nonterminal jobs retain the relay. + pub async fn apply_configuration( + &self, + current: &MycConfigDocumentV1, + candidate: &MycConfigDocumentV1, + applied_at: MigrationAppliedAtUnixSeconds, + build: &MigrationBuildIdentity, + ) -> Result<MycConfigApplyOutcome, MycConfigApplyError> { + if !self.is_writable() { + return Err(MycConfigApplyError::new( + MycConfigApplyErrorKind::InvalidMode, + )); + } + if current.profile() != candidate.profile() + || candidate.provider_contract().bindings().is_empty() + || !valid_build(candidate, build) + { + return Err(MycConfigApplyError::new( + MycConfigApplyErrorKind::InvalidInput, + )); + } + let current_binding = PersistedMetadata::from_configuration(current) + .map_err(|_| MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput))?; + if current_binding != PersistedMetadata::from(self.expected()) { + return Err(MycConfigApplyError::new(MycConfigApplyErrorKind::Binding)); + } + let candidate_binding = PersistedMetadata::from_configuration(candidate) + .map_err(|_| MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput))?; + let exact_replay = candidate_binding == current_binding; + let changed_relays = changed_existing_relays(current, candidate)?; + let candidate_permissions = permission_ceiling(candidate)?; + let permissions_narrowed = permission_ceiling(current)? + .iter() + .any(|permission| !candidate_permissions.contains(permission)); + let identities_changed = identities_changed(&current_binding, &candidate_binding); + let applied_at_unix_s = applied_at.get(); + let applied_at_unix_ms = + i64::try_from(applied_at_unix_s.saturating_mul(1000)).unwrap_or(i64::MAX); + let build = build.clone(); + + self.host() + .transaction(move |transaction| { + Box::pin(async move { + require_expected_metadata(transaction, &current_binding).await?; + let (generation, latest_applied_at) = read_latest_header(transaction).await?; + if exact_replay { + return Ok(MycConfigApplyOutcome { + generation, + revoked_connections: 0, + revoked_challenges: 0, + }); + } + if generation >= MYC_CONFIG_BINDING_MAX_GENERATIONS { + return Err(ConfigOperationError::ResourceExhausted); + } + if applied_at_unix_s < latest_applied_at { + return Err(ConfigOperationError::InvalidInput); + } + for relay_id in &changed_relays { + if relay_has_nonterminal_job(transaction, relay_id).await? { + return Err(ConfigOperationError::PolicyConflict); + } + } + let (revoked_connections, revoked_challenges) = if identities_changed { + revoke_for_identity_change(transaction, applied_at_unix_ms).await? + } else if permissions_narrowed { + revoke_for_permission_narrowing( + transaction, + applied_at_unix_ms, + &candidate_permissions, + ) + .await? + } else { + (0, 0) + }; + let next_generation = generation + 1; + insert_binding( + transaction, + next_generation, + &candidate_binding, + applied_at_unix_s, + &build, + ) + .await?; + match read_latest_config_binding(transaction).await? { + Some(actual) if actual == candidate_binding => {} + Some(_) | None => return Err(ConfigOperationError::Binding), + } + let (actual_generation, actual_applied_at) = + read_latest_header(transaction).await?; + if actual_generation != next_generation + || actual_applied_at != applied_at_unix_s + { + return Err(ConfigOperationError::Binding); + } + Ok(MycConfigApplyOutcome { + generation: next_generation, + revoked_connections, + revoked_challenges, + }) + }) + }) + .await + .map_err(map_apply_transaction_error) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ConfigOperationError { + InvalidInput, + Binding, + PolicyConflict, + ResourceExhausted, + Storage, +} + +impl From<RepositoryOperationError> for ConfigOperationError { + fn from(error: RepositoryOperationError) -> Self { + match error { + RepositoryOperationError::Binding => Self::Binding, + RepositoryOperationError::Storage => Self::Storage, + } + } +} + +fn valid_build(configuration: &MycConfigDocumentV1, build: &MigrationBuildIdentity) -> bool { + build.config_contract_version() == configuration.schema_version() + && build.state_contract_version() == crate::MYC_STATE_SCHEMA_VERSION + && build.admin_contract_version() == crate::MYC_OPERATOR_CONTRACT_VERSION + && build.status_contract_version() == crate::MYC_SIGNER_STATUS_CONTRACT_VERSION + && build.provider_contract_version() == MYC_PROVIDER_CONTRACT_VERSION +} + +fn identities_changed(current: &PersistedMetadata, candidate: &PersistedMetadata) -> bool { + current.transport_public_key != candidate.transport_public_key + || current.user_public_key != candidate.user_public_key + || current.discovery_public_key != candidate.discovery_public_key +} + +#[derive(PartialEq, Eq)] +struct RelayBinding<'a> { + url: &'a str, + read: bool, + write: bool, + required: bool, + authentication: &'a str, +} + +fn relay_bindings( + configuration: &MycConfigDocumentV1, +) -> Result<BTreeMap<&str, RelayBinding<'_>>, MycConfigApplyError> { + configuration + .normalized() + .pointer("/relays") + .and_then(Value::as_array) + .ok_or_else(|| MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput))? + .iter() + .map(|relay| { + let id = relay + .pointer("/id") + .and_then(Value::as_str) + .ok_or_else(|| MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput))?; + let value = RelayBinding { + url: relay + .pointer("/url") + .and_then(Value::as_str) + .ok_or_else(|| { + MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput) + })?, + read: relay + .pointer("/read") + .and_then(Value::as_bool) + .ok_or_else(|| { + MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput) + })?, + write: relay + .pointer("/write") + .and_then(Value::as_bool) + .ok_or_else(|| { + MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput) + })?, + required: relay + .pointer("/required") + .and_then(Value::as_bool) + .ok_or_else(|| { + MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput) + })?, + authentication: relay + .pointer("/authentication") + .and_then(Value::as_str) + .ok_or_else(|| { + MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput) + })?, + }; + Ok((id, value)) + }) + .collect() +} + +fn changed_existing_relays( + current: &MycConfigDocumentV1, + candidate: &MycConfigDocumentV1, +) -> Result<Vec<Box<str>>, MycConfigApplyError> { + let current = relay_bindings(current)?; + let candidate = relay_bindings(candidate)?; + Ok(current + .into_iter() + .filter(|(id, binding)| candidate.get(id).is_none_or(|next| next != binding)) + .map(|(id, _)| id.into()) + .collect()) +} + +fn permission_ceiling( + configuration: &MycConfigDocumentV1, +) -> Result<BTreeSet<Box<str>>, MycConfigApplyError> { + configuration + .normalized() + .pointer("/policy/permission_ceiling") + .and_then(Value::as_array) + .ok_or_else(|| MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput))? + .iter() + .map(|permission| { + permission + .as_str() + .map(Into::into) + .ok_or_else(|| MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput)) + }) + .collect() +} + +async fn read_latest_header( + transaction: &mut ServiceSqliteTransaction<'_>, +) -> Result<(u16, u64), ConfigOperationError> { + let rows = sqlx::query(READ_LATEST_HEADER_SQL) + .fetch_all(&mut *transaction) + .await + .map_err(|_| ConfigOperationError::Storage)?; + let [row] = rows.as_slice() else { + return Err(ConfigOperationError::Binding); + }; + let generation = row + .try_get::<i64, _>("generation") + .ok() + .and_then(|value| u16::try_from(value).ok()) + .filter(|value| (1..=MYC_CONFIG_BINDING_MAX_GENERATIONS).contains(value)) + .ok_or(ConfigOperationError::Binding)?; + let applied_at = row + .try_get::<i64, _>("applied_at_unix_s") + .ok() + .and_then(|value| u64::try_from(value).ok()) + .ok_or(ConfigOperationError::Binding)?; + Ok((generation, applied_at)) +} + +async fn relay_has_nonterminal_job( + transaction: &mut ServiceSqliteTransaction<'_>, + relay_id: &str, +) -> Result<bool, ConfigOperationError> { + let row = sqlx::query(RELAY_HAS_NONTERMINAL_JOB_SQL) + .bind(relay_id) + .fetch_one(&mut *transaction) + .await + .map_err(|_| ConfigOperationError::Storage)?; + match row.try_get::<i64, _>("is_blocked") { + Ok(0) => Ok(false), + Ok(1) => Ok(true), + _ => Err(ConfigOperationError::Binding), + } +} + +async fn revoke_for_identity_change( + transaction: &mut ServiceSqliteTransaction<'_>, + observed_at_unix_ms: i64, +) -> Result<(u64, u64), ConfigOperationError> { + let active = sqlx::query(REVOKE_ACTIVE_CONNECTIONS_SQL) + .bind(observed_at_unix_ms) + .execute(&mut *transaction) + .await + .map_err(|_| ConfigOperationError::Storage)? + .rows_affected(); + let pending = sqlx::query(DENY_PENDING_CONNECTIONS_SQL) + .bind(observed_at_unix_ms) + .execute(&mut *transaction) + .await + .map_err(|_| ConfigOperationError::Storage)? + .rows_affected(); + let challenges = sqlx::query(EXPIRE_ALL_PENDING_CHALLENGES_SQL) + .bind(observed_at_unix_ms) + .execute(&mut *transaction) + .await + .map_err(|_| ConfigOperationError::Storage)? + .rows_affected(); + Ok((active.saturating_add(pending), challenges)) +} + +async fn revoke_for_permission_narrowing( + transaction: &mut ServiceSqliteTransaction<'_>, + observed_at_unix_ms: i64, + permissions: &BTreeSet<Box<str>>, +) -> Result<(u64, u64), ConfigOperationError> { + let connections = + update_affected_connections(transaction, observed_at_unix_ms, permissions).await?; + let challenges = + update_affected_challenges(transaction, observed_at_unix_ms, permissions).await?; + Ok((connections, challenges)) +} + +async fn update_affected_connections( + transaction: &mut ServiceSqliteTransaction<'_>, + observed_at_unix_ms: i64, + permissions: &BTreeSet<Box<str>>, +) -> Result<u64, ConfigOperationError> { + let mut query = QueryBuilder::<Sqlite>::new( + "UPDATE connections SET status = 'expired', updated_at_unix_ms = \ + MAX(updated_at_unix_ms, ", + ); + query.push_bind(observed_at_unix_ms).push( + "), authorized_until_unix_ms = NULL WHERE status = 'active' AND EXISTS (\ + SELECT 1 FROM connection_permissions AS permission \ + WHERE permission.connection_id = connections.connection_id \ + AND permission.permission_scope = 'granted'", + ); + push_not_in(&mut query, permissions); + query.push(")"); + query + .build() + .execute(&mut *transaction) + .await + .map(|result| result.rows_affected()) + .map_err(|_| ConfigOperationError::Storage) +} + +async fn update_affected_challenges( + transaction: &mut ServiceSqliteTransaction<'_>, + observed_at_unix_ms: i64, + permissions: &BTreeSet<Box<str>>, +) -> Result<u64, ConfigOperationError> { + let mut query = QueryBuilder::<Sqlite>::new( + "UPDATE connection_auth_challenges SET state = 'expired', \ + resolved_at_unix_ms = MAX(issued_at_unix_ms, ", + ); + query.push_bind(observed_at_unix_ms).push( + ") WHERE state = 'pending' AND EXISTS (\ + SELECT 1 FROM connection_permissions AS permission \ + WHERE permission.connection_id = connection_auth_challenges.connection_id \ + AND permission.permission_scope = 'requested'", + ); + push_not_in(&mut query, permissions); + query.push(")"); + query + .build() + .execute(&mut *transaction) + .await + .map(|result| result.rows_affected()) + .map_err(|_| ConfigOperationError::Storage) +} + +fn push_not_in(query: &mut QueryBuilder<Sqlite>, permissions: &BTreeSet<Box<str>>) { + if permissions.is_empty() { + return; + } + query.push(" AND permission.permission_code NOT IN ("); + let mut separated = query.separated(", "); + for permission in permissions { + separated.push_bind(permission.as_ref()); + } + separated.push_unseparated(")"); +} + +async fn insert_binding( + transaction: &mut ServiceSqliteTransaction<'_>, + generation: u16, + binding: &PersistedMetadata, + applied_at_unix_s: u64, + build: &MigrationBuildIdentity, +) -> Result<(), ConfigOperationError> { + let result = sqlx::query(INSERT_CONFIG_BINDING_SQL) + .bind(i64::from(generation)) + .bind(binding.normalized_config_sha256.as_slice()) + .bind(binding.transport_public_key.as_ref()) + .bind(binding.user_public_key.as_ref()) + .bind(binding.discovery_public_key.as_deref()) + .bind(i64::from(binding.config_contract_version)) + .bind(i64::from(binding.state_contract_version)) + .bind(i64::from(binding.operator_contract_version)) + .bind(i64::from(binding.status_contract_version)) + .bind(i64::try_from(applied_at_unix_s).map_err(|_| ConfigOperationError::InvalidInput)?) + .bind(build.service_version()) + .bind(build.service_commit()) + .bind(build.lib_revision()) + .bind(build.rust_version()) + .bind(build.target()) + .bind(build.feature_profile()) + .bind(i64::from(build.provider_contract_version())) + .execute(&mut *transaction) + .await + .map_err(|_| ConfigOperationError::Storage)?; + (result.rows_affected() == 1) + .then_some(()) + .ok_or(ConfigOperationError::Storage) +} + +fn map_apply_transaction_error( + error: ServiceSqliteTransactionError<ConfigOperationError>, +) -> MycConfigApplyError { + if error.kind() == ServiceSqliteTransactionErrorKind::CommitOutcomeUnknown { + return MycConfigApplyError::new(MycConfigApplyErrorKind::CommitOutcomeUnknown); + } + let kind = match error.operation_error() { + Some(ConfigOperationError::InvalidInput) => MycConfigApplyErrorKind::InvalidInput, + Some(ConfigOperationError::Binding) => MycConfigApplyErrorKind::Binding, + Some(ConfigOperationError::PolicyConflict) => MycConfigApplyErrorKind::PolicyConflict, + Some(ConfigOperationError::ResourceExhausted) => MycConfigApplyErrorKind::ResourceExhausted, + Some(ConfigOperationError::Storage) | None => MycConfigApplyErrorKind::Transaction, + }; + MycConfigApplyError::new(kind) +} diff --git a/src/state_host.rs b/src/state_host.rs @@ -151,7 +151,11 @@ impl MycStateHost { /// Returns sealed typed repository access bound to this host and metadata. #[must_use] pub const fn repository(&self) -> MycStateRepository<'_> { - MycStateRepository::new(&self.host, &self.metadata) + MycStateRepository::new( + &self.host, + &self.metadata, + matches!(self.mode, MycStateHostMode::ReadWriteExisting), + ) } /// Captures one governed point-in-time backup from a writable Myc host. @@ -377,21 +381,22 @@ fn require_migration_build( fn exact_initialization_outcome(outcome: MigrationApplicationOutcome) -> bool { outcome.initial_version() == MYC_STATE_BASE_SCHEMA_VERSION && outcome.final_version() == MYC_STATE_SCHEMA_VERSION - && outcome.applied_count() == 8 + && outcome.applied_count() == 9 } fn exact_existing_outcome(outcome: MigrationApplicationOutcome) -> bool { outcome.final_version() == MYC_STATE_SCHEMA_VERSION && matches!( (outcome.initial_version(), outcome.applied_count()), - (MYC_STATE_BASE_SCHEMA_VERSION, 8) - | (2, 7) - | (3, 6) - | (4, 5) - | (5, 4) - | (6, 3) - | (7, 2) - | (8, 1) + (MYC_STATE_BASE_SCHEMA_VERSION, 9) + | (2, 8) + | (3, 7) + | (4, 6) + | (5, 5) + | (6, 4) + | (7, 3) + | (8, 2) + | (9, 1) | (MYC_STATE_SCHEMA_VERSION, 0) ) } diff --git a/src/state_metadata.rs b/src/state_metadata.rs @@ -451,7 +451,7 @@ fn require_profile_binding( .ok_or_else(|| MycStateMetadataError::new(MycStateMetadataErrorKind::Profile)) } -fn normalized_config_digest( +pub(crate) fn normalized_config_digest( profile: MycConfigProfile, normalized: &serde_json::Value, ) -> Result<MycNormalizedConfigDigest, MycStateMetadataError> { @@ -582,7 +582,7 @@ fn delivery_policies( .map_err(|_| invalid()) } -fn expected_identities( +pub(crate) fn expected_identities( normalized: &serde_json::Value, ) -> Result<MycExpectedIdentities, MycStateMetadataError> { let identity = |pointer: &str| { diff --git a/src/state_repository.rs b/src/state_repository.rs @@ -9,7 +9,10 @@ use radroots_service_sqlite::{ }; use sqlx::Row; -use crate::MycStateMetadata; +use crate::{ + MYC_STATE_SCHEMA_VERSION, MycConfigDocumentV1, MycStateMetadata, + state_metadata::{expected_identities, normalized_config_digest}, +}; const READ_METADATA_SQL: &str = r#"SELECT singleton, @@ -57,6 +60,58 @@ const INSERT_METADATA_SQL: &str = r#"INSERT INTO myc_state_metadata ( status_contract_version ) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?)"#; +const READ_LATEST_CONFIG_BINDING_SQL: &str = r#"SELECT + CASE + WHEN typeof(normalized_config_sha256) = 'blob' + AND length(normalized_config_sha256) = 32 + THEN normalized_config_sha256 + ELSE NULL + END AS normalized_config_sha256, + CASE + WHEN typeof(transport_public_key) = 'text' + AND length(CAST(transport_public_key AS BLOB)) = 64 + THEN transport_public_key + ELSE NULL + END AS transport_public_key, + CASE + WHEN typeof(user_public_key) = 'text' + AND length(CAST(user_public_key AS BLOB)) = 64 + THEN user_public_key + ELSE NULL + END AS user_public_key, + typeof(discovery_public_key) AS discovery_public_key_type, + CASE + WHEN typeof(discovery_public_key) = 'text' + AND length(CAST(discovery_public_key AS BLOB)) = 64 + THEN discovery_public_key + ELSE NULL + END AS discovery_public_key, + config_contract_version, + state_contract_version, + operator_contract_version, + status_contract_version +FROM myc_config_bindings +ORDER BY generation DESC +LIMIT 1"#; + +const INSERT_INITIAL_CONFIG_BINDING_SQL: &str = r#"INSERT INTO myc_config_bindings ( + generation, normalized_config_sha256, transport_public_key, user_public_key, + discovery_public_key, config_contract_version, state_contract_version, + operator_contract_version, status_contract_version, applied_at_unix_s, + service_version, service_commit, lib_revision, rust_version, target, + feature_profile, provider_contract_version +) +SELECT 1, metadata.normalized_config_sha256, metadata.transport_public_key, + metadata.user_public_key, metadata.discovery_public_key, + metadata.config_contract_version, 10, + metadata.operator_contract_version, metadata.status_contract_version, + migration.applied_at_unix_s, migration.service_version, + migration.service_commit, migration.lib_revision, migration.rust_version, + migration.target, migration.feature_profile, migration.provider_contract_version +FROM myc_state_metadata AS metadata +JOIN schema_migrations AS migration ON migration.version = 10 +WHERE metadata.singleton = 1"#; + /// Stable failure classes for typed Myc state-repository operations. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum MycStateRepositoryErrorKind { @@ -136,14 +191,20 @@ impl Error for MycStateRepositoryError {} pub struct MycStateRepository<'host> { host: &'host ServiceSqliteHost, expected: &'host MycStateMetadata, + writable: bool, } impl<'host> MycStateRepository<'host> { pub(crate) const fn new( host: &'host ServiceSqliteHost, expected: &'host MycStateMetadata, + writable: bool, ) -> Self { - Self { host, expected } + Self { + host, + expected, + writable, + } } pub(crate) const fn host(&self) -> &'host ServiceSqliteHost { @@ -154,6 +215,10 @@ impl<'host> MycStateRepository<'host> { self.expected } + pub(crate) const fn is_writable(&self) -> bool { + self.writable + } + /// Re-verifies the immutable Myc binding through the sealed transaction executor. pub async fn verify_binding(&self) -> Result<(), MycStateRepositoryError> { self.transact(false).await @@ -168,18 +233,26 @@ impl<'host> MycStateRepository<'host> { self.host .transaction(move |transaction| { Box::pin(async move { - let actual = read_metadata(transaction).await?; - match actual { - Some(actual) if actual == expected => Ok(()), - Some(_) => Err(RepositoryOperationError::Binding), + let birth = read_metadata(transaction).await?; + match birth { + Some(actual) if actual.same_contracts(&expected) => {} + Some(_) => return Err(RepositoryOperationError::Binding), None if initialize_missing => { insert_metadata(transaction, &expected).await?; match read_metadata(transaction).await? { - Some(actual) if actual == expected => Ok(()), - Some(_) | None => Err(RepositoryOperationError::Binding), + Some(actual) if actual == expected => {} + Some(_) | None => return Err(RepositoryOperationError::Binding), } } - None => Err(RepositoryOperationError::Binding), + None => return Err(RepositoryOperationError::Binding), + } + let latest = read_latest_config_binding(transaction).await?; + if latest.is_none() && initialize_missing { + insert_initial_config_binding(transaction).await?; + } + match read_latest_config_binding(transaction).await? { + Some(actual) if actual == expected => Ok(()), + Some(_) | None => Err(RepositoryOperationError::Binding), } }) }) @@ -199,14 +272,43 @@ impl fmt::Debug for MycStateRepository<'_> { #[derive(Clone, PartialEq, Eq)] pub(crate) struct PersistedMetadata { - normalized_config_sha256: [u8; 32], - transport_public_key: Box<str>, - user_public_key: Box<str>, - discovery_public_key: Option<Box<str>>, - config_contract_version: u32, - state_contract_version: u32, - operator_contract_version: u32, - status_contract_version: u32, + pub(crate) normalized_config_sha256: [u8; 32], + pub(crate) transport_public_key: Box<str>, + pub(crate) user_public_key: Box<str>, + pub(crate) discovery_public_key: Option<Box<str>>, + pub(crate) config_contract_version: u32, + pub(crate) state_contract_version: u32, + pub(crate) operator_contract_version: u32, + pub(crate) status_contract_version: u32, +} + +impl PersistedMetadata { + pub(crate) fn from_configuration( + configuration: &MycConfigDocumentV1, + ) -> Result<Self, RepositoryOperationError> { + let identities = expected_identities(configuration.normalized()) + .map_err(|_| RepositoryOperationError::Binding)?; + let digest = normalized_config_digest(configuration.profile(), configuration.normalized()) + .map_err(|_| RepositoryOperationError::Binding)?; + Ok(Self { + normalized_config_sha256: *digest.as_bytes(), + transport_public_key: identities.transport().as_hex().into(), + user_public_key: identities.user().as_hex().into(), + discovery_public_key: identities.discovery().map(|value| value.as_hex().into()), + config_contract_version: configuration.schema_version(), + state_contract_version: MYC_STATE_SCHEMA_VERSION, + operator_contract_version: crate::MYC_OPERATOR_CONTRACT_VERSION, + status_contract_version: crate::MYC_SIGNER_STATUS_CONTRACT_VERSION, + }) + } + + fn same_contracts(&self, other: &Self) -> bool { + self.config_contract_version == other.config_contract_version + && matches!(self.state_contract_version, 9 | 10) + && other.state_contract_version == MYC_STATE_SCHEMA_VERSION + && self.operator_contract_version == other.operator_contract_version + && self.status_contract_version == other.status_contract_version + } } impl From<&MycStateMetadata> for PersistedMetadata { @@ -236,12 +338,25 @@ pub(crate) async fn require_expected_metadata( transaction: &mut ServiceSqliteTransaction<'_>, expected: &PersistedMetadata, ) -> Result<(), RepositoryOperationError> { - match read_metadata(transaction).await? { + match read_latest_config_binding(transaction).await? { Some(actual) if actual == *expected => Ok(()), Some(_) | None => Err(RepositoryOperationError::Binding), } } +pub(crate) async fn read_latest_config_binding( + transaction: &mut ServiceSqliteTransaction<'_>, +) -> Result<Option<PersistedMetadata>, RepositoryOperationError> { + let rows = sqlx::query(READ_LATEST_CONFIG_BINDING_SQL) + .fetch_all(&mut *transaction) + .await + .map_err(|_| RepositoryOperationError::Storage)?; + if rows.len() > 1 { + return Err(RepositoryOperationError::Binding); + } + rows.first().map(decode_metadata_row).transpose() +} + async fn read_metadata( transaction: &mut ServiceSqliteTransaction<'_>, ) -> Result<Option<PersistedMetadata>, RepositoryOperationError> { @@ -258,6 +373,15 @@ async fn read_metadata( let singleton = row .try_get::<i64, _>("singleton") .map_err(|_| RepositoryOperationError::Binding)?; + let actual = decode_metadata_row(row)?; + (singleton == 1) + .then_some(Some(actual)) + .ok_or(RepositoryOperationError::Binding) +} + +fn decode_metadata_row( + row: &sqlx::sqlite::SqliteRow, +) -> Result<PersistedMetadata, RepositoryOperationError> { let normalized = row .try_get::<Option<Vec<u8>>, _>("normalized_config_sha256") .map_err(|_| RepositoryOperationError::Binding)? @@ -275,7 +399,7 @@ async fn read_metadata( "text" => Some(bounded_public_key(row, "discovery_public_key")?), _ => return Err(RepositoryOperationError::Binding), }; - let actual = PersistedMetadata { + Ok(PersistedMetadata { normalized_config_sha256, transport_public_key, user_public_key, @@ -284,10 +408,7 @@ async fn read_metadata( state_contract_version: bounded_version(row, "state_contract_version")?, operator_contract_version: bounded_version(row, "operator_contract_version")?, status_contract_version: bounded_version(row, "status_contract_version")?, - }; - (singleton == 1) - .then_some(Some(actual)) - .ok_or(RepositoryOperationError::Binding) + }) } fn bounded_public_key( @@ -340,6 +461,18 @@ async fn insert_metadata( .ok_or(RepositoryOperationError::Storage) } +async fn insert_initial_config_binding( + transaction: &mut ServiceSqliteTransaction<'_>, +) -> Result<(), RepositoryOperationError> { + let result = sqlx::query(INSERT_INITIAL_CONFIG_BINDING_SQL) + .execute(&mut *transaction) + .await + .map_err(|_| RepositoryOperationError::Storage)?; + (result.rows_affected() == 1) + .then_some(()) + .ok_or(RepositoryOperationError::Storage) +} + fn map_transaction_error( error: ServiceSqliteTransactionError<RepositoryOperationError>, ) -> MycStateRepositoryError { diff --git a/tests/build_policy.rs b/tests/build_policy.rs @@ -89,6 +89,6 @@ fn source_lock_binds_the_current_cargo_lock() { "source_archive_sha256 = \"b425371c134be96cce46b37f7035d6212f1efe8cff50bef366631ba5632991b0\"" )); assert!(SOURCE_LOCK.ends_with( - "[contract_versions]\nconfig = 1\nstate = 9\nadmin = 1\nstatus = 1\nprovider = 1\n" + "[contract_versions]\nconfig = 1\nstate = 10\nadmin = 1\nstatus = 1\nprovider = 1\n" )); } diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs @@ -71,6 +71,7 @@ const SOURCES: &[&str] = &[ include_str!("../src/status_v1.rs"), include_str!("../src/state_catalog.rs"), include_str!("../src/state_completion.rs"), + include_str!("../src/state_config.rs"), include_str!("../src/state_connection.rs"), include_str!("../src/state_delivery.rs"), include_str!("../src/state_discovery.rs"), @@ -117,6 +118,7 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() { "status_v1", "state_catalog", "state_completion", + "state_config", "state_connection", "state_delivery", "state_discovery", @@ -140,6 +142,9 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() { "```compile_fail", "[Myc API baseline](contracts/api_baselines/myc.txt)", "Status publication and cached snapshots can be obtained only", + "Schema v10 adds an append-only configuration-binding history capped at exactly\n1,024 generations", + "Exact replay returns the retained generation without another\nappend or revocation", + "Future startup\nmust present the latest normalized config and public-identity binding", ] { assert!(README.contains(required), "README is missing `{required}`"); } @@ -211,6 +216,11 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { "pub struct myc::MycNip46WorkError", "pub struct myc::MycStateHost", "pub struct myc::MycStateRepository", + "pub const myc::MYC_CONFIG_BINDING_MAX_GENERATIONS: u16", + "pub struct myc::MycConfigApplyOutcome", + "pub struct myc::MycConfigApplyError", + "pub enum myc::MycConfigApplyErrorKind", + "pub async fn myc::MycStateRepository<'_>::apply_configuration", "pub struct myc::MycNip46CommitRequest", "pub struct myc::MycNip46CommitRecord", "pub enum myc::MycNip46CommitAdmission", @@ -278,6 +288,7 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() { "status_v1", "state_catalog", "state_completion", + "state_config", "state_connection", "state_delivery", "state_discovery", @@ -974,8 +985,9 @@ fn public_errors_remain_crate_owned_redacted_and_source_free() { .lines() .filter(|line| line.starts_with("pub struct myc::") && line.ends_with("Error")) .count(); - assert_eq!(public_error_count, 32); + assert_eq!(public_error_count, 33); assert!(PUBLIC_API.contains("pub struct myc::MycDoctorError")); + assert!(PUBLIC_API.contains("pub struct myc::MycConfigApplyError")); assert!(!PUBLIC_API.contains("pub struct myc::MycRuntimeFoundation {")); assert!(!PUBLIC_API.contains("pub struct myc::MycStateHost {")); } diff --git a/tests/services_hardening_config_lifecycle.rs b/tests/services_hardening_config_lifecycle.rs @@ -0,0 +1,826 @@ +#![forbid(unsafe_code)] +#![cfg(any(target_os = "linux", target_os = "macos"))] + +use std::{ + error::Error, + fs, + num::NonZeroU32, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, +}; + +use myc::{ + MycConfigApplyErrorKind, MycConfigProfile, MycStateHostErrorKind, MycStateMetadata, + RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, initialize_myc_state, + open_myc_state_inspection, open_myc_state_read_write, parse_myc_cli_v1_from, + parse_myc_config_v1, resolve_myc_runtime_context, +}; +use radroots_service_sqlite::{ + MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, MigrationCatalog, OpenMode, + SchemaCatalog, ServiceDatabaseIdentity, ServiceSqliteConnectionOptions, ServiceSqliteHost, + ServiceSqlitePaths, initialize_database, +}; +use radroots_storage::event::SourceGeneration; +use sqlx::{ConnectOptions, Connection, Row, sqlite::SqliteConnectOptions}; + +const CONFIG: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); +const CONFIG_SOURCE: &str = include_str!("../src/state_config.rs"); +const LIB_SOURCE: &str = include_str!("../src/lib.rs"); + +fn runtime(root: &Path) -> myc::MycRuntimeContext { + let invocation = parse_myc_cli_v1_from([ + "myc", + "--profile", + "repo-local", + "--instance", + "primary", + "--repo-local-root", + root.to_str().expect("UTF-8 root"), + "run", + ]) + .expect("invocation"); + resolve_myc_runtime_context( + &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), + &invocation, + ) + .expect("runtime") +} + +fn prepare(runtime: &myc::MycRuntimeContext) { + fs::create_dir_all(runtime.context().paths().state()).expect("state directory"); + fs::set_permissions( + runtime.context().paths().state(), + fs::Permissions::from_mode(0o700), + ) + .expect("state mode"); +} + +fn configuration(source: &str) -> myc::MycConfigDocumentV1 { + parse_myc_config_v1(source.as_bytes(), MycConfigProfile::RepoLocal).expect("configuration") +} + +fn metadata( + runtime: &myc::MycRuntimeContext, + configuration: &myc::MycConfigDocumentV1, +) -> MycStateMetadata { + MycStateMetadata::new( + runtime, + configuration, + SourceGeneration::new([0x5a; 32]).expect("generation"), + 1_725_000_000_000, + ) + .expect("metadata") +} + +fn build() -> MigrationBuildIdentity { + build_for_schema(myc::MYC_STATE_SCHEMA_VERSION) +} + +fn build_for_schema(state_schema_version: u32) -> MigrationBuildIdentity { + build_for_contracts(state_schema_version, 1) +} + +fn build_for_contracts( + state_schema_version: u32, + provider_contract_version: u32, +) -> MigrationBuildIdentity { + MigrationBuildIdentity::new( + env!("CARGO_PKG_VERSION"), + "1111111111111111111111111111111111111111", + "7d7b454b4c9ed86569671993bd03ca868b676665", + "rustc-test", + "test-target", + "service-host", + 1, + state_schema_version, + 1, + 1, + provider_contract_version, + ) + .expect("build") +} + +#[derive(Debug)] +struct TestInitializationError; + +impl std::fmt::Display for TestInitializationError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("test catalog initialization failed") + } +} + +impl Error for TestInitializationError {} + +async fn initialize_v9(runtime: &myc::MycRuntimeContext, metadata: &MycStateMetadata) { + let full_migrations = myc::myc_migration_catalog().expect("full migrations"); + let migrations = MigrationCatalog::new(full_migrations.descriptors()[..8].iter().cloned()) + .expect("v9 migrations"); + let full_schema = myc::myc_schema_catalog().expect("full schema"); + let schema = SchemaCatalog::new(&migrations, full_schema.versions()[..9].iter().copied()) + .expect("v9 schema"); + let paths = ServiceSqlitePaths::from_runtime_context(runtime.context()).expect("paths"); + let initial = metadata.initial_database_metadata(); + let identity = ServiceDatabaseIdentity::new( + &paths, + initial.source_generation(), + NonZeroU32::new(9).unwrap(), + initial.application_id(), + ); + let authority = initialize_database( + &paths, + OpenMode::Initialize, + initial, + &schema, + |path: PathBuf| async move { + let connection = sqlx::SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(path) + .create_if_missing(false) + .disable_statement_logging(), + ) + .await + .map_err(|_| TestInitializationError)?; + connection + .close() + .await + .map_err(|_| TestInitializationError) + }, + ) + .await + .expect("v9 initialize"); + let (host, outcome) = ServiceSqliteHost::open_initialized( + &paths, + &identity, + &migrations, + &schema, + ServiceSqliteConnectionOptions::reviewed(), + authority, + MigrationAppliedAtUnixSeconds::new(1_725_000_000).unwrap(), + &build_for_schema(9), + &[], + ) + .await + .expect("v9 migrations"); + assert_eq!(outcome.final_version(), 9); + assert_eq!(outcome.applied_count(), 8); + + let digest = *metadata.configuration_digest().as_bytes(); + let identities = metadata.expected_identities(); + let transport: Box<str> = identities.transport().as_hex().into(); + let user: Box<str> = identities.user().as_hex().into(); + let discovery: Option<Box<str>> = identities.discovery().map(|value| value.as_hex().into()); + let versions = metadata.policy_versions(); + host.transaction(move |transaction| { + Box::pin(async move { + sqlx::query( + "INSERT INTO myc_state_metadata (singleton, normalized_config_sha256, \ + transport_public_key, user_public_key, discovery_public_key, \ + config_contract_version, state_contract_version, operator_contract_version, \ + status_contract_version) VALUES (1, ?, ?, ?, ?, ?, 9, ?, ?)", + ) + .bind(digest.as_slice()) + .bind(transport.as_ref()) + .bind(user.as_ref()) + .bind(discovery.as_deref()) + .bind(i64::from(versions.configuration())) + .bind(i64::from(versions.operator())) + .bind(i64::from(versions.status())) + .execute(&mut *transaction) + .await + .map(|_| ()) + }) + }) + .await + .expect("v9 Myc birth binding"); + host.close().await.expect("v9 close"); +} + +async fn initialize(runtime: &myc::MycRuntimeContext, metadata: &MycStateMetadata) { + initialize_myc_state( + runtime, + metadata, + MigrationAppliedAtUnixSeconds::new(1_725_000_000).unwrap(), + &build(), + ) + .await + .expect("initialize"); +} + +fn options(runtime: &myc::MycRuntimeContext) -> SqliteConnectOptions { + SqliteConnectOptions::new() + .filename(runtime.artifacts().state_database()) + .create_if_missing(false) + .disable_statement_logging() +} + +#[tokio::test] +async fn offline_apply_appends_one_generation_and_rebinds_future_startup() { + let directory = tempfile::tempdir().expect("root"); + let runtime = runtime(directory.path()); + prepare(&runtime); + let current = configuration(CONFIG); + let current_metadata = metadata(&runtime, &current); + initialize(&runtime, &current_metadata).await; + + let candidate_source = CONFIG.replace("level = \"info\"", "level = \"warn\""); + let candidate = configuration(&candidate_source); + let writer = open_myc_state_read_write( + &runtime, + &current_metadata, + MigrationAppliedAtUnixSeconds::new(1_725_000_001).unwrap(), + &build(), + ) + .await + .expect("writer"); + let second_writer = open_myc_state_read_write( + &runtime, + &current_metadata, + MigrationAppliedAtUnixSeconds::new(1_725_000_001).unwrap(), + &build(), + ) + .await + .expect_err("exclusive writer authority must reject a concurrent daemon"); + assert_eq!(second_writer.kind(), MycStateHostErrorKind::ReadWriteOpen); + + let mismatched_build = build_for_contracts(myc::MYC_STATE_SCHEMA_VERSION, 2); + let mismatch = writer + .repository() + .apply_configuration( + &current, + &candidate, + MigrationAppliedAtUnixSeconds::new(1_725_000_002).unwrap(), + &mismatched_build, + ) + .await + .expect_err("provider contract mismatch"); + assert_eq!(mismatch.kind(), MycConfigApplyErrorKind::InvalidInput); + + let outcome = writer + .repository() + .apply_configuration( + &current, + &candidate, + MigrationAppliedAtUnixSeconds::new(1_725_000_002).unwrap(), + &build(), + ) + .await + .expect("apply"); + assert_eq!(outcome.generation(), 2); + assert_eq!(outcome.revoked_connection_count(), 0); + assert_eq!(outcome.revoked_challenge_count(), 0); + assert_eq!( + format!("{outcome:?}"), + "MycConfigApplyOutcome { generation: 2, revoked_connections: 0, revoked_challenges: 0 }" + ); + writer.close().await.expect("close"); + + let old = open_myc_state_read_write( + &runtime, + &current_metadata, + MigrationAppliedAtUnixSeconds::new(1_725_000_003).unwrap(), + &build(), + ) + .await + .expect_err("old configuration must no longer bind"); + assert_eq!(old.kind(), MycStateHostErrorKind::Repository); + + let candidate_metadata = metadata(&runtime, &candidate); + let writer = open_myc_state_read_write( + &runtime, + &candidate_metadata, + MigrationAppliedAtUnixSeconds::new(1_725_000_003).unwrap(), + &build(), + ) + .await + .expect("candidate startup"); + let replay = writer + .repository() + .apply_configuration( + &candidate, + &candidate, + MigrationAppliedAtUnixSeconds::new(1_725_000_004).unwrap(), + &build(), + ) + .await + .expect("exact replay is an idempotent no-op"); + assert_eq!(replay.generation(), 2); + assert_eq!(replay.revoked_connection_count(), 0); + assert_eq!(replay.revoked_challenge_count(), 0); + writer.close().await.expect("close candidate"); + + let inspection = open_myc_state_inspection(&runtime, &candidate_metadata) + .await + .expect("inspection"); + let mode = inspection + .repository() + .apply_configuration( + &candidate, + &current, + MigrationAppliedAtUnixSeconds::new(1_725_000_004).unwrap(), + &build(), + ) + .await + .expect_err("inspection cannot apply"); + assert_eq!(mode.kind(), MycConfigApplyErrorKind::InvalidMode); + inspection.close().await.expect("inspection close"); + + let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime)) + .await + .expect("inspect database"); + let rows = sqlx::query( + "SELECT generation, normalized_config_sha256 FROM myc_config_bindings ORDER BY generation", + ) + .fetch_all(&mut connection) + .await + .expect("binding history"); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].get::<i64, _>(0), 1); + assert_eq!(rows[1].get::<i64, _>(0), 2); + assert_eq!( + rows[0].get::<Vec<u8>, _>(1), + current_metadata.configuration_digest().as_bytes() + ); + assert_eq!( + rows[1].get::<Vec<u8>, _>(1), + candidate_metadata.configuration_digest().as_bytes() + ); + let birth = sqlx::query_scalar::<_, Vec<u8>>( + "SELECT normalized_config_sha256 FROM myc_state_metadata WHERE singleton = 1", + ) + .fetch_one(&mut connection) + .await + .expect("birth binding"); + assert_eq!(birth, current_metadata.configuration_digest().as_bytes()); + let persisted = sqlx::query( + "SELECT service_version, service_commit, lib_revision, rust_version, target, \ + feature_profile FROM myc_config_bindings ORDER BY generation", + ) + .fetch_all(&mut connection) + .await + .expect("safe binding evidence"); + assert_eq!(persisted.len(), 2); + let database_bytes = fs::read(runtime.artifacts().state_database()).expect("database bytes"); + for forbidden in [ + "wss://relay-primary.example.test/", + "encrypted_file", + "transport.key", + directory.path().to_str().expect("UTF-8 temporary path"), + ] { + assert!( + !database_bytes + .windows(forbidden.len()) + .any(|window| window == forbidden.as_bytes()), + "configuration history persisted forbidden source material" + ); + } + connection.close().await.expect("close database"); +} + +#[tokio::test] +async fn v9_upgrade_seeds_one_v10_binding_without_rewriting_birth_evidence() { + let directory = tempfile::tempdir().expect("root"); + let runtime = runtime(directory.path()); + prepare(&runtime); + let current = configuration(CONFIG); + let current_metadata = metadata(&runtime, &current); + initialize_v9(&runtime, &current_metadata).await; + + let writer = open_myc_state_read_write( + &runtime, + &current_metadata, + MigrationAppliedAtUnixSeconds::new(1_725_000_010).unwrap(), + &build(), + ) + .await + .expect("upgrade to v10"); + writer.close().await.expect("close upgraded writer"); + + let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime)) + .await + .expect("inspect upgrade"); + let birth_version = sqlx::query_scalar::<_, i64>( + "SELECT state_contract_version FROM myc_state_metadata WHERE singleton = 1", + ) + .fetch_one(&mut connection) + .await + .expect("birth version"); + assert_eq!(birth_version, 9); + let binding = sqlx::query( + "SELECT generation, state_contract_version, applied_at_unix_s, \ + normalized_config_sha256 FROM myc_config_bindings", + ) + .fetch_one(&mut connection) + .await + .expect("seed binding"); + assert_eq!(binding.get::<i64, _>("generation"), 1); + assert_eq!(binding.get::<i64, _>("state_contract_version"), 10); + assert_eq!(binding.get::<i64, _>("applied_at_unix_s"), 1_725_000_010); + assert_eq!( + binding.get::<Vec<u8>, _>("normalized_config_sha256"), + current_metadata.configuration_digest().as_bytes() + ); + connection.close().await.expect("close inspection"); +} + +#[tokio::test] +async fn relay_change_is_blocked_by_nonterminal_work_but_safe_addition_is_admitted() { + let directory = tempfile::tempdir().expect("root"); + let runtime = runtime(directory.path()); + prepare(&runtime); + let current = configuration(CONFIG); + let current_metadata = metadata(&runtime, &current); + initialize(&runtime, &current_metadata).await; + + let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime)) + .await + .expect("database"); + sqlx::query( + "INSERT INTO nip46_requests (operation_id, correlation_id, operation_nonce, \ + request_identity_sha256, client_public_key, request_id, first_event_id, \ + method, request_sha256, received_at_unix_ms) VALUES (?, ?, ?, ?, ?, \ + 'config-lifecycle-job', ?, 'ping', ?, 1)", + ) + .bind([0x12_u8; 32].as_slice()) + .bind([0x21_u8; 32].as_slice()) + .bind([0x22_u8; 32].as_slice()) + .bind([0x23_u8; 32].as_slice()) + .bind("7777777777777777777777777777777777777777777777777777777777777777") + .bind([0x24_u8; 32].as_slice()) + .bind([0x25_u8; 32].as_slice()) + .execute(&mut connection) + .await + .expect("request source"); + sqlx::query( + "INSERT INTO delivery_jobs (job_id, source_kind, source_id, artifact_sha256, \ + policy_mode, required_acknowledgements, max_attempts, initial_backoff_ms, \ + maximum_backoff_ms, attempt_deadline_ms, status, created_at_unix_ms, \ + updated_at_unix_ms, finalized_at_unix_ms) VALUES (?, 'signer_response', ?, ?, \ + 'all_required', 1, 2, 1, 2, 2, 'pending', 1, 1, NULL)", + ) + .bind([0x11_u8; 32].as_slice()) + .bind([0x12_u8; 32].as_slice()) + .bind([0x13_u8; 32].as_slice()) + .execute(&mut connection) + .await + .expect("job"); + sqlx::query( + "INSERT INTO delivery_targets (job_id, target_index, relay_id, required, \ + attempt_count, status, active_attempt_id, next_attempt_at_unix_ms, \ + updated_at_unix_ms) VALUES (?, 0, 'primary', 1, 0, 'pending', NULL, NULL, 1)", + ) + .bind([0x11_u8; 32].as_slice()) + .execute(&mut connection) + .await + .expect("target"); + connection.close().await.expect("close database"); + + let writer = open_myc_state_read_write( + &runtime, + &current_metadata, + MigrationAppliedAtUnixSeconds::new(1_725_000_001).unwrap(), + &build(), + ) + .await + .expect("writer"); + let changed = configuration(&CONFIG.replace( + "wss://relay-primary.example.test/", + "wss://relay-primary-next.example.test/", + )); + let conflict = writer + .repository() + .apply_configuration( + &current, + &changed, + MigrationAppliedAtUnixSeconds::new(1_725_000_002).unwrap(), + &build(), + ) + .await + .expect_err("retained job blocks relay mutation"); + assert_eq!(conflict.kind(), MycConfigApplyErrorKind::PolicyConflict); + + let authentication_changed = configuration(&CONFIG.replacen( + "authentication = \"required\"", + "authentication = \"disabled\"", + 1, + )); + let conflict = writer + .repository() + .apply_configuration( + &current, + &authentication_changed, + MigrationAppliedAtUnixSeconds::new(1_725_000_002).unwrap(), + &build(), + ) + .await + .expect_err("retained job blocks relay authentication mutation"); + assert_eq!(conflict.kind(), MycConfigApplyErrorKind::PolicyConflict); + + let addition_source = CONFIG.replace( + "[transport]\n", + "[[relays]]\nid = \"tertiary\"\nurl = \"wss://relay-tertiary.example.test/\"\nread = true\nwrite = true\nrequired = false\nauthentication = \"required\"\n\n[transport]\n", + ); + let addition = configuration(&addition_source); + let outcome = writer + .repository() + .apply_configuration( + &current, + &addition, + MigrationAppliedAtUnixSeconds::new(1_725_000_003).unwrap(), + &build(), + ) + .await + .expect("safe relay addition"); + assert_eq!(outcome.generation(), 2); + writer.close().await.expect("close"); +} + +#[tokio::test] +async fn identity_change_atomically_revokes_live_connections_and_pending_challenges() { + let directory = tempfile::tempdir().expect("root"); + let runtime = runtime(directory.path()); + prepare(&runtime); + let current = configuration(CONFIG); + let current_metadata = metadata(&runtime, &current); + initialize(&runtime, &current_metadata).await; + + let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime)) + .await + .expect("database"); + for (id, status) in [(0x31_u8, "active"), (0x32_u8, "pending")] { + sqlx::query( + "INSERT INTO connections (connection_id, connection_nonce, client_public_key, \ + requested_permissions_sha256, policy_generation, status, created_at_unix_ms, \ + updated_at_unix_ms, authorized_until_unix_ms) VALUES (?, ?, ?, ?, 1, ?, 1, 1, ?)", + ) + .bind([id; 32].as_slice()) + .bind([id.saturating_add(16); 32].as_slice()) + .bind("7777777777777777777777777777777777777777777777777777777777777777") + .bind([0x41_u8; 32].as_slice()) + .bind(status) + .bind((status == "active").then_some(10_000_i64)) + .execute(&mut connection) + .await + .expect("connection"); + } + sqlx::query( + "INSERT INTO nip46_requests (operation_id, correlation_id, operation_nonce, \ + request_identity_sha256, client_public_key, request_id, first_event_id, method, \ + request_sha256, received_at_unix_ms) VALUES (?, ?, ?, ?, ?, 'identity-change', ?, \ + 'connect', ?, 1)", + ) + .bind([0x33_u8; 32].as_slice()) + .bind([0x34_u8; 32].as_slice()) + .bind([0x35_u8; 32].as_slice()) + .bind([0x36_u8; 32].as_slice()) + .bind("7777777777777777777777777777777777777777777777777777777777777777") + .bind([0x37_u8; 32].as_slice()) + .bind([0x38_u8; 32].as_slice()) + .execute(&mut connection) + .await + .expect("request"); + sqlx::query( + "INSERT INTO connection_auth_challenges (challenge_id, challenge_nonce, \ + connection_id, operation_id, policy_generation, challenge_url, state, \ + issued_at_unix_ms, expires_at_unix_ms, resolved_at_unix_ms) \ + VALUES (?, ?, ?, ?, 1, 'https://myc.example.test/challenge', 'pending', 1, 10000, NULL)", + ) + .bind([0x39_u8; 32].as_slice()) + .bind([0x3a_u8; 32].as_slice()) + .bind([0x31_u8; 32].as_slice()) + .bind([0x33_u8; 32].as_slice()) + .execute(&mut connection) + .await + .expect("challenge"); + connection.close().await.expect("close database"); + + let candidate = configuration(&CONFIG.replace( + "expected_public_key = \"2222222222222222222222222222222222222222222222222222222222222222\"", + "expected_public_key = \"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\"", + )); + let writer = open_myc_state_read_write( + &runtime, + &current_metadata, + MigrationAppliedAtUnixSeconds::new(1_725_000_001).unwrap(), + &build(), + ) + .await + .expect("writer"); + let outcome = writer + .repository() + .apply_configuration( + &current, + &candidate, + MigrationAppliedAtUnixSeconds::new(1_725_000_002).unwrap(), + &build(), + ) + .await + .expect("identity apply"); + assert_eq!(outcome.revoked_connection_count(), 2); + assert_eq!(outcome.revoked_challenge_count(), 1); + writer.close().await.expect("close writer"); + + let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime)) + .await + .expect("inspect"); + let statuses = + sqlx::query_scalar::<_, String>("SELECT status FROM connections ORDER BY connection_id") + .fetch_all(&mut connection) + .await + .expect("connection statuses"); + assert_eq!(statuses, ["expired", "denied"]); + let state = sqlx::query_scalar::<_, String>( + "SELECT state FROM connection_auth_challenges WHERE challenge_id = ?", + ) + .bind([0x39_u8; 32].as_slice()) + .fetch_one(&mut connection) + .await + .expect("challenge state"); + assert_eq!(state, "expired"); + connection.close().await.expect("close inspect"); +} + +#[tokio::test] +async fn permission_narrowing_revokes_only_connections_with_removed_grants() { + let directory = tempfile::tempdir().expect("root"); + let runtime = runtime(directory.path()); + prepare(&runtime); + let current = configuration(CONFIG); + let current_metadata = metadata(&runtime, &current); + initialize(&runtime, &current_metadata).await; + + let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime)) + .await + .expect("database"); + for (id, permission) in [(0x51_u8, "sign_event:kind:1"), (0x52_u8, "nip04_encrypt")] { + sqlx::query( + "INSERT INTO connections (connection_id, connection_nonce, client_public_key, \ + requested_permissions_sha256, policy_generation, status, created_at_unix_ms, \ + updated_at_unix_ms, authorized_until_unix_ms) VALUES (?, ?, ?, ?, 1, \ + 'active', 1, 1, 10000)", + ) + .bind([id; 32].as_slice()) + .bind([id.saturating_add(16); 32].as_slice()) + .bind("7777777777777777777777777777777777777777777777777777777777777777") + .bind([id.saturating_add(32); 32].as_slice()) + .execute(&mut connection) + .await + .expect("connection"); + sqlx::query( + "INSERT INTO connection_permissions (connection_id, permission_scope, \ + permission_code) VALUES (?, 'granted', ?)", + ) + .bind([id; 32].as_slice()) + .bind(permission) + .execute(&mut connection) + .await + .expect("permission"); + } + connection.close().await.expect("close database"); + + let candidate_source = CONFIG + .replace( + "permission_ceiling = [\"nip04_decrypt\", \"nip04_encrypt\", \"nip44_decrypt\", \"nip44_encrypt\", \"sign_event:kind:1\"]", + "permission_ceiling = [\"nip04_decrypt\", \"nip04_encrypt\", \"nip44_decrypt\", \"nip44_encrypt\", \"sign_event:kind:2\"]", + ) + .replace("allowed_sign_event_kinds = [1]", "allowed_sign_event_kinds = [2]"); + let candidate = configuration(&candidate_source); + let writer = open_myc_state_read_write( + &runtime, + &current_metadata, + MigrationAppliedAtUnixSeconds::new(1_725_000_001).unwrap(), + &build(), + ) + .await + .expect("writer"); + let outcome = writer + .repository() + .apply_configuration( + &current, + &candidate, + MigrationAppliedAtUnixSeconds::new(1_725_000_002).unwrap(), + &build(), + ) + .await + .expect("narrowing apply"); + assert_eq!(outcome.revoked_connection_count(), 1); + writer.close().await.expect("close writer"); + + let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime)) + .await + .expect("inspect"); + let rows = sqlx::query("SELECT connection_id, status FROM connections ORDER BY connection_id") + .fetch_all(&mut connection) + .await + .expect("statuses"); + assert_eq!(rows[0].get::<String, _>(1), "expired"); + assert_eq!(rows[1].get::<String, _>(1), "active"); + connection.close().await.expect("close inspect"); +} + +#[tokio::test] +async fn exact_history_capacity_fails_with_resource_exhausted_without_mutation() { + let directory = tempfile::tempdir().expect("root"); + let runtime = runtime(directory.path()); + prepare(&runtime); + let current = configuration(CONFIG); + let current_metadata = metadata(&runtime, &current); + initialize(&runtime, &current_metadata).await; + + let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime)) + .await + .expect("database"); + sqlx::query( + "WITH RECURSIVE generation(value) AS (VALUES(2) UNION ALL \ + SELECT value + 1 FROM generation WHERE value < 1024) \ + INSERT INTO myc_config_bindings (generation, normalized_config_sha256, \ + transport_public_key, user_public_key, discovery_public_key, \ + config_contract_version, state_contract_version, operator_contract_version, \ + status_contract_version, applied_at_unix_s, service_version, service_commit, \ + lib_revision, rust_version, target, feature_profile, provider_contract_version) \ + SELECT generation.value, binding.normalized_config_sha256, \ + binding.transport_public_key, binding.user_public_key, binding.discovery_public_key, \ + binding.config_contract_version, binding.state_contract_version, \ + binding.operator_contract_version, binding.status_contract_version, \ + binding.applied_at_unix_s, binding.service_version, binding.service_commit, \ + binding.lib_revision, binding.rust_version, binding.target, binding.feature_profile, \ + binding.provider_contract_version FROM generation \ + CROSS JOIN myc_config_bindings AS binding WHERE binding.generation = 1", + ) + .execute(&mut connection) + .await + .expect("fill bounded history"); + connection.close().await.expect("close database"); + + let writer = open_myc_state_read_write( + &runtime, + &current_metadata, + MigrationAppliedAtUnixSeconds::new(1_725_000_001).unwrap(), + &build(), + ) + .await + .expect("writer"); + let candidate = configuration(&CONFIG.replace("level = \"info\"", "level = \"warn\"")); + let error = writer + .repository() + .apply_configuration( + &current, + &candidate, + MigrationAppliedAtUnixSeconds::new(1_725_000_002).unwrap(), + &build(), + ) + .await + .expect_err("full history"); + assert_eq!(error.kind(), MycConfigApplyErrorKind::ResourceExhausted); + assert_eq!(error.code(), "resource_exhausted"); + assert!(Error::source(&error).is_none()); + writer.close().await.expect("close writer"); + + let mut connection = sqlx::SqliteConnection::connect_with(&options(&runtime)) + .await + .expect("inspect"); + let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM myc_config_bindings") + .fetch_one(&mut connection) + .await + .expect("count"); + assert_eq!(count, 1024); + connection.close().await.expect("close inspect"); +} + +#[test] +fn configuration_lifecycle_surface_is_sealed_and_diagnostics_are_safe() { + assert!(LIB_SOURCE.contains("mod state_config;")); + assert!(!LIB_SOURCE.contains("pub mod state_config;")); + for forbidden in [ + "pub transaction:", + "pub connection:", + "pub pool:", + "credential_reference", + "envelope_path", + "relay_url TEXT", + "DELETE FROM myc_config_bindings", + "UPDATE myc_config_bindings", + ] { + assert!( + !CONFIG_SOURCE.contains(forbidden), + "forbidden configuration-history surface `{forbidden}`" + ); + } + for kind in [ + MycConfigApplyErrorKind::InvalidMode, + MycConfigApplyErrorKind::InvalidInput, + MycConfigApplyErrorKind::Binding, + MycConfigApplyErrorKind::PolicyConflict, + MycConfigApplyErrorKind::ResourceExhausted, + MycConfigApplyErrorKind::Transaction, + MycConfigApplyErrorKind::CommitOutcomeUnknown, + ] { + let rendered = format!("{kind:?} {}", kind.code()); + for secret in ["relay-primary", "credential", "state.sqlite", "/var/lib"] { + assert!(!rendered.contains(secret)); + } + } + let error = MycConfigApplyErrorKind::PolicyConflict; + assert_eq!(error.code(), "config_apply_policy_conflict"); + let _source_free: fn(&myc::MycConfigApplyError) -> Option<&(dyn Error + 'static)> = + Error::source; +} diff --git a/tests/services_hardening_native_release.rs b/tests/services_hardening_native_release.rs @@ -53,7 +53,7 @@ fn native_release_contract_and_manifest_metadata_are_exact() { }, "contract_versions": { "config": 1, - "state": 9, + "state": 10, "admin": 1, "status": 1, "provider": 1 @@ -114,7 +114,7 @@ fn native_release_contract_and_manifest_metadata_are_exact() { host_feature_profile = "service-host" nix_material = "deferred" config_contract_version = 1 - state_contract_version = 9 + state_contract_version = 10 admin_contract_version = 1 status_contract_version = 1 provider_contract_version = 1 diff --git a/tests/services_hardening_state_catalog.rs b/tests/services_hardening_state_catalog.rs @@ -17,8 +17,10 @@ use myc::{ MYC_STATE_SCHEMA_VERSION_7_SHA256, MYC_STATE_SCHEMA_VERSION_8_MIGRATION_SHA256, MYC_STATE_SCHEMA_VERSION_8_OBJECT_COUNT, MYC_STATE_SCHEMA_VERSION_8_SHA256, MYC_STATE_SCHEMA_VERSION_9_MIGRATION_SHA256, MYC_STATE_SCHEMA_VERSION_9_OBJECT_COUNT, - MYC_STATE_SCHEMA_VERSION_9_SHA256, MycStateCatalogErrorKind, myc_migration_catalog, - myc_schema_catalog, validate_myc_state_catalogs, + MYC_STATE_SCHEMA_VERSION_9_SHA256, MYC_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256, + MYC_STATE_SCHEMA_VERSION_10_OBJECT_COUNT, MYC_STATE_SCHEMA_VERSION_10_SHA256, + MycStateCatalogErrorKind, myc_migration_catalog, myc_schema_catalog, + validate_myc_state_catalogs, }; use radroots_service_sqlite::{ MigrationCatalog, MigrationChecksum, MigrationDescriptor, SchemaCatalog, SchemaDigest, @@ -30,13 +32,13 @@ const LIB_SOURCE: &str = include_str!("../src/lib.rs"); const MANIFEST: &str = include_str!("../Cargo.toml"); #[test] -fn schema_v1_through_v9_and_all_migrations_have_exact_literal_identities() { +fn schema_v1_through_v10_and_all_migrations_have_exact_literal_identities() { let migrations = myc_migration_catalog().expect("Myc migration catalog"); let schema = myc_schema_catalog().expect("Myc schema catalog"); assert_eq!(MYC_STATE_BASE_SCHEMA_VERSION, 1); - assert_eq!(MYC_STATE_SCHEMA_VERSION, 9); - assert_eq!(migrations.descriptors().len(), 8); + assert_eq!(MYC_STATE_SCHEMA_VERSION, 10); + assert_eq!(migrations.descriptors().len(), 9); let metadata = &migrations.descriptors()[0]; assert_eq!(metadata.target_version(), 2); assert_eq!(metadata.name().as_str(), "create_myc_state_metadata"); @@ -102,13 +104,23 @@ fn schema_v1_through_v9_and_all_migrations_have_exact_literal_identities() { response.checksum().as_bytes(), &MYC_STATE_SCHEMA_VERSION_9_MIGRATION_SHA256 ); - assert_eq!(migrations.current_version(), 9); + let configuration = &migrations.descriptors()[8]; + assert_eq!(configuration.target_version(), 10); + assert_eq!( + configuration.name().as_str(), + "create_configuration_binding_history" + ); + assert_eq!( + configuration.checksum().as_bytes(), + &MYC_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256 + ); + assert_eq!(migrations.current_version(), 10); assert_eq!( migrations.digest().as_bytes(), &MYC_MIGRATION_CATALOG_SHA256 ); - assert_eq!(schema.versions().len(), 9); + assert_eq!(schema.versions().len(), 10); assert_eq!(schema.versions()[0].version(), 1); assert_eq!( schema.versions()[0].object_count(), @@ -198,6 +210,16 @@ fn schema_v1_through_v9_and_all_migrations_have_exact_literal_identities() { schema.versions()[8].digest().as_bytes(), &MYC_STATE_SCHEMA_VERSION_9_SHA256 ); + assert_eq!(schema.versions()[9].version(), 10); + assert_eq!( + schema.versions()[9].object_count(), + MYC_STATE_SCHEMA_VERSION_10_OBJECT_COUNT + ); + assert_eq!(schema.versions()[9].object_count(), 63); + assert_eq!( + schema.versions()[9].digest().as_bytes(), + &MYC_STATE_SCHEMA_VERSION_10_SHA256 + ); assert_eq!(schema.digest().as_bytes(), &MYC_STATE_SCHEMA_CATALOG_SHA256); assert_eq!(schema.migration_catalog_digest(), migrations.digest()); validate_myc_state_catalogs(&migrations, &schema).expect("exact catalogs"); @@ -208,7 +230,7 @@ fn schema_v1_through_v9_and_all_migrations_have_exact_literal_identities() { ); assert_eq!( hex::encode(MYC_MIGRATION_CATALOG_SHA256), - "8604c51ea6f16f0aa37a63eee09c600a0b7031efbc8e5e0e41b5f0f53c48e70b" + "5d6e0c8b832e66715abe176133d4433d52e6d18125aefdbc9d550515fd2121f2" ); assert_eq!( hex::encode(MYC_STATE_SCHEMA_VERSION_1_SHA256), @@ -220,7 +242,7 @@ fn schema_v1_through_v9_and_all_migrations_have_exact_literal_identities() { ); assert_eq!( hex::encode(MYC_STATE_SCHEMA_CATALOG_SHA256), - "632c8d5216d2a541fd28ae3a2cae8069c58bef11fe81d2f04399a77cf8359ea7" + "1f88c79f86ae472489f62ddc9661a681dcfb1ed7ff723a07d97ba776b036a25a" ); assert_eq!( hex::encode(MYC_STATE_SCHEMA_VERSION_3_MIGRATION_SHA256), @@ -278,6 +300,14 @@ fn schema_v1_through_v9_and_all_migrations_have_exact_literal_identities() { hex::encode(MYC_STATE_SCHEMA_VERSION_9_SHA256), "eee76f4ff0bd2dc2c061ae384e00de16c5f5efc4b6edd0ac7f73cad83a991ff7" ); + assert_eq!( + hex::encode(MYC_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256), + "28423ebb59f4b26223307b74a7e12905ca4818c01a655203ee8d63c911f44489" + ); + assert_eq!( + hex::encode(MYC_STATE_SCHEMA_VERSION_10_SHA256), + "b77ed23afa39ff45dda250faa1ebfc0587c34462658fc49fb7b2fbc8909ba303" + ); } #[test] @@ -336,9 +366,15 @@ fn independent_validator_rejects_migration_or_schema_drift() { let v8 = SchemaVersionCatalog::new(8, [object.clone()], v8_digest).expect("schema v8"); let v9_digest = SchemaVersionCatalog::computed_digest(9, [object.clone()]).expect("schema-v9 digest"); - let v9 = SchemaVersionCatalog::new(9, [object], v9_digest).expect("schema v9"); - let schema = SchemaCatalog::new(&expected_migrations, [v1, v2, v3, v4, v5, v6, v7, v8, v9]) - .expect("drift schema catalog"); + let v9 = SchemaVersionCatalog::new(9, [object.clone()], v9_digest).expect("schema v9"); + let v10_digest = + SchemaVersionCatalog::computed_digest(10, [object.clone()]).expect("schema-v10 digest"); + let v10 = SchemaVersionCatalog::new(10, [object], v10_digest).expect("schema v10"); + let schema = SchemaCatalog::new( + &expected_migrations, + [v1, v2, v3, v4, v5, v6, v7, v8, v9, v10], + ) + .expect("drift schema catalog"); assert_eq!( validate_myc_state_catalogs(&expected_migrations, &schema) .expect_err("schema drift") diff --git a/tests/services_hardening_state_repository.rs b/tests/services_hardening_state_repository.rs @@ -120,7 +120,7 @@ async fn initialization_migrates_and_binds_exact_metadata_before_inspection() { .fetch_all(&mut connection) .await .expect("migration rows"); - assert_eq!(migrations.len(), 8); + assert_eq!(migrations.len(), 9); assert_eq!(migrations[0].get::<i64, _>(0), 2); assert_eq!( migrations[0].get::<String, _>(1), @@ -161,6 +161,11 @@ async fn initialization_migrates_and_binds_exact_metadata_before_inspection() { migrations[7].get::<String, _>(1), "create_nip46_atomic_response" ); + assert_eq!(migrations[8].get::<i64, _>(0), 10); + assert_eq!( + migrations[8].get::<String, _>(1), + "create_configuration_binding_history" + ); let binding = sqlx::query( "SELECT normalized_config_sha256, transport_public_key, user_public_key, \ discovery_public_key, config_contract_version, state_contract_version, \ @@ -283,7 +288,9 @@ async fn repository_boundary_is_sealed_typed_redacted_and_network_free() { "BEGIN ", "COMMIT", "ROLLBACK", - "provider", + "MycProvider", + "provider_credential", + "provider_envelope", "relay", "reqwest", "nostr::",