myc

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

commit 3bbe2364f3b01b7066294f5ce0f12fd05a3b3016
parent 331e5983dae96ee3d243f80dbfeb864745858987
Author: triesap <tyson@radroots.org>
Date:   Tue, 25 Aug 2026 04:04:46 +0000

nip46: bind responses to transport authority

Diffstat:
MAGENTS.md | 2+-
MREADME | 2+-
Mcontracts/services_hardening/nip46_pending_response.v1.json | 2+-
Mcontracts/services_hardening/nip46_response_commit.v1.json | 2+-
Msrc/nip46_wave_080_b.rs | 76++++++++++++++++++++++++++++++++++++++++------------------------------------
Msrc/provider_contract.rs | 5+++--
Msrc/provider_executor.rs | 11+++++------
Msrc/runtime_nip46.rs | 10+++++-----
Msrc/state_recovery.rs | 10+++++++++-
Msrc/state_response.rs | 4++--
Mtests/package_boundary.rs | 2++
Mtests/services_hardening_provider_contract.rs | 18+++++++++++++-----
12 files changed, 83 insertions(+), 61 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -194,7 +194,7 @@ root, arbitrary member name, Nix/NixOS/OCI input or output, signing key, parent-owned human document, private harness, protected material, or publication/deployment authority. -- Step 221 integration requires a signed `pending_connection` response for an +- Step 221 integration requires a signer-transport-authored `pending_connection` response for an explicitly approval-gated NIP-46 connect request. Keep that exact response and its initial delivery job atomic and immutable without recording a false terminal operation completion; relay delivery and exact replay use only the diff --git a/README b/README @@ -255,7 +255,7 @@ relay. Required relay subscriptions and provider handshakes complete before Ready; bounded reconnect publishes Unready when a required dependency is lost, while optional operations loss publishes Degraded. NIP-46 dispatch verifies, decrypts, admits, authorizes, executes providers outside transactions, -re-encrypts in the verified request context, signs through the user provider, +re-encrypts in the verified request context, signs through the transport provider, and atomically commits completion, exact response bytes, immutable targets, and initial outbox state. Completed replay never re-executes a provider. diff --git a/contracts/services_hardening/nip46_pending_response.v1.json b/contracts/services_hardening/nip46_pending_response.v1.json @@ -8,7 +8,7 @@ "decision": "immutable_explicit_approval_pending_decision", "connection": "same_pending_connection_and_policy_generation", "response": "independently_signature_verified_canonical_kind_24133_pending_connection_event", - "response_signer": "exact_bound_user_provider_operation", + "response_signer": "exact_bound_transport_provider_operation", "recipient": "exact_original_nip46_client_public_key", "targets_and_retry_policy": "normalized_configuration_bound_in_state_metadata", "time": "caller_injected_positive_unix_milliseconds", diff --git a/contracts/services_hardening/nip46_response_commit.v1.json b/contracts/services_hardening/nip46_response_commit.v1.json @@ -6,7 +6,7 @@ "authority": { "completion": "sealed_step147_completion_component", "response": "independently_signature_verified_canonical_kind_24133_event", - "response_signer": "exact_bound_user_provider_operation", + "response_signer": "exact_bound_transport_provider_operation", "recipient": "exact_original_nip46_client_public_key", "targets_and_retry_policy": "normalized_configuration_bound_in_state_metadata", "time": "caller_injected_positive_unix_milliseconds", diff --git a/src/nip46_wave_080_b.rs b/src/nip46_wave_080_b.rs @@ -24,6 +24,7 @@ use super::nip46_wave_080_a::{ connect_request, connection_time, keys, metadata, migration_evidence, permissions, prepared_request, runtime, unsigned_sign_event, untrusted_response, }; +use crate::provider_verification::verify_encrypted_provider_response; use crate::state_response::MycNip46PendingResponseCommitRequest; pub(crate) async fn active_connection( @@ -148,7 +149,7 @@ fn pending_response_request( decision: &crate::MycConnectionDecisionRecord, ) -> (MycNip46PendingResponseCommitRequest, Vec<u8>) { let unsigned = NostrUnsignedEvent::new( - keys(3).public_key(), + keys(2).public_key(), Timestamp::from_secs(OBSERVED_AT_SECONDS + 41), Kind::Custom(24_133), vec![Tag::public_key(keys(10).public_key())], @@ -157,8 +158,8 @@ fn pending_response_request( let operation = MycProviderOperation::new( config .provider_contract() - .binding(MycProviderRole::User) - .expect("user binding"), + .binding(MycProviderRole::Transport) + .expect("transport binding"), MycProviderOperationId::from_bytes([0xa1; 32]), MycProviderCorrelationId::from_bytes([0xa2; 32]), MycProviderDeadlineUnixMs::new(PROVIDER_DEADLINE_MS).expect("provider deadline"), @@ -167,27 +168,21 @@ fn pending_response_request( ) .expect("pending response operation"); let signed = unsigned - .sign_with_keys(&keys(3)) + .sign_with_keys(&keys(2)) .expect("signed pending response"); let bytes = serde_json::to_vec(&signed).expect("canonical pending response"); - let response: MycLocalSignerUntrustedResponse = untrusted_response( + let verified = verify_encrypted_provider_response( + config + .provider_contract() + .binding(MycProviderRole::Transport) + .expect("transport binding"), &operation, - hex::encode(operation.correlation_id().as_bytes()), + MycProviderResponseObservedAtUnixMs::new(RECEIVED_AT_MS + 41_001).expect("response time"), WireProviderResult::SignEvent { payload_hex: ProtectedWireHex::from_bytes(&bytes), }, - ); - let verified = response - .verify( - config - .provider_contract() - .binding(MycProviderRole::User) - .expect("user binding"), - &operation, - MycProviderResponseObservedAtUnixMs::new(RECEIVED_AT_MS + 41_001) - .expect("response time"), - ) - .expect("verified pending response"); + ) + .expect("verified pending response"); let request = MycNip46PendingResponseCommitRequest::new( work, decision, @@ -252,6 +247,21 @@ async fn pending_approval_response_and_delivery_job_commit_atomically_and_replay .expect("pending response read") .expect("retained pending response"); assert_eq!(by_job, committed); + repository + .verify_delivery_invariants() + .await + .expect("pending response satisfies delivery invariants"); + repository + .recover_delivery_state( + crate::MycDeliveryTimeUnixMs::new(RECEIVED_AT_MS + 41_004).expect("recovery time"), + crate::MycDeliveryRecoveryEntropy::from_injected_entropy([0xa3; 32]), + ) + .await + .expect("pending response survives restart recovery"); + repository + .verify_delivery_invariants() + .await + .expect("recovered pending response satisfies delivery invariants"); host.close().await.expect("host close"); let options = SqliteConnectOptions::new() @@ -297,7 +307,7 @@ pub(crate) fn atomic_response_request( completion: &MycNip46CommitRequest, ) -> (MycNip46ResponseCommitRequest, Vec<u8>) { let unsigned = NostrUnsignedEvent::new( - keys(3).public_key(), + keys(2).public_key(), Timestamp::from_secs(OBSERVED_AT_SECONDS + 3), Kind::Custom(24_133), vec![Tag::public_key(keys(10).public_key())], @@ -306,8 +316,8 @@ pub(crate) fn atomic_response_request( let operation = MycProviderOperation::new( config .provider_contract() - .binding(MycProviderRole::User) - .expect("user binding"), + .binding(MycProviderRole::Transport) + .expect("transport binding"), MycProviderOperationId::from_bytes([0x91; 32]), MycProviderCorrelationId::from_bytes([0x92; 32]), MycProviderDeadlineUnixMs::new(PROVIDER_DEADLINE_MS).expect("provider deadline"), @@ -315,26 +325,20 @@ pub(crate) fn atomic_response_request( .expect("response signing input"), ) .expect("response operation"); - let signed = unsigned.sign_with_keys(&keys(3)).expect("signed response"); + let signed = unsigned.sign_with_keys(&keys(2)).expect("signed response"); let bytes = serde_json::to_vec(&signed).expect("canonical response"); - let response: MycLocalSignerUntrustedResponse = untrusted_response( + let verified = verify_encrypted_provider_response( + config + .provider_contract() + .binding(MycProviderRole::Transport) + .expect("transport binding"), &operation, - hex::encode(operation.correlation_id().as_bytes()), + MycProviderResponseObservedAtUnixMs::new(RECEIVED_AT_MS + 3_001).expect("response time"), WireProviderResult::SignEvent { payload_hex: ProtectedWireHex::from_bytes(&bytes), }, - ); - let verified = response - .verify( - config - .provider_contract() - .binding(MycProviderRole::User) - .expect("user binding"), - &operation, - MycProviderResponseObservedAtUnixMs::new(RECEIVED_AT_MS + 3_001) - .expect("response time"), - ) - .expect("verified response"); + ) + .expect("verified response"); let request = MycNip46ResponseCommitRequest::new( completion, &operation, diff --git a/src/provider_contract.rs b/src/provider_contract.rs @@ -153,6 +153,7 @@ impl MycProviderCapabilitySet { match role { MycProviderRole::Transport => Self( common + | MycProviderCapability::SignEvent.bit() | MycProviderCapability::Nip04Encrypt.bit() | MycProviderCapability::Nip04Decrypt.bit() | MycProviderCapability::Nip44Encrypt.bit() @@ -1158,8 +1159,8 @@ mod tests { #[test] fn capability_sets_are_closed_ordered_and_duplicate_free() { let transport = MycProviderCapabilitySet::for_role(MycProviderRole::Transport); - assert_eq!(transport.len(), 6); - assert!(!transport.contains(MycProviderCapability::SignEvent)); + assert_eq!(transport.len(), 7); + assert!(transport.contains(MycProviderCapability::SignEvent)); assert_eq!( MycProviderCapabilitySet::for_role(MycProviderRole::User) .iter() diff --git a/src/provider_executor.rs b/src/provider_executor.rs @@ -425,8 +425,7 @@ const fn capability_allowed_for_role( capability: MycProviderCapability, ) -> bool { match role { - MycProviderRole::Transport => !matches!(capability, MycProviderCapability::SignEvent), - MycProviderRole::User => true, + MycProviderRole::Transport | MycProviderRole::User => true, MycProviderRole::Discovery => matches!( capability, MycProviderCapability::Describe @@ -563,10 +562,10 @@ mod tests { #[test] fn capability_matrix_and_diagnostics_are_closed() { for capability in MycProviderCapability::ALL { - assert_eq!( - capability_allowed_for_role(MycProviderRole::Transport, capability), - !matches!(capability, MycProviderCapability::SignEvent) - ); + assert!(capability_allowed_for_role( + MycProviderRole::Transport, + capability + )); assert!(capability_allowed_for_role( MycProviderRole::User, capability diff --git a/src/runtime_nip46.rs b/src/runtime_nip46.rs @@ -573,17 +573,17 @@ impl MycRuntimeNip46Coordinator { ) -> Result<SignedRuntimeResponse, MycNip46DispatchError> { let content = core::str::from_utf8(ciphertext) .map_err(|_| dispatch_error(MycNip46DispatchErrorKind::Provider))?; - let user_binding = self + let transport_binding = self .configuration .provider_contract() - .binding(MycProviderRole::User) + .binding(MycProviderRole::Transport) .ok_or_else(|| dispatch_error(MycNip46DispatchErrorKind::Runtime))?; - let user = NostrPublicKey::from_hex(user_binding.expected_identity().as_hex()) + let transport = NostrPublicKey::from_hex(transport_binding.expected_identity().as_hex()) .map_err(|_| dispatch_error(MycNip46DispatchErrorKind::Runtime))?; let client = NostrPublicKey::from_hex(work.request_record().client_public_key().as_hex()) .map_err(|_| dispatch_error(MycNip46DispatchErrorKind::Runtime))?; let unsigned = UnsignedEvent::new( - user, + transport, Timestamp::from_secs(completed_at.get() / 1_000), Kind::Custom(NIP46_RPC_KIND), vec![Tag::public_key(client)], @@ -592,7 +592,7 @@ impl MycRuntimeNip46Coordinator { let input = MycProviderOperationInput::sign_event(unsigned.as_json().as_bytes()) .map_err(|_| dispatch_error(MycNip46DispatchErrorKind::Runtime))?; let operation = derived_operation( - user_binding, + transport_binding, work, RESPONSE_SIGN_OPERATION_DOMAIN, RESPONSE_SIGN_CORRELATION_DOMAIN, diff --git a/src/state_recovery.rs b/src/state_recovery.rs @@ -34,13 +34,21 @@ const READ_INVARIANTS_SQL: &str = r#"SELECT SELECT 1 FROM nip46_signed_responses r WHERE r.operation_id = j.source_id AND r.response_sha256 = j.artifact_sha256 + ) AND NOT EXISTS ( + SELECT 1 FROM nip46_pending_responses r + WHERE r.operation_id = j.source_id + AND r.response_sha256 = j.artifact_sha256 )) OR (j.source_kind = 'discovery_handler' AND NOT EXISTS ( SELECT 1 FROM discovery_documents d WHERE d.generation_id = j.source_id AND d.event_sha256 = j.artifact_sha256 ))) AS invalid_sources, - (SELECT COUNT(*) FROM nip46_signed_responses r + (SELECT COUNT(*) FROM ( + SELECT operation_id FROM nip46_signed_responses + UNION ALL + SELECT operation_id FROM nip46_pending_responses + ) r WHERE NOT EXISTS ( SELECT 1 FROM delivery_jobs j WHERE j.source_kind = 'signer_response' AND j.source_id = r.operation_id diff --git a/src/state_response.rs b/src/state_response.rs @@ -234,7 +234,7 @@ impl MycNip46ResponseCommitRequest { response: &MycVerifiedProviderResponse, committed_at: MycDeliveryTimeUnixMs, ) -> Result<Self, MycNip46ResponseCommitError> { - if response_operation.role() != MycProviderRole::User + if response_operation.role() != MycProviderRole::Transport || response_operation.input().capability() != MycProviderCapability::SignEvent || response.operation_id() != response_operation.operation_id() || response.correlation_id() != response_operation.correlation_id() @@ -355,7 +355,7 @@ impl MycNip46PendingResponseCommitRequest { || decision.policy_generation() != connection.policy_generation() || connection.status() != MycConnectionStatus::Pending || connection.client_public_key() != work.request_record().client_public_key() - || response_operation.role() != MycProviderRole::User + || response_operation.role() != MycProviderRole::Transport || response_operation.input().capability() != MycProviderCapability::SignEvent || response.operation_id() != response_operation.operation_id() || response.correlation_id() != response_operation.correlation_id() diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs @@ -813,6 +813,7 @@ fn step148_response_commit_is_one_atomic_exact_byte_authority() { for required in [ "sealed_step147_completion_component", "independently_signature_verified_canonical_kind_24133_event", + "exact_bound_transport_provider_operation", "exact_signed_response_bytes_sha256_and_event_id", "zero_attempt_target_state", "committed_response_bytes_only", @@ -872,6 +873,7 @@ fn step221_pending_response_is_atomic_exact_and_nonterminal() { assert_eq!(contract["terminal_effects"]["session_activation"], false); for required in [ "immutable_explicit_approval_pending_decision", + "exact_bound_transport_provider_operation", "exact_committed_pending_response_bytes", "response_edge_failure_rolls_back_response_and_delivery", "no_terminal_operation_commit_is_created", diff --git a/tests/services_hardening_provider_contract.rs b/tests/services_hardening_provider_contract.rs @@ -131,10 +131,11 @@ fn admitted_configuration_derives_exact_role_bindings_and_limits() { ); assert!(transport.local_signer_limits().is_none()); assert!( - !transport + transport .required_capabilities() .contains(MycProviderCapability::SignEvent) ); + assert_eq!(transport.required_capabilities().len(), 7); let user = contract .binding(MycProviderRole::User) @@ -231,17 +232,24 @@ fn every_operation_is_identity_deadline_and_role_bound_before_execution() { .provider_contract() .binding(MycProviderRole::Transport) .expect("transport"); - let rejected = MycProviderOperation::new( + let response_sign = MycProviderOperation::new( transport, operation_id, correlation_id, deadline, MycProviderOperationInput::sign_event(br#"{"kind":1}"#).expect("event input"), ) - .expect_err("transport cannot sign events"); + .expect("transport response signing operation"); + assert_eq!(response_sign.role(), MycProviderRole::Transport); + assert_eq!(response_sign.instance().as_str(), "transport"); + assert_eq!(response_sign.provider(), MycProviderKind::EncryptedFile); + assert_eq!( + response_sign.expected_identity(), + transport.expected_identity() + ); assert_eq!( - rejected.kind(), - MycProviderContractErrorKind::UnsupportedOperation + response_sign.input().capability(), + MycProviderCapability::SignEvent ); }