myc

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

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:
Mcontracts/api_baselines/myc.txt | 2+-
Acontracts/services_hardening/nip46_authorization.v1.json | 45+++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 1+
Asrc/nip46_authorization.rs | 221+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/state_connection.rs | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Msrc/state_metadata.rs | 45+++++++++++++++++++++++++++++++++++++++++++++
Mtests/package_boundary.rs | 39+++++++++++++++++++++++++++++++++++++++
Mtests/services_hardening_connection_state.rs | 378+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
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),