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:
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
);
}