commit 5b9644dfa35ed5819090b74176d200ca86b29d07
parent 47dca4d428fd11712a30a7b8a81de181b391910d
Author: triesap <tyson@radroots.org>
Date: Sat, 22 Aug 2026 00:58:07 +0000
feat(myc): bind NIP-46 authorization policy
Diffstat:
8 files changed, 781 insertions(+), 59 deletions(-)
diff --git a/contracts/api_baselines/myc.txt b/contracts/api_baselines/myc.txt
@@ -131,7 +131,7 @@ pub myc::MycConnectionPermission::Nip04Encrypt
pub myc::MycConnectionPermission::Nip44Decrypt
pub myc::MycConnectionPermission::Nip44Encrypt
pub myc::MycConnectionPermission::Ping
-pub myc::MycConnectionPermission::SignEvent(u16)
+pub myc::MycConnectionPermission::SignEvent(u32)
pub myc::MycConnectionPermission::SwitchRelays
pub enum myc::MycConnectionStateErrorKind
pub myc::MycConnectionStateErrorKind::InvalidChallengeLifetime
diff --git a/contracts/services_hardening/nip46_authorization.v1.json b/contracts/services_hardening/nip46_authorization.v1.json
@@ -0,0 +1,45 @@
+{
+ "schema": "radroots.myc.nip46-authorization.v1",
+ "contract_version": 1,
+ "prerequisites": ["nip46_replay.v1", "myc_state_schema_v5"],
+ "policy_authority": "normalized_configuration_bound_in_myc_state_metadata",
+ "admission_precedence": [
+ "configured_denied_client_is_direct_denial_without_connection_or_rate_window",
+ "configured_trusted_client_is_active_only_with_permissions_within_ceiling_and_bounded_authorization_lifetime",
+ "configured_trusted_and_unknown_client_admissions_consume_global_and_relay_rate_windows",
+ "all_unknown_clients_require_explicit_operator_approval"
+ ],
+ "permissions": {
+ "ceiling": "exact_normalized_configuration_permission_ceiling",
+ "sign_event_kind_width": "u32",
+ "operator_grant": "subset_of_requested_and_configured_ceiling",
+ "client_metadata_authority": "none"
+ },
+ "challenges": {
+ "enabled": "exact_normalized_configuration_switch",
+ "url": "exact_operator_configured_url_only",
+ "pending_lifetime": "positive_and_not_greater_than_configured_pending_lifetime_ms",
+ "authorized_lifetime": "positive_and_not_greater_than_configured_authorized_lifetime_ms",
+ "creation_rate_window": "configuration_bound_connection_scope",
+ "authorization_rate_window": "separate_configuration_bound_connection_scope",
+ "terminal_replay": "exact_and_state_bound"
+ },
+ "rate_windows": {
+ "unknown_connection_admission": "global_and_configured_relay_subjects",
+ "arbitrary_client_subject_rows": "forbidden_before_connection",
+ "retention": "exact_configuration_bound_per_class",
+ "saturated_replay": "stable"
+ },
+ "durable_authority": {
+ "owner": "existing_myc_state_repository_and_service_sqlite_transaction",
+ "new_store_or_limiter": "forbidden",
+ "configuration_bypass": "rejected_before_transaction"
+ },
+ "nonclaims": [
+ "ciphertext_decryption",
+ "supported_method_admission",
+ "provider_execution",
+ "response_commit",
+ "relay_publication"
+ ]
+}
diff --git a/src/lib.rs b/src/lib.rs
@@ -4,6 +4,7 @@
mod cli_v1;
mod config_v1;
mod nip46_admission;
+mod nip46_authorization;
mod nip46_replay;
mod nip46_verification;
mod provider_contract;
diff --git a/src/nip46_authorization.rs b/src/nip46_authorization.rs
@@ -0,0 +1,221 @@
+//! Configuration-bound NIP-46 connection and challenge policy.
+
+use serde_json::Value;
+
+use crate::{
+ MycAuthorizationChallengeRecord, MycAuthorizationChallengeRequest,
+ MycAuthorizationChallengeState, MycAuthorizationChallengeUrl, MycConnectionAdmissionPolicy,
+ MycConnectionAdmissionRequest, MycConnectionOperatorDecision, MycConnectionPermission,
+ MycConnectionPermissionSet, MycConnectionTimeUnixMs, MycNip46ClientPublicKey,
+};
+
+#[derive(Clone, PartialEq, Eq)]
+pub(crate) struct MycConnectionPolicies {
+ trusted_clients: Box<[MycNip46ClientPublicKey]>,
+ denied_clients: Box<[MycNip46ClientPublicKey]>,
+ permission_ceiling: MycConnectionPermissionSet,
+ challenge: Option<MycChallengePolicy>,
+ retention: MycAuthorizationRetentionPolicy,
+}
+
+#[derive(Clone, PartialEq, Eq)]
+struct MycChallengePolicy {
+ url: MycAuthorizationChallengeUrl,
+ pending_lifetime_ms: u64,
+ authorized_lifetime_ms: u64,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+struct MycAuthorizationRetentionPolicy {
+ terminal_connections_ms: u64,
+ terminal_challenges_ms: u64,
+ request_dedup_ms: u64,
+ completed_outbox_ms: u64,
+}
+
+impl MycConnectionPolicies {
+ pub(crate) fn from_normalized(document: &Value) -> Result<Self, ()> {
+ let invalid = || ();
+ let strings = |pointer: &str| {
+ document
+ .pointer(pointer)
+ .and_then(Value::as_array)
+ .ok_or_else(invalid)?
+ .iter()
+ .map(|value| value.as_str().ok_or_else(invalid))
+ .collect::<Result<Vec<_>, _>>()
+ };
+ let clients = |pointer: &str| {
+ strings(pointer)?
+ .into_iter()
+ .map(|value| MycNip46ClientPublicKey::new(value).map_err(|_| invalid()))
+ .collect::<Result<Vec<_>, _>>()
+ .map(Vec::into_boxed_slice)
+ };
+ let trusted_clients = clients("/policy/trusted_clients")?;
+ let denied_clients = clients("/policy/denied_clients")?;
+ if trusted_clients
+ .iter()
+ .any(|trusted| denied_clients.contains(trusted))
+ {
+ return Err(());
+ }
+ let permission_ceiling = MycConnectionPermissionSet::new(
+ &strings("/policy/permission_ceiling")?
+ .into_iter()
+ .map(|value| MycConnectionPermission::parse(value).ok_or_else(invalid))
+ .collect::<Result<Vec<_>, _>>()?,
+ )
+ .map_err(|_| ())?;
+ let integer = |pointer: &str| {
+ document
+ .pointer(pointer)
+ .and_then(Value::as_u64)
+ .filter(|value| *value > 0 && i64::try_from(*value).is_ok())
+ .ok_or_else(invalid)
+ };
+ let challenge = document
+ .pointer("/policy/challenges/enabled")
+ .and_then(Value::as_bool)
+ .ok_or_else(invalid)?
+ .then(|| {
+ let url = document
+ .pointer("/policy/challenges/url")
+ .and_then(Value::as_str)
+ .ok_or_else(invalid)
+ .and_then(|value| MycAuthorizationChallengeUrl::new(value).map_err(|_| ()))?;
+ let pending_lifetime_ms = integer("/policy/challenges/pending_lifetime_ms")?;
+ let authorized_lifetime_ms = integer("/policy/challenges/authorized_lifetime_ms")?;
+ if authorized_lifetime_ms < pending_lifetime_ms {
+ return Err(());
+ }
+ Ok(MycChallengePolicy {
+ url,
+ pending_lifetime_ms,
+ authorized_lifetime_ms,
+ })
+ })
+ .transpose()?;
+ let retention = MycAuthorizationRetentionPolicy {
+ terminal_connections_ms: integer("/policy/retention/terminal_connections_ms")?,
+ terminal_challenges_ms: integer("/policy/retention/terminal_challenges_ms")?,
+ request_dedup_ms: integer("/policy/retention/request_dedup_ms")?,
+ completed_outbox_ms: integer("/policy/retention/completed_outbox_ms")?,
+ };
+ Ok(Self {
+ trusted_clients,
+ denied_clients,
+ permission_ceiling,
+ challenge,
+ retention,
+ })
+ }
+
+ fn expected_admission(&self, client: &MycNip46ClientPublicKey) -> MycConnectionAdmissionPolicy {
+ if self.denied_clients.contains(client) {
+ MycConnectionAdmissionPolicy::Denied
+ } else if self.trusted_clients.contains(client) {
+ MycConnectionAdmissionPolicy::Trusted
+ } else {
+ MycConnectionAdmissionPolicy::ExplicitApproval
+ }
+ }
+
+ pub(crate) fn admits_connection_request(
+ &self,
+ request: &MycConnectionAdmissionRequest,
+ ) -> bool {
+ let expected = self.expected_admission(request.client_public_key());
+ if request.policy() != expected {
+ return false;
+ }
+ if expected == MycConnectionAdmissionPolicy::Denied {
+ return true;
+ }
+ if !request
+ .requested_permissions()
+ .is_subset_of(&self.permission_ceiling)
+ {
+ return false;
+ }
+ match expected {
+ MycConnectionAdmissionPolicy::Trusted => {
+ self.admits_authorized_until(request.observed_at(), request.authorized_until())
+ }
+ MycConnectionAdmissionPolicy::ExplicitApproval => request.authorized_until().is_none(),
+ MycConnectionAdmissionPolicy::Denied => true,
+ }
+ }
+
+ pub(crate) fn admits_operator_decision(
+ &self,
+ observed_at: MycConnectionTimeUnixMs,
+ decision: &MycConnectionOperatorDecision,
+ ) -> bool {
+ match decision {
+ MycConnectionOperatorDecision::Approve {
+ granted_permissions,
+ authorized_until,
+ } => {
+ granted_permissions.is_subset_of(&self.permission_ceiling)
+ && self.admits_authorized_until(observed_at, *authorized_until)
+ }
+ MycConnectionOperatorDecision::Deny => true,
+ }
+ }
+
+ pub(crate) fn admits_challenge_request(
+ &self,
+ request: &MycAuthorizationChallengeRequest,
+ ) -> bool {
+ let Some(policy) = &self.challenge else {
+ return false;
+ };
+ request.url() == &policy.url
+ && request
+ .expires_at()
+ .get()
+ .checked_sub(request.issued_at().get())
+ .is_some_and(|lifetime| lifetime <= policy.pending_lifetime_ms)
+ }
+
+ pub(crate) fn challenge_is_current(
+ &self,
+ record: &MycAuthorizationChallengeRecord,
+ observed_at: MycConnectionTimeUnixMs,
+ ) -> bool {
+ let Some(policy) = &self.challenge else {
+ return false;
+ };
+ record.state() == MycAuthorizationChallengeState::Authorized
+ && record.resolved_at().is_some_and(|resolved| {
+ observed_at >= resolved
+ && observed_at
+ .get()
+ .checked_sub(resolved.get())
+ .is_some_and(|age| age <= policy.authorized_lifetime_ms)
+ })
+ }
+
+ fn admits_authorized_until(
+ &self,
+ observed_at: MycConnectionTimeUnixMs,
+ authorized_until: Option<MycConnectionTimeUnixMs>,
+ ) -> bool {
+ match (&self.challenge, authorized_until) {
+ (None, None) => true,
+ (Some(policy), Some(until)) => until
+ .get()
+ .checked_sub(observed_at.get())
+ .is_some_and(|lifetime| lifetime > 0 && lifetime <= policy.authorized_lifetime_ms),
+ _ => false,
+ }
+ }
+
+ pub(crate) const fn retention_is_bounded(&self) -> bool {
+ self.retention.terminal_connections_ms > 0
+ && self.retention.terminal_challenges_ms > 0
+ && self.retention.request_dedup_ms > 0
+ && self.retention.completed_outbox_ms > 0
+ }
+}
diff --git a/src/state_connection.rs b/src/state_connection.rs
@@ -247,7 +247,7 @@ impl Error for MycConnectionStateError {}
pub enum MycConnectionPermission {
GetPublicKey,
GetSessionCapability,
- SignEvent(u16),
+ SignEvent(u32),
Nip04Encrypt,
Nip04Decrypt,
Nip44Encrypt,
@@ -273,7 +273,7 @@ impl MycConnectionPermission {
}
}
- fn parse(value: &str) -> Option<Self> {
+ pub(crate) fn parse(value: &str) -> Option<Self> {
match value {
"get_public_key" => Some(Self::GetPublicKey),
"get_session_capability" => Some(Self::GetSessionCapability),
@@ -286,7 +286,7 @@ impl MycConnectionPermission {
"logout" => Some(Self::Logout),
_ => value
.strip_prefix("sign_event:kind:")
- .and_then(|kind| kind.parse::<u16>().ok().map(|parsed| (kind, parsed)))
+ .and_then(|kind| kind.parse::<u32>().ok().map(|parsed| (kind, parsed)))
.filter(|(kind, parsed)| *kind == parsed.to_string())
.map(|(_, parsed)| Self::SignEvent(parsed)),
}
@@ -332,7 +332,7 @@ impl MycConnectionPermissionSet {
&self.digest
}
- fn is_subset_of(&self, other: &Self) -> bool {
+ pub(crate) fn is_subset_of(&self, other: &Self) -> bool {
self.permissions
.iter()
.all(|permission| other.permissions.binary_search(permission).is_ok())
@@ -580,6 +580,26 @@ impl MycConnectionAdmissionRequest {
relay_id: self.relay_id.clone(),
}
}
+
+ pub(crate) const fn client_public_key(&self) -> &MycNip46ClientPublicKey {
+ &self.client_public_key
+ }
+
+ pub(crate) const fn requested_permissions(&self) -> &MycConnectionPermissionSet {
+ &self.requested_permissions
+ }
+
+ pub(crate) const fn observed_at(&self) -> MycConnectionTimeUnixMs {
+ self.observed_at
+ }
+
+ pub(crate) const fn authorized_until(&self) -> Option<MycConnectionTimeUnixMs> {
+ self.authorized_until
+ }
+
+ pub(crate) const fn policy(&self) -> MycConnectionAdmissionPolicy {
+ self.policy
+ }
}
impl fmt::Debug for MycConnectionAdmissionRequest {
@@ -868,6 +888,18 @@ impl MycAuthorizationChallengeRequest {
expires_at: self.expires_at,
}
}
+
+ pub(crate) const fn url(&self) -> &MycAuthorizationChallengeUrl {
+ &self.url
+ }
+
+ pub(crate) const fn issued_at(&self) -> MycConnectionTimeUnixMs {
+ self.issued_at
+ }
+
+ pub(crate) const fn expires_at(&self) -> MycConnectionTimeUnixMs {
+ self.expires_at
+ }
}
impl fmt::Debug for MycAuthorizationChallengeRequest {
@@ -1029,15 +1061,18 @@ impl MycStateRepository<'_> {
&self,
request: &MycConnectionAdmissionRequest,
) -> Result<MycConnectionAdmission, MycStateRepositoryError> {
- if !self.expected().admits_rate_relay(&request.relay_id) {
+ if !self.expected().admits_rate_relay(&request.relay_id)
+ || !self.expected().admits_connection_request(request)
+ {
return Err(MycStateRepositoryError::new(
MycStateRepositoryErrorKind::Binding,
));
}
let request = request.owned();
- let rate_policy = self
- .expected()
- .governance_rate_policy(MycRateLimitClass::ConnectionAdmission);
+ let rate_policy = (request.policy != MycConnectionAdmissionPolicy::Denied).then(|| {
+ self.expected()
+ .governance_rate_policy(MycRateLimitClass::ConnectionAdmission)
+ });
let expected = PersistedMetadata::from(self.expected());
self.host()
.transaction(move |transaction| {
@@ -1060,6 +1095,14 @@ impl MycStateRepository<'_> {
audit_correlation: MycAuditCorrelationId,
decision: MycConnectionOperatorDecision,
) -> Result<MycConnectionRecord, MycStateRepositoryError> {
+ if !self
+ .expected()
+ .admits_connection_operator_decision(observed_at, &decision)
+ {
+ return Err(MycStateRepositoryError::new(
+ MycStateRepositoryErrorKind::Binding,
+ ));
+ }
let expected = PersistedMetadata::from(self.expected());
self.host()
.transaction(move |transaction| {
@@ -1138,6 +1181,14 @@ impl MycStateRepository<'_> {
&self,
request: &MycAuthorizationChallengeRequest,
) -> Result<MycAuthorizationChallengeAdmission, MycStateRepositoryError> {
+ if !self
+ .expected()
+ .admits_authorization_challenge_request(request)
+ {
+ return Err(MycStateRepositoryError::new(
+ MycStateRepositoryErrorKind::Binding,
+ ));
+ }
let request = request.owned();
let rate_policy = self
.expected()
@@ -1167,7 +1218,8 @@ impl MycStateRepository<'_> {
.expected()
.governance_rate_policy(MycRateLimitClass::ChallengeAuthorization);
let expected = PersistedMetadata::from(self.expected());
- self.host()
+ let result = self
+ .host()
.transaction(move |transaction| {
Box::pin(async move {
verify_metadata(transaction, &expected).await?;
@@ -1184,7 +1236,18 @@ impl MycStateRepository<'_> {
})
})
.await
- .map_err(map_transaction_error)
+ .map_err(map_transaction_error)?;
+ if result.record().is_some_and(|record| {
+ record.state() == MycAuthorizationChallengeState::Authorized
+ && !self
+ .expected()
+ .authorization_challenge_is_current(record, observed_at)
+ }) {
+ return Err(MycStateRepositoryError::new(
+ MycStateRepositoryErrorKind::Binding,
+ ));
+ }
+ Ok(result)
}
}
@@ -1237,7 +1300,7 @@ async fn verify_metadata(
async fn admit_connection(
transaction: &mut ServiceSqliteTransaction<'_>,
request: &MycConnectionAdmissionRequest,
- rate_policy: MycRateLimitPolicy,
+ rate_policy: Option<MycRateLimitPolicy>,
) -> Result<MycConnectionAdmission, ConnectionOperationError> {
let binding = read_request_binding(transaction, request.operation_id).await?;
if binding.client_public_key != request.client_public_key
@@ -1268,16 +1331,17 @@ async fn admit_connection(
occurred_at: request.observed_at,
operation_id: Some(request.operation_id),
};
- if !govern_rate_attempt(
- transaction,
- rate_policy,
- MycRateLimitClass::ConnectionAdmission,
- &[global_subject(), relay_subject(&request.relay_id)],
- evidence,
- MycAuditKind::ConnectionAdmission,
- )
- .await
- .map_err(map_governance_error)?
+ if let Some(rate_policy) = rate_policy
+ && !govern_rate_attempt(
+ transaction,
+ rate_policy,
+ MycRateLimitClass::ConnectionAdmission,
+ &[global_subject(), relay_subject(&request.relay_id)],
+ evidence,
+ MycAuditKind::ConnectionAdmission,
+ )
+ .await
+ .map_err(map_governance_error)?
{
return Ok(MycConnectionAdmission::RateLimited);
}
@@ -1729,7 +1793,8 @@ async fn authorize_challenge(
|| connection
.authorized_until
.is_some_and(|until| until < observed_at);
- let (state, decision, reason) = if observed_at > before.expires_at || connection_expired {
+ let expired = observed_at > before.expires_at || connection_expired;
+ let (state, decision, reason) = if expired {
(
MycAuthorizationChallengeState::Expired,
MycConnectionDecision::Denied,
diff --git a/src/state_metadata.rs b/src/state_metadata.rs
@@ -11,6 +11,11 @@ use radroots_service_sqlite::{
use radroots_storage::event::SourceGeneration;
use sha2::{Digest, Sha256};
+use crate::nip46_authorization::MycConnectionPolicies;
+use crate::state_connection::{
+ MycAuthorizationChallengeRecord, MycAuthorizationChallengeRequest,
+ MycConnectionAdmissionRequest, MycConnectionOperatorDecision, MycConnectionTimeUnixMs,
+};
use crate::state_delivery::{MycDeliveryPolicies, MycDeliveryPolicyMode, MycDeliveryRelayId};
use crate::state_discovery::MycDiscoveryPolicies;
use crate::state_governance::{
@@ -174,6 +179,7 @@ pub struct MycStateMetadata {
configuration: MycNormalizedConfigDigest,
identities: MycExpectedIdentities,
governance: MycGovernancePolicies,
+ authorization: MycConnectionPolicies,
delivery: MycDeliveryPolicies,
discovery: Option<MycDiscoveryPolicies>,
policy_versions: MycStatePolicyVersions,
@@ -214,6 +220,13 @@ impl MycStateMetadata {
);
let normalized = configuration.normalized();
let governance = governance_policies(normalized)?;
+ let authorization = MycConnectionPolicies::from_normalized(normalized)
+ .map_err(|_| MycStateMetadataError::new(MycStateMetadataErrorKind::Invariant))?;
+ if !authorization.retention_is_bounded() {
+ return Err(MycStateMetadataError::new(
+ MycStateMetadataErrorKind::Invariant,
+ ));
+ }
let identities = expected_identities(normalized)?;
let delivery = delivery_policies(normalized)?;
let discovery = MycDiscoveryPolicies::from_normalized(normalized, &identities)
@@ -239,6 +252,7 @@ impl MycStateMetadata {
configuration,
identities,
governance,
+ authorization,
delivery,
discovery,
policy_versions,
@@ -290,6 +304,37 @@ impl MycStateMetadata {
self.governance.audit_retention_ms()
}
+ pub(crate) fn admits_connection_request(
+ &self,
+ request: &MycConnectionAdmissionRequest,
+ ) -> bool {
+ self.authorization.admits_connection_request(request)
+ }
+
+ pub(crate) fn admits_connection_operator_decision(
+ &self,
+ observed_at: MycConnectionTimeUnixMs,
+ decision: &MycConnectionOperatorDecision,
+ ) -> bool {
+ self.authorization
+ .admits_operator_decision(observed_at, decision)
+ }
+
+ pub(crate) fn admits_authorization_challenge_request(
+ &self,
+ request: &MycAuthorizationChallengeRequest,
+ ) -> bool {
+ self.authorization.admits_challenge_request(request)
+ }
+
+ pub(crate) fn authorization_challenge_is_current(
+ &self,
+ record: &MycAuthorizationChallengeRecord,
+ observed_at: MycConnectionTimeUnixMs,
+ ) -> bool {
+ self.authorization.challenge_is_current(record, observed_at)
+ }
+
pub(crate) const fn delivery_policies(&self) -> &MycDeliveryPolicies {
&self.delivery
}
diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs
@@ -6,15 +6,19 @@ const ROOT: &str = include_str!("../src/lib.rs");
const README: &str = include_str!("../README");
const PUBLIC_API: &str = include_str!("../contracts/api_baselines/myc.txt");
const NIP46_VERIFICATION: &str = include_str!("../src/nip46_verification.rs");
+const NIP46_AUTHORIZATION: &str = include_str!("../src/nip46_authorization.rs");
const NIP46_REPLAY: &str = include_str!("../src/nip46_replay.rs");
const NIP46_VERIFICATION_CONTRACT: &str =
include_str!("../contracts/services_hardening/nip46_verification.v1.json");
const NIP46_REPLAY_CONTRACT: &str =
include_str!("../contracts/services_hardening/nip46_replay.v1.json");
+const NIP46_AUTHORIZATION_CONTRACT: &str =
+ include_str!("../contracts/services_hardening/nip46_authorization.v1.json");
const SOURCES: &[&str] = &[
include_str!("../src/cli_v1.rs"),
include_str!("../src/config_v1.rs"),
include_str!("../src/nip46_admission.rs"),
+ include_str!("../src/nip46_authorization.rs"),
include_str!("../src/nip46_replay.rs"),
include_str!("../src/nip46_verification.rs"),
include_str!("../src/provider_contract.rs"),
@@ -47,6 +51,7 @@ fn implementation_modules_are_private_and_rustdoc_uses_the_reviewed_readme() {
"cli_v1",
"config_v1",
"nip46_admission",
+ "nip46_authorization",
"nip46_replay",
"nip46_verification",
"provider_contract",
@@ -121,6 +126,7 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() {
"cli_v1",
"config_v1",
"nip46_admission",
+ "nip46_authorization",
"nip46_replay",
"nip46_verification",
"provider_contract",
@@ -173,6 +179,39 @@ fn reviewed_api_is_root_only_and_exposes_no_implementation_authority() {
}
#[test]
+fn step144_authorization_is_configuration_bound_and_reuses_durable_state() {
+ for forbidden in [
+ "sqlx::",
+ "ServiceSqlite",
+ "tokio::spawn",
+ "std::time::SystemTime",
+ "rand::",
+ "getrandom",
+ "RelayPool",
+ ] {
+ assert!(
+ !NIP46_AUTHORIZATION.contains(forbidden),
+ "Step 144 policy projection gained forbidden authority `{forbidden}`"
+ );
+ }
+ for required in [
+ "normalized_configuration_bound_in_myc_state_metadata",
+ "configured_denied_client_is_direct_denial_without_connection_or_rate_window",
+ "configured_trusted_and_unknown_client_admissions_consume_global_and_relay_rate_windows",
+ "all_unknown_clients_require_explicit_operator_approval",
+ "exact_operator_configured_url_only",
+ "separate_configuration_bound_connection_scope",
+ "existing_myc_state_repository_and_service_sqlite_transaction",
+ "\"new_store_or_limiter\": \"forbidden\"",
+ ] {
+ assert!(
+ NIP46_AUTHORIZATION_CONTRACT.contains(required),
+ "Step 144 contract is missing `{required}`"
+ );
+ }
+}
+
+#[test]
fn step143_replay_binding_remains_pure_and_reuses_the_durable_authority() {
for forbidden in [
"ServiceSqlite",
diff --git a/tests/services_hardening_connection_state.rs b/tests/services_hardening_connection_state.rs
@@ -33,6 +33,10 @@ const CONFIG_EXAMPLE: &[u8] =
const CONNECTION_SOURCE: &str = include_str!("../src/state_connection.rs");
const GOVERNANCE_SOURCE: &str = include_str!("../src/state_governance.rs");
const CLIENT_PUBLIC_KEY: &str = "2222222222222222222222222222222222222222222222222222222222222222";
+const TRUSTED_CLIENT_PUBLIC_KEY: &str =
+ "7777777777777777777777777777777777777777777777777777777777777777";
+const DENIED_CLIENT_PUBLIC_KEY: &str =
+ "8888888888888888888888888888888888888888888888888888888888888888";
fn runtime(root: &Path) -> myc::MycRuntimeContext {
let root = root.to_str().expect("UTF-8 temporary root");
@@ -99,6 +103,22 @@ fn client() -> MycNip46ClientPublicKey {
MycNip46ClientPublicKey::new(CLIENT_PUBLIC_KEY).expect("client identity")
}
+fn trusted_client() -> MycNip46ClientPublicKey {
+ MycNip46ClientPublicKey::new(TRUSTED_CLIENT_PUBLIC_KEY).expect("trusted client identity")
+}
+
+fn denied_client() -> MycNip46ClientPublicKey {
+ MycNip46ClientPublicKey::new(DENIED_CLIENT_PUBLIC_KEY).expect("denied client identity")
+}
+
+fn client_for_policy(policy: MycConnectionAdmissionPolicy) -> MycNip46ClientPublicKey {
+ match policy {
+ MycConnectionAdmissionPolicy::Trusted => trusted_client(),
+ MycConnectionAdmissionPolicy::ExplicitApproval => client(),
+ MycConnectionAdmissionPolicy::Denied => denied_client(),
+ }
+}
+
fn permission_set(permissions: &[MycConnectionPermission]) -> MycConnectionPermissionSet {
MycConnectionPermissionSet::new(permissions).expect("permission set")
}
@@ -117,6 +137,7 @@ fn audit_correlation(byte: u8) -> MycAuditCorrelationId {
async fn admit_request(
repository: &MycStateRepository<'_>,
+ client_public_key: MycNip46ClientPublicKey,
request_id: &str,
event_byte: u8,
method: MycSignerRequestMethod,
@@ -128,7 +149,7 @@ async fn admit_request(
method.as_str()
);
let request = MycSignerRequest::new(
- client(),
+ client_public_key,
MycNip46RequestId::new(request_id).expect("request ID"),
MycNip46EventId::from_bytes([event_byte; 32]),
method,
@@ -156,7 +177,7 @@ fn connection_request(
) -> MycConnectionAdmissionRequest {
MycConnectionAdmissionRequest::new(
operation_id,
- client(),
+ client_for_policy(policy),
permissions,
policy_generation(generation),
MycConnectionNonce::from_injected_entropy([nonce_byte; 32]),
@@ -175,7 +196,7 @@ fn hex(bytes: &[u8]) -> String {
#[test]
fn connection_inputs_are_closed_bounded_canonical_and_redacted() {
let maximum = (0..MYC_CONNECTION_PERMISSION_MAX_COUNT)
- .map(|kind| MycConnectionPermission::SignEvent(u16::try_from(kind).expect("kind")))
+ .map(|kind| MycConnectionPermission::SignEvent(u32::try_from(kind).expect("kind")))
.collect::<Vec<_>>();
let permissions = MycConnectionPermissionSet::new(&maximum).expect("maximum permissions");
assert_eq!(
@@ -192,7 +213,7 @@ fn connection_inputs_are_closed_bounded_canonical_and_redacted() {
MycConnectionStateErrorKind::InvalidPermissionSet
);
let excessive = (0..=MYC_CONNECTION_PERMISSION_MAX_COUNT)
- .map(|kind| MycConnectionPermission::SignEvent(u16::try_from(kind).expect("kind")))
+ .map(|kind| MycConnectionPermission::SignEvent(u32::try_from(kind).expect("kind")))
.collect::<Vec<_>>();
assert_eq!(
MycConnectionPermissionSet::new(&excessive)
@@ -201,6 +222,9 @@ fn connection_inputs_are_closed_bounded_canonical_and_redacted() {
MycConnectionStateErrorKind::InvalidPermissionSet
);
assert!(MycConnectionPermissionSet::new(&[]).is_ok());
+ assert!(
+ MycConnectionPermissionSet::new(&[MycConnectionPermission::SignEvent(u32::MAX)]).is_ok()
+ );
assert!(MycConnectionPolicyGeneration::new(i64::MAX.unsigned_abs()).is_ok());
assert!(MycConnectionTimeUnixMs::new(i64::MAX.unsigned_abs()).is_ok());
@@ -400,6 +424,7 @@ async fn rate_windows_audit_pagination_retention_and_compaction_are_durable_and_
let first_operation = admit_request(
&repository,
+ client(),
"rate-first",
0x80,
MycSignerRequestMethod::Connect,
@@ -409,6 +434,7 @@ async fn rate_windows_audit_pagination_retention_and_compaction_are_durable_and_
.await;
let second_operation = admit_request(
&repository,
+ client(),
"rate-second",
0x82,
MycSignerRequestMethod::Connect,
@@ -418,22 +444,22 @@ async fn rate_windows_audit_pagination_retention_and_compaction_are_durable_and_
.await;
let first_request = connection_request(
first_operation,
- permission_set(&[MycConnectionPermission::Ping]),
+ permission_set(&[MycConnectionPermission::Nip44Encrypt]),
11,
0x84,
100,
- Some(1_000),
- MycConnectionAdmissionPolicy::Trusted,
+ None,
+ MycConnectionAdmissionPolicy::ExplicitApproval,
);
let unconfigured_relay_request = MycConnectionAdmissionRequest::new(
first_operation,
client(),
- permission_set(&[MycConnectionPermission::Ping]),
+ permission_set(&[MycConnectionPermission::Nip44Encrypt]),
policy_generation(11),
MycConnectionNonce::from_injected_entropy([0x84; 32]),
time(100),
- Some(time(1_000)),
- MycConnectionAdmissionPolicy::Trusted,
+ None,
+ MycConnectionAdmissionPolicy::ExplicitApproval,
MycRateRelayId::new("unconfigured").expect("relay ID"),
)
.expect("unconfigured relay request");
@@ -447,12 +473,12 @@ async fn rate_windows_audit_pagination_retention_and_compaction_are_durable_and_
);
let second_request = connection_request(
second_operation,
- permission_set(&[MycConnectionPermission::Ping]),
+ permission_set(&[MycConnectionPermission::Nip44Encrypt]),
11,
0x85,
100,
- Some(1_000),
- MycConnectionAdmissionPolicy::Trusted,
+ None,
+ MycConnectionAdmissionPolicy::ExplicitApproval,
);
let (first, second) = tokio::join!(
repository.admit_connection(&first_request),
@@ -486,6 +512,7 @@ async fn rate_windows_audit_pagination_retention_and_compaction_are_durable_and_
let boundary_operation = admit_request(
&repository,
+ client(),
"rate-boundary",
0x86,
MycSignerRequestMethod::Connect,
@@ -495,12 +522,12 @@ async fn rate_windows_audit_pagination_retention_and_compaction_are_durable_and_
.await;
let boundary_request = connection_request(
boundary_operation,
- permission_set(&[MycConnectionPermission::Ping]),
+ permission_set(&[MycConnectionPermission::Nip44Encrypt]),
11,
0x88,
200,
- Some(1_000),
- MycConnectionAdmissionPolicy::Trusted,
+ None,
+ MycConnectionAdmissionPolicy::ExplicitApproval,
);
assert!(matches!(
repository
@@ -512,6 +539,7 @@ async fn rate_windows_audit_pagination_retention_and_compaction_are_durable_and_
let reset_operation = admit_request(
&repository,
+ client(),
"rate-reset",
0x89,
MycSignerRequestMethod::Connect,
@@ -521,12 +549,12 @@ async fn rate_windows_audit_pagination_retention_and_compaction_are_durable_and_
.await;
let reset_request = connection_request(
reset_operation,
- permission_set(&[MycConnectionPermission::Ping]),
+ permission_set(&[MycConnectionPermission::Nip44Encrypt]),
11,
0x8b,
201,
- Some(1_000),
- MycConnectionAdmissionPolicy::Trusted,
+ None,
+ MycConnectionAdmissionPolicy::ExplicitApproval,
);
assert!(matches!(
repository
@@ -736,6 +764,7 @@ async fn challenge_creation_and_authorization_use_distinct_durable_rate_budgets(
let repository = host.repository();
let connect = admit_request(
&repository,
+ trusted_client(),
"distinct-rates-connect",
0xa0,
MycSignerRequestMethod::Connect,
@@ -746,7 +775,7 @@ async fn challenge_creation_and_authorization_use_distinct_durable_rate_budgets(
let connection = repository
.admit_connection(&connection_request(
connect,
- permission_set(&[MycConnectionPermission::Ping]),
+ permission_set(&[MycConnectionPermission::Nip44Encrypt]),
17,
0xa2,
11,
@@ -762,6 +791,7 @@ async fn challenge_creation_and_authorization_use_distinct_durable_rate_budgets(
.clone();
let first_operation = admit_request(
&repository,
+ trusted_client(),
"distinct-rates-first",
0xa3,
MycSignerRequestMethod::Ping,
@@ -771,6 +801,7 @@ async fn challenge_creation_and_authorization_use_distinct_durable_rate_budgets(
.await;
let second_operation = admit_request(
&repository,
+ trusted_client(),
"distinct-rates-second",
0xa5,
MycSignerRequestMethod::Ping,
@@ -782,7 +813,7 @@ async fn challenge_creation_and_authorization_use_distinct_durable_rate_budgets(
first_operation,
connection.id(),
policy_generation(17),
- MycAuthorizationChallengeUrl::new("https://operator.example/first").expect("URL"),
+ MycAuthorizationChallengeUrl::new("https://myc.example.test/auth/challenge").expect("URL"),
MycAuthorizationChallengeNonce::from_injected_entropy([0xa7; 32]),
time(22),
time(200),
@@ -792,7 +823,7 @@ async fn challenge_creation_and_authorization_use_distinct_durable_rate_budgets(
second_operation,
connection.id(),
policy_generation(17),
- MycAuthorizationChallengeUrl::new("https://operator.example/second").expect("URL"),
+ MycAuthorizationChallengeUrl::new("https://myc.example.test/auth/challenge").expect("URL"),
MycAuthorizationChallengeNonce::from_injected_entropy([0xa8; 32]),
time(23),
time(200),
@@ -894,6 +925,7 @@ async fn connection_admission_and_operator_decisions_are_atomic_replay_safe_and_
let trusted_operation = admit_request(
&repository,
+ trusted_client(),
"connect-trusted",
0x10,
MycSignerRequestMethod::Connect,
@@ -902,9 +934,49 @@ async fn connection_admission_and_operator_decisions_are_atomic_replay_safe_and_
)
.await;
let requested = permission_set(&[
- MycConnectionPermission::Ping,
+ MycConnectionPermission::Nip44Encrypt,
MycConnectionPermission::SignEvent(1),
]);
+ let forged_unknown_policy = MycConnectionAdmissionRequest::new(
+ trusted_operation,
+ trusted_client(),
+ requested.clone(),
+ policy_generation(1),
+ MycConnectionNonce::from_injected_entropy([0x1e; 32]),
+ time(110),
+ None,
+ MycConnectionAdmissionPolicy::ExplicitApproval,
+ MycRateRelayId::new("primary").expect("relay ID"),
+ )
+ .expect("structurally valid forged policy");
+ assert_eq!(
+ repository
+ .admit_connection(&forged_unknown_policy)
+ .await
+ .expect_err("configuration decides trusted admission")
+ .kind(),
+ MycStateRepositoryErrorKind::Binding
+ );
+ let above_ceiling = MycConnectionAdmissionRequest::new(
+ trusted_operation,
+ trusted_client(),
+ permission_set(&[MycConnectionPermission::Ping]),
+ policy_generation(1),
+ MycConnectionNonce::from_injected_entropy([0x1d; 32]),
+ time(110),
+ Some(time(1_000)),
+ MycConnectionAdmissionPolicy::Trusted,
+ MycRateRelayId::new("primary").expect("relay ID"),
+ )
+ .expect("structurally valid permission expansion");
+ assert_eq!(
+ repository
+ .admit_connection(&above_ceiling)
+ .await
+ .expect_err("configuration permission ceiling")
+ .kind(),
+ MycStateRepositoryErrorKind::Binding
+ );
assert_eq!(
repository
.admit_connection(&connection_request(
@@ -948,7 +1020,7 @@ async fn connection_admission_and_operator_decisions_are_atomic_replay_safe_and_
let trusted_id = trusted_connection.id();
assert_eq!(
hex(trusted_id.as_bytes()),
- "8819838c497a75152f7c1cd80b8e3bd7836fb84e002e62280f657a5f74903c5c"
+ "08a11316dac6cf56849f1b7e6e9a50ea4b3e577ee39ea18440c33a1c2ec22671"
);
let trusted_replay = repository
.admit_connection(&connection_request(
@@ -978,6 +1050,7 @@ async fn connection_admission_and_operator_decisions_are_atomic_replay_safe_and_
let pending_operation = admit_request(
&repository,
+ client(),
"connect-pending",
0x12,
MycSignerRequestMethod::Connect,
@@ -1007,7 +1080,25 @@ async fn connection_admission_and_operator_decisions_are_atomic_replay_safe_and_
.connection()
.expect("pending connection")
.id();
- let granted = permission_set(&[MycConnectionPermission::Ping]);
+ let granted = permission_set(&[MycConnectionPermission::Nip44Encrypt]);
+ assert_eq!(
+ repository
+ .decide_pending_connection(
+ pending_operation,
+ pending_id,
+ policy_generation(2),
+ time(130),
+ audit_correlation(0x6f),
+ MycConnectionOperatorDecision::Approve {
+ granted_permissions: permission_set(&[MycConnectionPermission::Ping]),
+ authorized_until: Some(time(900)),
+ },
+ )
+ .await
+ .expect_err("operator cannot expand the configured ceiling")
+ .kind(),
+ MycStateRepositoryErrorKind::Binding
+ );
assert_eq!(
repository
.decide_pending_connection(
@@ -1083,6 +1174,7 @@ async fn connection_admission_and_operator_decisions_are_atomic_replay_safe_and_
let operator_denied_operation = admit_request(
&repository,
+ client(),
"connect-operator-denied",
0x16,
MycSignerRequestMethod::Connect,
@@ -1137,6 +1229,7 @@ async fn connection_admission_and_operator_decisions_are_atomic_replay_safe_and_
let denied_operation = admit_request(
&repository,
+ denied_client(),
"connect-denied",
0x14,
MycSignerRequestMethod::Connect,
@@ -1147,7 +1240,7 @@ async fn connection_admission_and_operator_decisions_are_atomic_replay_safe_and_
let denied = repository
.admit_connection(&connection_request(
denied_operation,
- requested.clone(),
+ permission_set(&[MycConnectionPermission::Ping]),
3,
0x24,
141,
@@ -1170,7 +1263,7 @@ async fn connection_admission_and_operator_decisions_are_atomic_replay_safe_and_
let denied_replay = repository
.admit_connection(&connection_request(
denied_operation,
- requested,
+ permission_set(&[MycConnectionPermission::Ping]),
3,
0x25,
142,
@@ -1194,7 +1287,7 @@ async fn connection_admission_and_operator_decisions_are_atomic_replay_safe_and_
let mismatch = repository
.admit_connection(&connection_request(
denied_operation,
- permission_set(&[MycConnectionPermission::Ping]),
+ permission_set(&[MycConnectionPermission::Nip44Encrypt]),
3,
0x26,
143,
@@ -1234,6 +1327,165 @@ async fn connection_admission_and_operator_decisions_are_atomic_replay_safe_and_
}
#[tokio::test]
+async fn configured_denial_precedes_saturated_unknown_client_rate_windows() {
+ let directory = tempfile::tempdir().expect("temporary root");
+ let runtime = runtime(directory.path());
+ prepare_state_directory(&runtime);
+ let configuration = std::str::from_utf8(CONFIG_EXAMPLE)
+ .expect("UTF-8 configuration")
+ .replacen(
+ "[rate_limits.connection_admission]\nscope = \"global_and_relay\"\nwindow_ms = 60000\nmax_attempts = 10\nretention_ms = 3600000\nmaximum_tracked_subjects = 4096",
+ "[rate_limits.connection_admission]\nscope = \"global_and_relay\"\nwindow_ms = 100\nmax_attempts = 1\nretention_ms = 200\nmaximum_tracked_subjects = 8",
+ 1,
+ );
+ let metadata = metadata_from_bytes(&runtime, configuration.as_bytes());
+ let (applied_at, build) = migration_evidence();
+ initialize_myc_state(&runtime, &metadata, applied_at, &build)
+ .await
+ .expect("state initialization");
+ let host = open_myc_state_read_write(&runtime, &metadata, applied_at, &build)
+ .await
+ .expect("writable host");
+ let repository = host.repository();
+
+ let first = admit_request(
+ &repository,
+ client(),
+ "unknown-first",
+ 0xb0,
+ MycSignerRequestMethod::Connect,
+ 0xb1,
+ 100,
+ )
+ .await;
+ let second = admit_request(
+ &repository,
+ client(),
+ "unknown-second",
+ 0xb2,
+ MycSignerRequestMethod::Connect,
+ 0xb3,
+ 100,
+ )
+ .await;
+ let denied = admit_request(
+ &repository,
+ denied_client(),
+ "configured-denial",
+ 0xb4,
+ MycSignerRequestMethod::Connect,
+ 0xb5,
+ 100,
+ )
+ .await;
+ let trusted_first = admit_request(
+ &repository,
+ trusted_client(),
+ "trusted-first",
+ 0xb9,
+ MycSignerRequestMethod::Connect,
+ 0xba,
+ 202,
+ )
+ .await;
+ let trusted_second = admit_request(
+ &repository,
+ trusted_client(),
+ "trusted-second",
+ 0xbb,
+ MycSignerRequestMethod::Connect,
+ 0xbc,
+ 202,
+ )
+ .await;
+ let permissions = permission_set(&[MycConnectionPermission::Nip44Encrypt]);
+ assert!(matches!(
+ repository
+ .admit_connection(&connection_request(
+ first,
+ permissions.clone(),
+ 1,
+ 0xb6,
+ 101,
+ None,
+ MycConnectionAdmissionPolicy::ExplicitApproval,
+ ))
+ .await
+ .expect("first unknown admission"),
+ MycConnectionAdmission::Admitted(_)
+ ));
+ assert!(matches!(
+ repository
+ .admit_connection(&connection_request(
+ second,
+ permissions,
+ 1,
+ 0xb7,
+ 101,
+ None,
+ MycConnectionAdmissionPolicy::ExplicitApproval,
+ ))
+ .await
+ .expect("saturated unknown admission"),
+ MycConnectionAdmission::RateLimited
+ ));
+ assert!(matches!(
+ repository
+ .admit_connection(&connection_request(
+ trusted_first,
+ permission_set(&[MycConnectionPermission::Nip44Encrypt]),
+ 1,
+ 0xbd,
+ 202,
+ Some(1_000),
+ MycConnectionAdmissionPolicy::Trusted,
+ ))
+ .await
+ .expect("first trusted admission in the next window"),
+ MycConnectionAdmission::Admitted(_)
+ ));
+ assert!(matches!(
+ repository
+ .admit_connection(&connection_request(
+ trusted_second,
+ permission_set(&[MycConnectionPermission::Nip44Encrypt]),
+ 1,
+ 0xbe,
+ 202,
+ Some(1_000),
+ MycConnectionAdmissionPolicy::Trusted,
+ ))
+ .await
+ .expect("saturated trusted admission"),
+ MycConnectionAdmission::RateLimited
+ ));
+ let direct = repository
+ .admit_connection(&connection_request(
+ denied,
+ permission_set(&[MycConnectionPermission::Ping]),
+ 1,
+ 0xb8,
+ 101,
+ None,
+ MycConnectionAdmissionPolicy::Denied,
+ ))
+ .await
+ .expect("configured denial bypasses unknown-client rate admission");
+ assert_eq!(
+ direct.record().expect("denial record").decision(),
+ myc::MycConnectionDecision::Denied
+ );
+ assert!(
+ direct
+ .record()
+ .expect("denial record")
+ .connection()
+ .is_none()
+ );
+ host.close().await.expect("host close");
+}
+
+#[tokio::test]
async fn challenge_authorization_expiry_and_terminal_replay_remain_exactly_bound() {
let directory = tempfile::tempdir().expect("temporary root");
let runtime = runtime(directory.path());
@@ -1250,6 +1502,7 @@ async fn challenge_authorization_expiry_and_terminal_replay_remain_exactly_bound
let connect_operation = admit_request(
&repository,
+ trusted_client(),
"connect-challenge",
0x30,
MycSignerRequestMethod::Connect,
@@ -1260,7 +1513,7 @@ async fn challenge_authorization_expiry_and_terminal_replay_remain_exactly_bound
let connection = repository
.admit_connection(&connection_request(
connect_operation,
- permission_set(&[MycConnectionPermission::Ping]),
+ permission_set(&[MycConnectionPermission::Nip44Encrypt]),
7,
0x32,
201,
@@ -1277,6 +1530,7 @@ async fn challenge_authorization_expiry_and_terminal_replay_remain_exactly_bound
let ping_operation = admit_request(
&repository,
+ trusted_client(),
"ping-challenge",
0x33,
MycSignerRequestMethod::Ping,
@@ -1288,7 +1542,7 @@ async fn challenge_authorization_expiry_and_terminal_replay_remain_exactly_bound
ping_operation,
connection.id(),
policy_generation(7),
- MycAuthorizationChallengeUrl::new("https://operator.example/").expect("URL"),
+ MycAuthorizationChallengeUrl::new("https://myc.example.test/auth/challenge").expect("URL"),
MycAuthorizationChallengeNonce::from_injected_entropy([0x34; 32]),
time(211),
time(211),
@@ -1298,11 +1552,47 @@ async fn challenge_authorization_expiry_and_terminal_replay_remain_exactly_bound
invalid_lifetime.kind(),
MycConnectionStateErrorKind::InvalidChallengeLifetime
);
+ let client_selected_url = MycAuthorizationChallengeRequest::new(
+ ping_operation,
+ connection.id(),
+ policy_generation(7),
+ MycAuthorizationChallengeUrl::new("https://attacker.example/redirect").expect("URL"),
+ MycAuthorizationChallengeNonce::from_injected_entropy([0x34; 32]),
+ time(211),
+ time(300),
+ )
+ .expect("structurally valid client-selected URL");
+ assert_eq!(
+ repository
+ .issue_authorization_challenge(&client_selected_url)
+ .await
+ .expect_err("only configured operator URL is authoritative")
+ .kind(),
+ MycStateRepositoryErrorKind::Binding
+ );
+ let excessive_pending_lifetime = MycAuthorizationChallengeRequest::new(
+ ping_operation,
+ connection.id(),
+ policy_generation(7),
+ MycAuthorizationChallengeUrl::new("https://myc.example.test/auth/challenge").expect("URL"),
+ MycAuthorizationChallengeNonce::from_injected_entropy([0x34; 32]),
+ time(211),
+ time(900_212),
+ )
+ .expect("structurally valid excessive pending lifetime");
+ assert_eq!(
+ repository
+ .issue_authorization_challenge(&excessive_pending_lifetime)
+ .await
+ .expect_err("configured pending lifetime is authoritative")
+ .kind(),
+ MycStateRepositoryErrorKind::Binding
+ );
let challenge_request = MycAuthorizationChallengeRequest::new(
ping_operation,
connection.id(),
policy_generation(7),
- MycAuthorizationChallengeUrl::new("https://operator.example/authorize").expect("URL"),
+ MycAuthorizationChallengeUrl::new("https://myc.example.test/auth/challenge").expect("URL"),
MycAuthorizationChallengeNonce::from_injected_entropy([0x35; 32]),
time(211),
time(300),
@@ -1312,7 +1602,7 @@ async fn challenge_authorization_expiry_and_terminal_replay_remain_exactly_bound
ping_operation,
connection.id(),
policy_generation(7),
- MycAuthorizationChallengeUrl::new("https://operator.example/authorize").expect("URL"),
+ MycAuthorizationChallengeUrl::new("https://myc.example.test/auth/challenge").expect("URL"),
MycAuthorizationChallengeNonce::from_injected_entropy([0x34; 32]),
time(209),
time(300),
@@ -1341,14 +1631,14 @@ async fn challenge_authorization_expiry_and_terminal_replay_remain_exactly_bound
let challenge_id = challenge.record().expect("challenge record").id();
assert_eq!(
hex(challenge_id.as_bytes()),
- "b0f43d00c70db2bf058d461a2c1ac940ef52cfc76a4d271b7bbc89464fb25abb"
+ "9a85ce42301af50287626ddcf209a145a2742cf0ef178e44acf06108d1b86b29"
);
let replay_request = MycAuthorizationChallengeRequest::new(
ping_operation,
connection.id(),
policy_generation(7),
- MycAuthorizationChallengeUrl::new("https://operator.example/authorize").expect("URL"),
+ MycAuthorizationChallengeUrl::new("https://myc.example.test/auth/challenge").expect("URL"),
MycAuthorizationChallengeNonce::from_injected_entropy([0x36; 32]),
time(211),
time(300),
@@ -1417,9 +1707,24 @@ async fn challenge_authorization_expiry_and_terminal_replay_remain_exactly_bound
terminal_replay.record().expect("authorization record"),
authorized.record().expect("authorization record")
);
+ assert_eq!(
+ repository
+ .authorize_challenge(
+ challenge_id,
+ connection.id(),
+ ping_operation,
+ policy_generation(7),
+ time(3_600_301),
+ )
+ .await
+ .expect_err("authorized challenge lifetime is bounded by configuration")
+ .kind(),
+ MycStateRepositoryErrorKind::Binding
+ );
let deadline_operation = admit_request(
&repository,
+ trusted_client(),
"ping-deadline",
0x3a,
MycSignerRequestMethod::Ping,
@@ -1431,7 +1736,7 @@ async fn challenge_authorization_expiry_and_terminal_replay_remain_exactly_bound
deadline_operation,
connection.id(),
policy_generation(7),
- MycAuthorizationChallengeUrl::new("https://operator.example/deadline").expect("URL"),
+ MycAuthorizationChallengeUrl::new("https://myc.example.test/auth/challenge").expect("URL"),
MycAuthorizationChallengeNonce::from_injected_entropy([0x3c; 32]),
time(416),
time(440),
@@ -1461,6 +1766,7 @@ async fn challenge_authorization_expiry_and_terminal_replay_remain_exactly_bound
let expiring_operation = admit_request(
&repository,
+ trusted_client(),
"ping-expiring",
0x37,
MycSignerRequestMethod::Ping,
@@ -1472,7 +1778,7 @@ async fn challenge_authorization_expiry_and_terminal_replay_remain_exactly_bound
expiring_operation,
connection.id(),
policy_generation(7),
- MycAuthorizationChallengeUrl::new("https://operator.example/expiry").expect("URL"),
+ MycAuthorizationChallengeUrl::new("https://myc.example.test/auth/challenge").expect("URL"),
MycAuthorizationChallengeNonce::from_injected_entropy([0x39; 32]),
time(451),
time(600),