From a93a6546de2e5e4d39a1c80968d455f055b390a6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 02:58:35 +0300 Subject: [PATCH 01/47] feat(device-pairing): add trusted module pairing lifecycle --- .../run-native-master-cipher-coverage.sh | 3 +- Cargo.lock | 1 + MODULE.md | 3 + README.md | 6 +- crates/tinysecurity-bus/src/callbacks.rs | 8 + .../src/device_frame_tests.rs | 2 +- crates/tinysecurity-bus/src/device_pairing.rs | 353 ++++ .../src/device_pairing_tests.rs | 163 ++ crates/tinysecurity-bus/src/lib.rs | 4 +- crates/tinysecurity-bus/src/names.rs | 3 + crates/tinysecurity-bus/src/policy.rs | 4 + crates/tinysecurity-bus/tests/scoped_wire.rs | 6 +- crates/tinysecurity-bus/tests/wire.rs | 2 +- .../tinysecurity-crypto/src/device_handles.rs | 141 +- .../src/device_handles_tests.rs | 89 + .../tinysecurity-crypto/src/device_pairing.rs | 1402 +++++++++++++++ .../src/device_pairing_tests.rs | 865 ++++++++++ crates/tinysecurity-crypto/src/lib.rs | 1 + .../src/native_handles/cipher.rs | 101 ++ .../src/native_handles/cipher_tests.rs | 51 + crates/tinysecurity-module/Cargo.toml | 2 + .../secret_cipher_native_contract/fixture.rs | 1499 ++++++++++++++++- crates/tinysecurity-module/src/adapter.rs | 87 +- .../src/adapter_device_pairing.rs | 1223 ++++++++++++++ .../tinysecurity-module/src/adapter_secret.rs | 54 + .../src/adapter_secret_tests.rs | 13 + crates/tinysecurity-module/src/callbacks.rs | 11 +- docs/module-crypto.md | 27 +- docs/specs/device-pairing-contract-1.25.md | 211 +++ 29 files changed, 6262 insertions(+), 73 deletions(-) create mode 100644 crates/tinysecurity-bus/src/device_pairing.rs create mode 100644 crates/tinysecurity-bus/src/device_pairing_tests.rs create mode 100644 crates/tinysecurity-crypto/src/device_pairing.rs create mode 100644 crates/tinysecurity-crypto/src/device_pairing_tests.rs create mode 100644 crates/tinysecurity-module/src/adapter_device_pairing.rs create mode 100644 docs/specs/device-pairing-contract-1.25.md diff --git a/.github/scripts/run-native-master-cipher-coverage.sh b/.github/scripts/run-native-master-cipher-coverage.sh index 6dd53a4b..71a98188 100755 --- a/.github/scripts/run-native-master-cipher-coverage.sh +++ b/.github/scripts/run-native-master-cipher-coverage.sh @@ -29,7 +29,8 @@ fi printf 'Native fixtures: repository working directory\n' "$verifier" "$module" "$device_verifier" "$module" +"$verifier" "$module" 18 external_cwd="$(mktemp -d)" trap 'rmdir "$external_cwd"' EXIT printf 'Native fixtures: external working directory\n' -(cd -- "$external_cwd" && "$verifier" "$module" && "$device_verifier" "$module") +(cd -- "$external_cwd" && "$verifier" "$module" && "$device_verifier" "$module" && "$verifier" "$module" 18) diff --git a/Cargo.lock b/Cargo.lock index 849b034e..8641ff86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1800,6 +1800,7 @@ name = "tinysecurity-module" version = "0.14.0" dependencies = [ "async-trait", + "base64 0.22.1", "getrandom 0.3.4", "serde", "serde_json", diff --git a/MODULE.md b/MODULE.md index 701e55f9..64d9ed20 100644 --- a/MODULE.md +++ b/MODULE.md @@ -121,6 +121,9 @@ the [security architecture page](https://github.com/tinyhumansai/openhuman/blob/ ## Confidential device handles Full details: [`Confidential device handles`](docs/module-crypto.md#confidential-device-handles). +## Confidential paired-device lifecycle (contract 1.25) +Full details: [paired-device lifecycle specification](docs/specs/device-pairing-contract-1.25.md). + ## Registered subject policies (contract 1.12, extended in 1.13) Full details: [`Registered subject policies (contract 1.12, extended in 1.13)`](docs/module-subject-policies.md#registered-subject-policies-contract-112-extended-in-113). diff --git a/README.md b/README.md index 62c80b4a..bd1297cc 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@ compile the transport-free `tinysecurity-bus` contract and load the isolated Contract 1.23 adds confidential per-frame `DeviceFrame2` calls over the live opaque tunnel capability issued by approved `ExecuteDevice2` work; see the [device contract](MODULE.md#confidential-device-handles). +Contract 1.25 adds `DevicePairing2`, which owns the paired-device static +identity, ciphertext-only persistence, host-attested enrollment/reconnect, +session refresh and revocation while preserving the existing v2/v3/v4 wire; +see the [pairing lifecycle specification](docs/specs/device-pairing-contract-1.25.md). Contract 1.1 also supplies immutable native [filesystem scopes](docs/specs/immutable-path-scopes.md) through `RegisterPathPolicy`, `ValidatePath`, `ValidateParent` and `CheckPath`. Contract 1.3 adds native `Redact`, `ScanPrompt` and `ScanToolDefinition`, with a @@ -36,7 +40,7 @@ diagnostics work with disabled autonomy, which is the default. This is bootstrap infrastructure for [OpenHuman #7328](https://github.com/tinyhumansai/openhuman/issues/7328) and [TinySecurity #1](https://github.com/tinyhumansai/tinysecurity/issues/1). -It does **not** complete the migration. Concrete sandbox spawning, connection pinning, Linux Secret Service and pairing challenge delivery (`Begin`/`Verify`) remain unavailable; contract 1.14 password, keyring and pairing-status members are implemented. The optional [native device handle surface](MODULE.md#confidential-device-handles) uses authenticated confidential calls and exact human/audit receipts. Reserved wire types do not advertise callable handlers. +It does **not** complete the migration. Concrete sandbox spawning, connection pinning, Linux Secret Service and production host configuration/adapter migration remain unavailable. Contract 1.25 supplies the module-owned pairing and tunnel lifecycle; the host must provide the server-trusted controller/socket/reconnect callbacks and encrypted persistence plumbing described in its specification. The optional [native device handle surface](MODULE.md#confidential-device-handles) uses authenticated confidential calls; initial master unlock remains HumanOnly. Reserved wire types do not advertise callable handlers. There is no production host migration until an upstream release and checksum manifest exist. diff --git a/crates/tinysecurity-bus/src/callbacks.rs b/crates/tinysecurity-bus/src/callbacks.rs index 5d5668b4..4dd1b8d8 100644 --- a/crates/tinysecurity-bus/src/callbacks.rs +++ b/crates/tinysecurity-bus/src/callbacks.rs @@ -137,6 +137,14 @@ impl ApprovalCasRequest2 { } /// Fixed new callback identities; incompatible semantics never reuse legacy interfaces. pub mod callback_names2 { + /// Fixed trusted-host pairing context and encrypted-state callback. + pub const DEVICE_PAIRING_HOST: &str = "ai.tinyhumans.tinysecurity.DevicePairingHost2"; + /// Resolve one server-owned pairing/socket event against authenticated host state. + pub const RESOLVE_PAIRING_EVENT: &str = "ResolvePairingEvent"; + /// Load or compare-and-swap ciphertext for the fixed module-owned identity record. + pub const PERSIST_DEVICE_PAIRING: &str = "PersistDevicePairing"; + /// Consume a one-shot import handle and load bounded legacy device ciphertext. + pub const LOAD_LEGACY_DEVICE_IDENTITIES: &str = "LoadLegacyDeviceIdentities"; /// Fixed init-bound response-controlled confidential native output slots. pub const SECRET_CIPHER_CONSUMER: &str = "ai.tinyhumans.tinysecurity.SecretCipherConsumer2"; /// Stage exact bound output privately until successful owner response. diff --git a/crates/tinysecurity-bus/src/device_frame_tests.rs b/crates/tinysecurity-bus/src/device_frame_tests.rs index 20767b45..6196d0e8 100644 --- a/crates/tinysecurity-bus/src/device_frame_tests.rs +++ b/crates/tinysecurity-bus/src/device_frame_tests.rs @@ -70,5 +70,5 @@ fn frame_operations_redact_both_payloads_without_changing_open_wire_shape() #[test] fn frame_member_name_is_stable_and_contract_version_is_bumped() { assert_eq!(crate::names::methods::DEVICE_FRAME2, "DeviceFrame2"); - assert_eq!(crate::CONTRACT_VERSION, (1, 24)); + assert_eq!(crate::CONTRACT_VERSION, (1, 25)); } diff --git a/crates/tinysecurity-bus/src/device_pairing.rs b/crates/tinysecurity-bus/src/device_pairing.rs new file mode 100644 index 00000000..80dee5b8 --- /dev/null +++ b/crates/tinysecurity-bus/src/device_pairing.rs @@ -0,0 +1,353 @@ +//! Confidential host-attested paired-device tunnel lifecycle. + +use serde::{Deserialize, Serialize}; + +/// Immutable paired-device scope and module-owned identity persistence binding. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DevicePairingScope2 { + /// Existing native device scope whose static public key is used on the wire. + pub scope_id: String, + /// Existing `HumanOnly` master registration used to encrypt the private identity. + pub secret_registration_id: String, + /// Opaque fixed backend id for the encrypted module-owned state record. + pub persistence_backend_id: String, + /// Fixed backend containing channel-keyed legacy device metadata and ciphertext. + /// `None` disables migration; it never causes a replacement identity to be minted. + pub legacy_device_backend_id: Option, + /// Fixed source registration for the legacy encrypted private-key records. + pub legacy_source_registration_id: Option, +} + +/// Init-only bounds for paired-device enrollment and reconnect. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DevicePairingConfig2 { + /// Existing trusted device scopes, at most 128. + pub scopes: Vec, + /// Maximum enrolled devices per scope, in 1..=4096. + pub max_pairings: u32, + /// Maximum pending enrollments and renewals per scope, in 1..=128. + pub max_pending: u32, + /// Maximum pending enrollment/renewal receipt lifetime in 1..=300000 milliseconds. + pub max_pending_ttl_ms: u32, + /// Maximum active session lifetime in 1..=604800000 milliseconds (seven days). + /// Active expiry is also bounded by the authenticated host channel expiry. + pub max_session_ttl_ms: u32, + /// Maximum plaintext serialized state snapshot, in 1..=1048576 bytes. + pub max_state_bytes: u32, + /// Bounded context and persistence callback wait, in 1..=30000 milliseconds. + pub callback_timeout_ms: u32, +} + +/// Exact paired-device action. Context claims are resolved through fixed host callbacks. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum DevicePairingOperation2 { + /// Load or create the encrypted module-owned identity after master approval. + Unlock { + /// Server-owned authenticated controller event; never a user or model field. + host_event_id: String, + }, + /// Wipe one authenticated user's loaded identity and all its transient tunnels on logout. + Lock { + /// Server-owned logout/session-invalidation event. + host_event_id: String, + }, + /// Start a new QR enrollment under an authenticated user controller. + Begin { + /// Server-owned authenticated controller event. + host_event_id: String, + }, + /// Import the host's available channel-specific legacy keys under the approved master. + ImportLegacy { + /// One-shot authenticated user-controller event. + host_event_id: String, + }, + /// Recover public metadata for a durable pairing whose confirmation response was lost. + Recover { + /// Host-known channel id; the callback independently authorizes this exact channel. + channel_id: String, + /// One-shot authenticated user-controller event. + host_event_id: String, + }, + /// Process the existing versioned handshake and return its wire reply. + Handshake { + /// Single-use module-minted receipt. + receipt: String, + /// Server-owned socket event for this channel and connection. + host_event_id: String, + /// Confidential unchanged protocol handshake bytes. + frame: Vec, + }, + /// Prove static device-key possession and commit enrollment or renewal. + Confirm { + /// Single-use module-minted receipt. + receipt: String, + /// Server-owned socket event for this channel and connection. + host_event_id: String, + /// Confidential first authenticated frame under the static device key. + frame: Vec, + }, + /// Start a fresh proof for the same enrolled device on a newly registered channel. + Renew { + /// Existing module-minted pairing identity. + pairing_id: String, + /// Server-owned reconnect event, not a user assertion. + host_event_id: String, + }, + /// Extend a live session from fresh host attestation without client re-handshake. + RefreshSession { + /// Existing module-minted transient session capability. + session_id: String, + /// Server-owned event for the same still-authenticated socket. + host_event_id: String, + }, + /// Revoke an enrolled device from an authenticated user controller. + Revoke { + /// Existing module-minted pairing identity. + pairing_id: String, + /// Server-owned authenticated controller event. + host_event_id: String, + }, + /// End one transient socket session while retaining durable device enrollment. + EndSession { + /// Existing module-minted transient session capability. + session_id: String, + /// Server-owned close event for this exact channel and nonce. + host_event_id: String, + }, +} + +/// Confidential trusted-host operation; identity and transport facts are callback-derived. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DevicePairingRequest2 { + /// Exact host execution identity, bounded to 128 bytes. + pub call_id: String, + /// One paired-device lifecycle operation. + pub operation: DevicePairingOperation2, +} + +/// Pairing result. Optional fields are populated only for the matching operation. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DevicePairingResponse2 { + /// Owner-minted pending receipt for Begin or Renew. + pub receipt: Option, + /// Owner-minted enrolled identity for Confirm or Renew completion. + pub pairing_id: Option, + /// Transient frame capability created by Confirm; retired on socket close. + pub session_id: Option, + /// Stable public core identity for Begin or Unlock. + pub core_public_key: Option, + /// Verified device public identity after successful Confirm. + pub device_public_key: Option, + /// Unchanged wire-format response for Handshake. + pub frame: Vec, + /// Absolute bounded expiry of pending receipt or active session, for host scheduling. + pub expires_at_ms: u64, +} + +/// Why the module is asking the host to attest an event. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DevicePairingPurpose2 { + /// Direct authenticated user-controller action for unlock, begin or revoke. + UserController, + /// One-shot direct-user authorization to import available legacy device keys. + LegacyImport, + /// Authenticated logout or user-scope invalidation observed by the host. + UserLogout, + /// A currently active authenticated event on a registered socket channel. + SocketEvent, + /// A backend-attested reconnect for an already enrolled device. + Reconnect, + /// A server-observed closure or expiry of one socket connection. + SocketClosed, +} + +/// Callback lookup; caller supplies only an opaque event id and purpose. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DevicePairingContextRequest2 { + /// Opaque server-owned event identity. + pub host_event_id: String, + /// Required host authority for this operation. + pub purpose: DevicePairingPurpose2, + /// Optional channel being authorized by a direct controller action. + /// The host must bind it to authenticated server-owned state. + pub requested_channel_id: Option, +} + +/// Facts independently resolved from the authenticated host and server-owned state. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DevicePairingContextResponse2 { + /// Existing configured device scope. + pub scope_id: String, + /// Opaque stable user binding; no user identifier is exported in clear text. + pub user_binding: String, + /// Existing Secret2 alias for this exact authenticated scope, when Unlock is authorized. + /// This module-issued alias is validated against `secret_registration_id` and generation; + /// it is never accepted from the pairing request. + pub secret_master_handle: Option, + /// One-shot host-issued capability returned only for a LegacyImport lookup. + pub legacy_import_handle: Option, + /// Registered backend channel, empty for user-controller operations. + pub channel_id: String, + /// Fresh connection nonce, empty for user-controller operations. + pub connection_nonce: String, + /// Current module generation captured by the trusted host. + pub generation: u64, + /// Actual host dispatch/socket source independently checked for this purpose. + pub source: DevicePairingSource2, + /// Absolute backend/session expiry, in milliseconds. + pub expires_at_ms: u64, +} + +/// Trusted source class returned by the host callback, never accepted from a request. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DevicePairingSource2 { + /// Authenticated direct user controller call, not an agent tool invocation. + AuthenticatedUserController, + /// Server-observed end of the authenticated user's active scope. + AuthenticatedUserLogout, + /// Agent-tool or sub-agent invocation; pairing authority is denied. + Agent, + /// Host automation; pairing authority is denied. + Automation, + /// Authenticated event on the existing registered socket channel. + RegisteredSocket, + /// Backend-authenticated reconnect for an enrolled device. + BackendReconnect, + /// Server-observed socket closure; no user interaction required. + SocketClosed, +} + +/// Fixed persistence operation; only encrypted module-owned record bytes cross the callback. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum DevicePairingPersistenceOperation2 { + /// Read the encrypted state and its compare-and-swap revision. + Load, + /// Store ciphertext only if the supplied revision still matches. + Store { + /// Revision returned by Load or the preceding successful Store. + expected_revision: String, + /// Ciphertext produced and authenticated by the module's native cipher owner. + ciphertext: Vec, + }, +} + +/// Legacy encrypted device private-key format selected by the fixed host store. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DevicePairingLegacyFormat2 { + /// Authenticated ChaCha20-Poly1305 `enc2:` value. + Enc2, + /// Historical `enc:` value; decoded only inside the module and immediately re-encrypted. + Enc, +} + +/// One old channel's ciphertext and public device identity. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DevicePairingLegacyRecord2 { + /// Existing host/backend channel id. + pub channel_id: String, + /// Public X25519 device identity from the existing paired-device metadata. + pub device_public_key: String, + /// Historical format identified by the fixed source callback. + pub format: DevicePairingLegacyFormat2, + /// Encrypted private identity only; never plaintext key bytes. + pub ciphertext: Vec, + /// Revoked entries are represented explicitly and must be refused by the module. + pub revoked: bool, +} + +/// One-shot callback request for existing channel-specific identity ciphertext. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DevicePairingLegacyLoadRequest2 { + /// Opaque host capability issued by the one-shot LegacyImport context lookup. + pub handle: String, +} + +/// Bounded existing identities from an exact trusted source; ciphertext only. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DevicePairingLegacyLoadResponse2 { + /// Exact pairing scope bound by the host event. + pub scope_id: String, + /// Exact authenticated user binding; no clear user identifier is exported. + pub user_binding: String, + /// Module generation captured by the one-shot event lookup. + pub generation: u64, + /// Exact configured source backend. + pub backend_id: String, + /// Exact configured legacy source registration. + pub source_registration_id: String, + /// At most the configured per-scope pairing capacity. + pub records: Vec, +} + +/// Exact fixed persistence binding captured during module initialization. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DevicePairingPersistenceRequest2 { + /// Configured opaque backend. + pub backend_id: String, + /// Existing configured scope. + pub scope_id: String, + /// Exact registered Secret2 master binding. + pub secret_registration_id: String, + /// One bounded load or compare-and-swap store. + pub operation: DevicePairingPersistenceOperation2, +} + +/// Load/store result; absent ciphertext means the trusted store has no record. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DevicePairingPersistenceResponse2 { + /// Exact backend echoed by the fixed callback. + pub backend_id: String, + /// Exact scope echoed by the fixed callback. + pub scope_id: String, + /// Exact master registration echoed by the fixed callback. + pub secret_registration_id: String, + /// Current immutable-store revision. + pub revision: String, + /// Encrypted record only; private key bytes are never returned here. + pub ciphertext: Option>, +} + +macro_rules! redacted { + ($($ty:ty),+ $(,)?) => {$ ( + impl std::fmt::Debug for $ty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(concat!(stringify!($ty), "([REDACTED])")) + } + } + )+}; +} +redacted!( + DevicePairingScope2, + DevicePairingConfig2, + DevicePairingOperation2, + DevicePairingRequest2, + DevicePairingResponse2, + DevicePairingContextRequest2, + DevicePairingContextResponse2, + DevicePairingPersistenceOperation2, + DevicePairingPersistenceRequest2, + DevicePairingPersistenceResponse2, + DevicePairingLegacyRecord2, + DevicePairingLegacyLoadRequest2, + DevicePairingLegacyLoadResponse2, +); + +#[cfg(test)] +#[path = "device_pairing_tests.rs"] +mod tests; diff --git a/crates/tinysecurity-bus/src/device_pairing_tests.rs b/crates/tinysecurity-bus/src/device_pairing_tests.rs new file mode 100644 index 00000000..4bb0275c --- /dev/null +++ b/crates/tinysecurity-bus/src/device_pairing_tests.rs @@ -0,0 +1,163 @@ +use super::*; +use crate::CONTRACT_VERSION; +use serde_json::json; + +#[test] +fn pairing_request_carries_only_event_or_receipt_and_confidential_protocol_bytes() +-> Result<(), Box> { + let request = DevicePairingRequest2 { + call_id: "pair-1".into(), + operation: DevicePairingOperation2::Begin { + host_event_id: "event-1".into(), + }, + }; + let encoded = serde_json::to_value(&request)?; + assert_eq!( + encoded, + json!({ + "call_id": "pair-1", + "operation": {"kind": "begin", "host_event_id": "event-1"} + }) + ); + assert!( + serde_json::from_value::(json!({ + "call_id": "pair-1", + "scope_id": "caller-asserted", + "operation": {"kind": "begin", "host_event_id": "event-1"} + })) + .is_err() + ); + Ok(()) +} + +#[test] +fn pairing_request_and_response_debug_redact_protocol_bytes_and_receipts() { + let request = DevicePairingRequest2 { + call_id: "pair-1".into(), + operation: DevicePairingOperation2::Confirm { + receipt: "private-receipt".into(), + host_event_id: "event-2".into(), + frame: vec![1, 2, 3], + }, + }; + let response = DevicePairingResponse2 { + receipt: Some("private-receipt".into()), + pairing_id: None, + session_id: None, + core_public_key: None, + device_public_key: None, + frame: vec![4, 5, 6], + expires_at_ms: 42, + }; + assert_eq!(format!("{request:?}"), "DevicePairingRequest2([REDACTED])"); + assert_eq!( + format!("{response:?}"), + "DevicePairingResponse2([REDACTED])" + ); +} + +#[test] +fn refresh_and_close_are_distinct_transient_session_operations() { + let refresh = DevicePairingRequest2 { + call_id: "refresh-1".into(), + operation: DevicePairingOperation2::RefreshSession { + session_id: "module-session".into(), + host_event_id: "socket-event".into(), + }, + }; + let close = DevicePairingRequest2 { + call_id: "close-1".into(), + operation: DevicePairingOperation2::EndSession { + session_id: "module-session".into(), + host_event_id: "close-event".into(), + }, + }; + assert!(format!("{refresh:?}").contains("REDACTED")); + assert!(format!("{close:?}").contains("REDACTED")); + let lock = DevicePairingRequest2 { + call_id: "logout-1".into(), + operation: DevicePairingOperation2::Lock { + host_event_id: "logout-event".into(), + }, + }; + assert!(format!("{lock:?}").contains("REDACTED")); +} + +#[test] +fn pairing_contract_is_versioned_after_the_frame_contract() { + assert_eq!(CONTRACT_VERSION, (1, 25)); +} + +#[test] +fn persistence_response_echoes_fixed_scope_and_backend_binding() +-> Result<(), Box> { + let response = DevicePairingPersistenceResponse2 { + backend_id: "backend-a".into(), + scope_id: "scope-a".into(), + secret_registration_id: "registration-a".into(), + revision: "rev-1".into(), + ciphertext: Some(b"enc2:opaque".to_vec()), + }; + let encoded = serde_json::to_value(&response)?; + assert_eq!(encoded["scope_id"], "scope-a"); + assert_eq!(encoded["backend_id"], "backend-a"); + assert_eq!(encoded["secret_registration_id"], "registration-a"); + assert!(format!("{response:?}").contains("REDACTED")); + Ok(()) +} + +#[test] +fn legacy_import_and_recovery_requests_carry_no_key_or_authority_claims() +-> Result<(), Box> { + for operation in [ + DevicePairingOperation2::ImportLegacy { + host_event_id: "user-event".into(), + }, + DevicePairingOperation2::Recover { + channel_id: "host-known-channel".into(), + host_event_id: "user-event".into(), + }, + ] { + let request = DevicePairingRequest2 { + call_id: "user-controller-call".into(), + operation, + }; + let encoded = serde_json::to_value(&request)?; + assert!(encoded.get("scope_id").is_none()); + assert!(encoded.get("user_binding").is_none()); + assert!(encoded.get("private_key").is_none()); + assert!(format!("{request:?}").contains("REDACTED")); + } + Ok(()) +} + +#[test] +fn legacy_source_callback_is_bounded_and_echoes_every_fixed_binding() +-> Result<(), Box> { + let request = DevicePairingLegacyLoadRequest2 { + handle: "one-shot-import-handle".into(), + }; + let response = DevicePairingLegacyLoadResponse2 { + scope_id: "scope-a".into(), + user_binding: "user-a".into(), + generation: 9, + backend_id: "legacy-device-store".into(), + source_registration_id: "legacy-key-registration".into(), + records: vec![DevicePairingLegacyRecord2 { + channel_id: "channel-a".into(), + device_public_key: "device-public-key".into(), + format: DevicePairingLegacyFormat2::Enc2, + ciphertext: b"enc2:opaque".to_vec(), + revoked: false, + }], + }; + let wire = serde_json::to_value(&response)?; + assert_eq!(wire["generation"], 9); + assert_eq!(wire["backend_id"], "legacy-device-store"); + assert_eq!(wire["source_registration_id"], "legacy-key-registration"); + assert_eq!(wire["records"][0]["revoked"], false); + assert!(format!("{request:?}").contains("REDACTED")); + assert!(format!("{response:?}").contains("REDACTED")); + assert!(!format!("{response:?}").contains("enc2:opaque")); + Ok(()) +} diff --git a/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs index 113c88bf..73e5e38a 100644 --- a/crates/tinysecurity-bus/src/lib.rs +++ b/crates/tinysecurity-bus/src/lib.rs @@ -12,6 +12,8 @@ mod device; pub use device::*; mod device_frame; pub use device_frame::*; +mod device_pairing; +pub use device_pairing::*; mod sandbox; pub use sandbox::*; mod sandbox_scopes; @@ -61,7 +63,7 @@ pub use redact::RedactorConfig; pub use scoped_policy::*; pub mod names; /// Current wire vocabulary version, independent of the artifact version. -pub const CONTRACT_VERSION: (u32, u32) = (1, 24); +pub const CONTRACT_VERSION: (u32, u32) = (1, 25); /// Whether a remote module's advertised version supplies this host's vocabulary. /// /// `version` is the provider version received by the host, not a host requirement diff --git a/crates/tinysecurity-bus/src/names.rs b/crates/tinysecurity-bus/src/names.rs index 03eec5fc..59d143b6 100644 --- a/crates/tinysecurity-bus/src/names.rs +++ b/crates/tinysecurity-bus/src/names.rs @@ -25,6 +25,8 @@ pub mod methods { pub const EXECUTE_DEVICE2: &str = "ExecuteDevice2"; /// Seal or open a frame through a live tunnel handle created by approved device work. pub const DEVICE_FRAME2: &str = "DeviceFrame2"; + /// Host-attested pairing, renewal and revocation of device tunnel identities (contract 1.25). + pub const DEVICE_PAIRING2: &str = "DevicePairing2"; /// Trusted legacy history migration without broad grant revival. pub const IMPORT_APPROVAL_HISTORY2: &str = "ImportApprovalHistory2"; /// Durable exact approval evaluation. @@ -173,6 +175,7 @@ pub const METHODS: &[&str] = &[ methods::PAIRING_CONTROL2, methods::EVALUATE_DEVICE2, methods::EXECUTE_DEVICE2, + methods::DEVICE_PAIRING2, methods::DEVICE_FRAME2, methods::PLAN, methods::EVALUATE_SANDBOX_APPROVAL2, diff --git a/crates/tinysecurity-bus/src/policy.rs b/crates/tinysecurity-bus/src/policy.rs index 70637fcc..5b34d141 100644 --- a/crates/tinysecurity-bus/src/policy.rs +++ b/crates/tinysecurity-bus/src/policy.rs @@ -419,6 +419,9 @@ pub struct ModuleConfig { /// Init-only bounded native device registration; absent preserves older payloads. #[serde(default, skip_serializing_if = "Option::is_none")] pub device: Option, + /// Init-only trusted paired-device lifecycle; absent preserves older payloads. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub device_pairing: Option, /// Init-only immutable verified sandbox authority; absent preserves legacy payloads. #[serde(default, skip_serializing_if = "Option::is_none")] pub sandbox: Option, @@ -447,6 +450,7 @@ impl Default for ModuleConfig { Self { contract_version: crate::CONTRACT_VERSION, device: None, + device_pairing: None, crypto: None, secret: None, secret_cipher: None, diff --git a/crates/tinysecurity-bus/tests/scoped_wire.rs b/crates/tinysecurity-bus/tests/scoped_wire.rs index d29bc23b..e5a1d36e 100644 --- a/crates/tinysecurity-bus/tests/scoped_wire.rs +++ b/crates/tinysecurity-bus/tests/scoped_wire.rs @@ -83,9 +83,9 @@ fn new_members_are_advertised_and_named_by_contract_constants() { #[test] fn contract_bump_is_additive_for_older_hosts_and_modules() { - assert_eq!(CONTRACT_VERSION, (1, 24)); - // A host built against 1.24 accepts this module and newer minors, not a 1.23 module. - assert!(is_compatible((1, 24))); + assert_eq!(CONTRACT_VERSION, (1, 25)); + // A host built against 1.25 accepts this module and newer minors, not a 1.24 module. + assert!(is_compatible((1, 25))); assert!(!is_compatible((1, 23))); assert!(!is_compatible((1, 22))); assert!(!is_compatible((1, 21))); diff --git a/crates/tinysecurity-bus/tests/wire.rs b/crates/tinysecurity-bus/tests/wire.rs index 8a5c1b3a..c81bd663 100644 --- a/crates/tinysecurity-bus/tests/wire.rs +++ b/crates/tinysecurity-bus/tests/wire.rs @@ -343,7 +343,7 @@ fn redactor_init_is_additive_and_secrets_never_enter_public_info() serde_json::to_value(&config)?["redactor"]["secrets"][0], "literal-private" ); - assert_eq!(CONTRACT_VERSION, (1, 24)); + assert_eq!(CONTRACT_VERSION, (1, 25)); for (mode, wire) in [ (RedactMode::Text, "text"), (RedactMode::StructuredArguments, "structured_arguments"), diff --git a/crates/tinysecurity-crypto/src/device_handles.rs b/crates/tinysecurity-crypto/src/device_handles.rs index 9c4ff23a..26cd10f2 100644 --- a/crates/tinysecurity-crypto/src/device_handles.rs +++ b/crates/tinysecurity-crypto/src/device_handles.rs @@ -33,6 +33,7 @@ struct Scope { enum Native { Key(Arc, DeviceKeyPurpose2), Tunnel(Arc>, String, String), + PairedTunnel(Arc>), } struct TunnelInput<'a> { key: &'a str, @@ -63,6 +64,29 @@ struct State { /// Service-owned registry; dropping its last owner drops all private key material. #[derive(Clone)] pub struct Registry(Arc>); + +/// Module-owned tunnel capability and its authenticated lifetime binding. +pub struct PairedTunnelRegistration<'a> { + /// Configured scope bound to the host request. + pub scope_id: &'a str, + /// Opaque module-minted frame handle. + pub handle: &'a str, + /// Confidential tunnel state, retained only by the registry. + pub tunnel: Arc>, + /// Owning module generation. + pub generation: u64, + /// Absolute authenticated session expiry. + pub expires_wall: u64, + /// Monotonic authenticated session expiry. + pub expires_monotonic: u64, + /// Current module clock reading. + pub now: Time, +} +impl std::fmt::Debug for PairedTunnelRegistration<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("PairedTunnelRegistration([REDACTED])") + } +} impl std::fmt::Debug for Registry { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("Registry([REDACTED])") @@ -297,7 +321,7 @@ impl Registry { check_operation(operation, state.limits.max_message_bytes)?; match &state.entry(handle, work)?.native { Native::Tunnel(tunnel, _, _) => Arc::clone(tunnel), - Native::Key(..) => return Err(Error::DeviceDenied), + Native::Key(..) | Native::PairedTunnel(..) => return Err(Error::DeviceDenied), } }; let bytes = tunnel @@ -319,7 +343,7 @@ impl Registry { check_operation(operation, state.limits.max_message_bytes)?; match &state.entry(handle, work)?.native { Native::Tunnel(tunnel, _, _) => Arc::clone(tunnel), - Native::Key(..) => return Err(Error::DeviceDenied), + Native::Key(..) | Native::PairedTunnel(..) => return Err(Error::DeviceDenied), } }; let bytes = tunnel @@ -379,9 +403,9 @@ impl Registry { .get(&request.handle) .filter(|entry| entry.scope == request.scope_id && entry.epoch == scope.epoch) .ok_or(Error::DeviceDenied)?; - let Native::Tunnel(_, _, _) = &entry.native else { + if !matches!(entry.native, Native::Tunnel(..) | Native::PairedTunnel(..)) { return Err(Error::DeviceDenied); - }; + } match &request.operation { DeviceFrameOperation2::Seal { plaintext } => { if plaintext.len() > state.limits.max_message_bytes as usize { @@ -414,8 +438,9 @@ impl Registry { BudgetError::Capacity => Error::DeviceCapacity, BudgetError::Rate => Error::DeviceRate, })?; - let Native::Tunnel(tunnel, _, _) = &entry.native else { - return Err(Error::DeviceDenied); + let tunnel = match &entry.native { + Native::Tunnel(tunnel, _, _) | Native::PairedTunnel(tunnel) => tunnel, + Native::Key(..) => return Err(Error::DeviceDenied), }; Arc::clone(tunnel) }; @@ -451,15 +476,113 @@ impl Registry { if generation != state.generation { return Err(Error::DeviceDenied); } - let scope = state.scopes.get(scope_id).ok_or(Error::DeviceDenied)?; + let epoch = state.scopes.get(scope_id).ok_or(Error::DeviceDenied)?.epoch; let entry = state .handles .get(handle) - .filter(|entry| entry.scope == scope_id && entry.epoch == scope.epoch) + .filter(|entry| entry.scope == scope_id && entry.epoch == epoch) + .ok_or(Error::DeviceDenied)?; + if !matches!(entry.native, Native::Tunnel(..) | Native::PairedTunnel(..)) { + return Err(Error::DeviceDenied); + } + Ok(()) + } + /// Register a module-verified paired session in the same opaque capability registry used by + /// `DeviceFrame2`. The supplied tunnel remains owned by the module and is never serialized. + /// # Errors + /// Rejects stale generation, unknown scope, duplicate handle, invalid expiry or capacity. + pub fn register_paired_tunnel(&self, registration: PairedTunnelRegistration<'_>) -> Result<()> { + let mut state = self.lock()?; + state.check_time(registration.now)?; + state.prune(registration.now); + let epoch = state + .scopes + .get(registration.scope_id) + .ok_or(Error::DeviceDenied)? + .epoch; + if registration.generation != state.generation + || registration.handle.len() != 32 + || !registration + .handle + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + || state.handles.len() >= state.limits.max_handles as usize + || state.handles.contains_key(registration.handle) + || registration.expires_wall <= registration.now.wall + || registration.expires_monotonic <= registration.now.monotonic + || registration.expires_wall - registration.now.wall > 604_800_000 + || registration.expires_monotonic - registration.now.monotonic > 604_800_000 + { + return Err(Error::DeviceDenied); + } + state.handles.insert( + registration.handle.to_owned(), + Entry { + scope: registration.scope_id.to_owned(), + epoch, + expires_wall: registration.expires_wall, + expires_monotonic: registration.expires_monotonic, + frame_rate: RateCounter::new(registration.now.monotonic), + native: Native::PairedTunnel(registration.tunnel), + }, + ); + Ok(()) + } + /// Refresh a live paired-session entry without replacing its tunnel or frame budget. + /// # Errors + /// Rejects expired, stale-generation, wrong-scope or non-tunnel handles. + pub fn refresh_paired_tunnel( + &self, + scope_id: &str, + handle: &str, + generation: u64, + expires_wall: u64, + expires_monotonic: u64, + now: Time, + ) -> Result<()> { + let mut state = self.lock()?; + state.check_time(now)?; + state.prune(now); + if generation != state.generation + || expires_wall <= now.wall + || expires_monotonic <= now.monotonic + || expires_wall - now.wall > 604_800_000 + || expires_monotonic - now.monotonic > 604_800_000 + { + return Err(Error::DeviceDenied); + } + let epoch = state.scopes.get(scope_id).ok_or(Error::DeviceDenied)?.epoch; + let entry = state + .handles + .get_mut(handle) + .filter(|entry| entry.scope == scope_id && entry.epoch == epoch) .ok_or(Error::DeviceDenied)?; - if !matches!(entry.native, Native::Tunnel(..)) { + if !matches!(entry.native, Native::Tunnel(..) | Native::PairedTunnel(..)) + || now.wall >= entry.expires_wall + || now.monotonic >= entry.expires_monotonic + { return Err(Error::DeviceDenied); } + entry.expires_wall = expires_wall; + entry.expires_monotonic = expires_monotonic; + Ok(()) + } + /// Immediately invalidate one opaque tunnel handle, including pending response validation. + /// # Errors + /// Rejects an unavailable registry; an unknown handle is already retired. + pub fn retire_paired_tunnel(&self, handle: &str) -> Result<()> { + self.lock()?.handles.remove(handle); + Ok(()) + } + /// Wipe all paired tunnel capabilities for one authenticated scope on logout. + /// In-flight frame responses fail their late registry validation after removal. + /// # Errors + /// Rejects a poisoned registry. + pub fn retire_paired_scope(&self, scope_id: &str) -> Result<()> { + let mut state = self.lock()?; + state.handles.retain(|_, entry| { + entry.scope != scope_id || !matches!(&entry.native, Native::PairedTunnel(_)) + }); Ok(()) } /// Close native authority and drop all owned credentials even if observers remain. diff --git a/crates/tinysecurity-crypto/src/device_handles_tests.rs b/crates/tinysecurity-crypto/src/device_handles_tests.rs index 09d484d2..3c2d23f1 100644 --- a/crates/tinysecurity-crypto/src/device_handles_tests.rs +++ b/crates/tinysecurity-crypto/src/device_handles_tests.rs @@ -738,6 +738,95 @@ fn frame_revalidation_rejects_a_tunnel_after_generation_change() -> TestResult { Ok(()) } +#[test] +fn paired_tunnel_uses_device_frame_registry_and_transient_retirement() -> TestResult { + let reg = registry()?; + let server_static = KeyPair::from_secret(Zeroizing::new([51; 32]))?; + let client_static = KeyPair::from_secret(Zeroizing::new([52; 32]))?; + let server_ephemeral = KeyPair::from_secret(Zeroizing::new([53; 32]))?; + let client_ephemeral = KeyPair::from_secret(Zeroizing::new([54; 32]))?; + let keys = server_static.session_keys( + client_static.public_key(), + &server_ephemeral, + client_ephemeral.public_key(), + client_ephemeral.public_key(), + server_ephemeral.public_key(), + )?; + let client_keys = client_static.session_keys( + server_static.public_key(), + &client_ephemeral, + server_ephemeral.public_key(), + client_ephemeral.public_key(), + server_ephemeral.public_key(), + )?; + let client = Tunnel::new(Role::Client, client_keys); + let server = Arc::new(Mutex::new(Tunnel::new(Role::Server, keys))); + let handle = "ab".repeat(16); + reg.register_paired_tunnel(PairedTunnelRegistration { + scope_id: &"a".repeat(32), + handle: &handle, + tunnel: server.clone(), + generation: 1, + expires_wall: 20_000, + expires_monotonic: 19_000, + now: time(0), + })?; + let sealed = client.seal(b"paired frame")?; + let opened = reg.execute_frame( + &DeviceFrameRequest2 { + scope_id: "a".repeat(32), + handle: handle.clone(), + call_id: "paired-frame".into(), + operation: DeviceFrameOperation2::Open { + frame: sealed.clone(), + }, + }, + 1, + time(0), + )?; + assert_eq!(opened.bytes, b"paired frame"); + reg.refresh_paired_tunnel(&"a".repeat(32), &handle, 1, 30_000, 29_000, time(1))?; + assert!(matches!( + reg.execute_frame( + &DeviceFrameRequest2 { + scope_id: "a".repeat(32), + handle: handle.clone(), + call_id: "paired-frame-replay".into(), + operation: DeviceFrameOperation2::Open { frame: sealed }, + }, + 1, + time(1), + ), + Err(Error::Replay) + )); + assert!( + reg.validate_frame(&"a".repeat(32), &handle, 1, time(1)) + .is_ok() + ); + reg.retire_paired_tunnel(&handle)?; + assert!( + reg.validate_frame(&"a".repeat(32), &handle, 1, time(1)) + .is_err() + ); + assert!(reg.retire_paired_tunnel(&handle).is_ok()); + let logout_handle = "cd".repeat(16); + reg.register_paired_tunnel(PairedTunnelRegistration { + scope_id: &"a".repeat(32), + handle: &logout_handle, + tunnel: server, + generation: 1, + expires_wall: 30_000, + expires_monotonic: 29_000, + now: time(1), + })?; + reg.retire_paired_scope(&"a".repeat(32))?; + assert!( + reg.validate_frame(&"a".repeat(32), &logout_handle, 1, time(1)) + .is_err() + ); + Ok(()) +} + #[test] fn confidential_frames_enforce_size_limits_expiry_and_replay() -> TestResult { let mut cfg = config(); diff --git a/crates/tinysecurity-crypto/src/device_pairing.rs b/crates/tinysecurity-crypto/src/device_pairing.rs new file mode 100644 index 00000000..f558be50 --- /dev/null +++ b/crates/tinysecurity-crypto/src/device_pairing.rs @@ -0,0 +1,1402 @@ +//! Module-owned paired-device identity, proof and tunnel lifecycle. + +use crate::{ + Error, Result, + device::{KeyPair, PublicKey, Role, Tunnel}, +}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::{Arc, Mutex}, +}; +use tinysecurity_bus::{DevicePairingConfig2, DevicePairingContextResponse2, DevicePairingSource2}; +use zeroize::{Zeroize, Zeroizing}; + +/// Trusted epoch and monotonic time, never a request field. +#[derive(Clone, Copy, Debug)] +pub struct Time { + /// Epoch milliseconds. + pub wall: u64, + /// Monotonic milliseconds. + pub monotonic: u64, +} + +struct Identity { + secret: Zeroizing<[u8; 32]>, + key: KeyPair, +} +impl std::fmt::Debug for Identity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Identity([REDACTED])") + } +} +#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +struct Record { + id: String, + scope: String, + user: String, + device_key: String, + channel: String, + nonce: String, + #[serde(default)] + identity_id: String, +} +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] +struct Revocation { + scope: String, + user: String, + channel: String, +} +struct Binding { + scope: String, + user: String, + channel: String, + nonce: String, + generation: u64, +} +impl From<&DevicePairingContextResponse2> for Binding { + fn from(c: &DevicePairingContextResponse2) -> Self { + Self { + scope: c.scope_id.clone(), + user: c.user_binding.clone(), + channel: c.channel_id.clone(), + nonce: c.connection_nonce.clone(), + generation: c.generation, + } + } +} +impl PartialEq for Binding { + fn eq(&self, other: &Self) -> bool { + self.scope == other.scope + && self.user == other.user + && self.channel == other.channel + && self.nonce == other.nonce + && self.generation == other.generation + } +} +impl Eq for Binding {} +struct Handshake { + device_key: String, + tunnel: Option>>, +} +struct Pending { + binding: Binding, + expected_key: Option, + pairing_id: Option, + identity_id: String, + expires_wall: u64, + expires_mono: u64, + handshake: Option, + confirming: bool, +} +#[derive(serde::Serialize, serde::Deserialize)] +struct Snapshot { + version: u8, + identities: BTreeMap, + #[serde(default)] + scope_identities: BTreeMap, + pairings: Vec, + #[serde(default)] + revocations: Vec, +} +impl Drop for Snapshot { + fn drop(&mut self) { + self.identities.values_mut().for_each(Zeroize::zeroize); + } +} + +/// Legacy encrypted host value after internal native decryption, ready for one atomic import. +pub struct LegacyDeviceIdentity { + /// Existing paired-device channel id. + pub channel_id: String, + /// Existing public device key from trusted host metadata. + pub device_public_key: String, + /// Private core identity, consumed and zeroized by the native owner. + pub core_private_key: Zeroizing<[u8; 32]>, + /// Explicit old metadata state; revoked identities are never imported. + pub revoked: bool, +} +impl std::fmt::Debug for LegacyDeviceIdentity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("LegacyDeviceIdentity([REDACTED])") + } +} + +/// Public metadata produced by an idempotent legacy import. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ImportedDevicePairing { + /// Existing host/backend channel. + pub channel_id: String, + /// Stable module identity used by later revoke and reconnect operations. + pub pairing_id: String, + /// Existing public device identity. + pub device_public_key: String, + /// Preserved old core identity for the unchanged wire handshake. + pub core_public_key: String, +} + +/// Public metadata recovered for a known channel. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RecoveredDevicePairing { + /// Stable module identity used by later revoke and reconnect operations. + pub pairing_id: String, + /// Existing public device identity. + pub device_public_key: String, + /// Core identity the existing device expects on the wire. + pub core_public_key: String, +} +/// Confirmation prepared for durable encrypted storage before a tunnel is published. +pub struct PreparedConfirmation { + receipt: String, + session_id: String, + record: Record, + tunnel: Arc>, + session_expires: (u64, u64), + state: Zeroizing>, +} +impl std::fmt::Debug for PreparedConfirmation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("PreparedConfirmation([REDACTED])") + } +} +impl PreparedConfirmation { + /// Private state bytes for the native cipher owner only; encrypt before persistence. + #[must_use] + pub fn state_to_encrypt(&self) -> &[u8] { + &self.state + } + /// Verified public device key. + #[must_use] + pub fn device_public_key(&self) -> &str { + &self.record.device_key + } +} + +/// Public metadata returned only after durable confirmation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PairingResult { + /// Stable module-minted identity. + pub pairing_id: String, + /// Transient module-minted frame capability; changes on every successful reconnect. + pub session_id: String, + /// Verified device wire public key. + pub device_public_key: String, + /// Registered device scope. + pub scope_id: String, + /// Active backend channel. + pub channel_id: String, + /// Absolute session expiry. + pub expires_at_ms: u64, +} + +/// Native owner for stable identities, one-time receipts and bounded tunnel sessions. +pub struct DevicePairingOwner { + config: DevicePairingConfig2, + generation: u64, + identities: BTreeMap, + scope_identities: BTreeMap, + records: BTreeMap, + revocations: BTreeSet, + sessions: BTreeMap, + tunnels: BTreeMap>>, + session_expiries: BTreeMap, + pending: BTreeMap, + last: Time, + faulted: bool, +} +impl std::fmt::Debug for DevicePairingOwner { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("DevicePairingOwner([REDACTED])") + } +} +impl DevicePairingOwner { + /// Initialize from module-owned identities loaded by the native cipher owner. + /// # Errors + /// Rejects invalid bounds, scopes, keys or generation. + pub fn new( + config: DevicePairingConfig2, + generation: u64, + now: Time, + secrets: BTreeMap>, + ) -> Result { + validate_config(&config, generation)?; + if secrets.len() != config.scopes.len() + || config + .scopes + .iter() + .any(|s| !secrets.contains_key(&s.scope_id)) + { + return Err(Error::DeviceDenied); + } + let mut identities = BTreeMap::new(); + let mut scope_identities = BTreeMap::new(); + for (scope, secret) in secrets { + let key = KeyPair::from_secret(Zeroizing::new(*secret))?; + let identity_id = scope_identity_id(&scope); + if identities + .insert(identity_id.clone(), Identity { secret, key }) + .is_some() + { + return Err(Error::DeviceDenied); + } + scope_identities.insert(scope, identity_id); + } + Ok(Self { + config, + generation, + identities, + scope_identities, + records: BTreeMap::new(), + revocations: BTreeSet::new(), + sessions: BTreeMap::new(), + tunnels: BTreeMap::new(), + session_expiries: BTreeMap::new(), + pending: BTreeMap::new(), + last: now, + faulted: false, + }) + } + + /// Restore stable private identities and durable pairings from an authenticated module snapshot. + /// The caller must first decrypt the snapshot with the configured Secret2 master capability. + /// # Errors + /// Rejects corrupt state, unknown scopes, invalid identities or incompatible bounds. + pub fn restore( + config: DevicePairingConfig2, + generation: u64, + now: Time, + plaintext: &[u8], + ) -> Result { + let mut snapshot: Snapshot = + serde_json::from_slice(plaintext).map_err(|_| Error::DeviceDenied)?; + if snapshot.version != 1 && snapshot.version != 2 { + return Err(Error::DeviceDenied); + } + let old_scope_layout = snapshot.version == 1; + let mut secrets = std::mem::take(&mut snapshot.identities) + .into_iter() + .map(|(identity_id, secret)| (identity_id, Zeroizing::new(secret))) + .collect::>(); + let scope_identities = if old_scope_layout { + let mut migrated = BTreeMap::new(); + for scope in &config.scopes { + let secret = secrets.remove(&scope.scope_id).ok_or(Error::DeviceDenied)?; + let identity_id = scope_identity_id(&scope.scope_id); + secrets.insert(identity_id.clone(), secret); + migrated.insert(scope.scope_id.clone(), identity_id); + } + migrated + } else { + std::mem::take(&mut snapshot.scope_identities) + }; + if scope_identities.len() != config.scopes.len() + || config.scopes.iter().any(|scope| { + scope_identities + .get(&scope.scope_id) + .is_none_or(|identity_id| !secrets.contains_key(identity_id)) + }) + { + return Err(Error::DeviceDenied); + } + validate_config(&config, generation)?; + let mut owner = Self { + config, + generation, + identities: BTreeMap::new(), + scope_identities: scope_identities.clone(), + records: BTreeMap::new(), + revocations: BTreeSet::new(), + sessions: BTreeMap::new(), + tunnels: BTreeMap::new(), + session_expiries: BTreeMap::new(), + pending: BTreeMap::new(), + last: now, + faulted: false, + }; + for (identity_id, secret) in secrets { + let key = KeyPair::from_secret(Zeroizing::new(*secret))?; + owner + .identities + .insert(identity_id, Identity { secret, key }); + } + for record in std::mem::take(&mut snapshot.pairings) { + let mut record = record; + if record.identity_id.is_empty() && old_scope_layout { + record.identity_id = scope_identity_id(&record.scope); + } + if !owner.scope_identities.contains_key(&record.scope) + || !owner.identities.contains_key(&record.identity_id) + || record.id.len() != 32 + || !record.id.bytes().all(|byte| byte.is_ascii_hexdigit()) + || PublicKey::decode(&record.device_key).is_err() + || record.user.is_empty() + || !valid_channel(&record.channel) + || record.nonce.is_empty() + || owner + .records + .values() + .any(|existing| existing.channel == record.channel) + || owner.records.insert(record.id.clone(), record).is_some() + { + return Err(Error::DeviceDenied); + } + } + if snapshot.revocations.len() > owner.config.max_pairings as usize { + return Err(Error::DeviceDenied); + } + for revocation in std::mem::take(&mut snapshot.revocations) { + if !owner.scope_identities.contains_key(&revocation.scope) + || revocation.user.is_empty() + || !valid_channel(&revocation.channel) + || owner + .revocations + .iter() + .any(|existing| existing.channel == revocation.channel) + { + return Err(Error::DeviceDenied); + } + owner.revocations.insert(revocation); + } + if owner.records.values().any(|record| { + owner.revocations.iter().any(|revocation| { + revocation.scope == record.scope + && revocation.user == record.user + && revocation.channel == record.channel + }) + }) { + return Err(Error::DeviceDenied); + } + if owner.records.len().saturating_add(owner.revocations.len()) + > owner.config.max_pairings as usize + { + return Err(Error::DeviceDenied); + } + Ok(owner) + } + + /// Return the stable core public key for a configured scope. + /// # Errors + /// Denies unknown scopes, clock rollback and faulted authority. + pub fn public_key(&mut self, scope: &str, now: Time) -> Result { + self.check_time(now)?; + let identity_id = self + .scope_identities + .get(scope) + .ok_or(Error::DeviceDenied)?; + self.identities + .get(identity_id) + .map(|i| i.key.public_key().encode()) + .ok_or(Error::DeviceDenied) + } + + /// Return the exact core public key assigned to one enrolled pairing. + /// # Errors + /// Denies an unknown pairing, unavailable clock or faulted owner. + pub fn core_public_key_for_pairing(&mut self, id: &str, now: Time) -> Result { + self.check_time(now)?; + let record = self.records.get(id).ok_or(Error::DeviceDenied)?; + self.identities + .get(&record.identity_id) + .map(|identity| identity.key.public_key().encode()) + .ok_or(Error::DeviceDenied) + } + + /// Serialize the current identity and pairing registry for immediate internal encryption. + /// The returned zeroizing buffer must never be passed to a host callback. + /// # Errors + /// Rejects faulted state or a snapshot exceeding the configured size limit. + pub fn snapshot_state(&self) -> Result>> { + self.snapshot(&self.records) + } + + /// Import a validated whole batch of old channel-specific static identities. + /// The supplied private bytes are already confined to the module and are + /// consumed into zeroizing native key owners before any state is published. + /// # Errors + /// Rejects invalid source/context, revoked or duplicate rows, conflicts, + /// invalid keys, capacity overflow or an oversized resulting snapshot. + pub fn import_legacy_identities( + &mut self, + context: &DevicePairingContextResponse2, + rows: Vec, + now: Time, + ) -> Result> { + self.check_time(now)?; + let binding = self.user_controller_context(context, now)?; + if rows.len() > self.config.max_pairings as usize { + return Err(Error::DeviceDenied); + } + let mut seen = BTreeSet::new(); + let mut new_identities = BTreeMap::new(); + let mut new_records = BTreeMap::new(); + let mut imported = Vec::with_capacity(rows.len()); + for row in rows { + if row.revoked + || !valid_channel(&row.channel_id) + || !seen.insert(row.channel_id.clone()) + { + return Err(Error::DeviceDenied); + } + // Module revocation is authoritative even if the host's stale legacy + // ciphertext still claims that the old channel is active. + if self.revocations.contains(&Revocation { + scope: binding.scope.clone(), + user: binding.user.clone(), + channel: row.channel_id.clone(), + }) { + return Err(Error::DeviceDenied); + } + PublicKey::decode(&row.device_public_key)?; + let secret = row.core_private_key; + let key = KeyPair::from_secret(Zeroizing::new(*secret))?; + let core_public_key = key.public_key().encode(); + if let Some(existing) = self + .records + .values() + .find(|record| record.channel == row.channel_id) + { + let existing_identity = self + .identities + .get(&existing.identity_id) + .ok_or(Error::DeviceDenied)?; + if existing.scope != binding.scope + || existing.user != binding.user + || existing.device_key != row.device_public_key + || existing_identity.key.public_key().encode() != core_public_key + { + return Err(Error::DeviceDenied); + } + imported.push(ImportedDevicePairing { + channel_id: existing.channel.clone(), + pairing_id: existing.id.clone(), + device_public_key: existing.device_key.clone(), + core_public_key, + }); + continue; + } + let id = random_id()?; + let identity_id = format!("legacy:{id}"); + let identity = Identity { secret, key }; + let record = Record { + id: id.clone(), + scope: binding.scope.clone(), + user: binding.user.clone(), + device_key: row.device_public_key.clone(), + channel: row.channel_id.clone(), + nonce: "legacy-imported".into(), + identity_id: identity_id.clone(), + }; + if new_identities.insert(identity_id, identity).is_some() + || new_records.insert(id.clone(), record).is_some() + { + return Err(Error::DeviceDenied); + } + imported.push(ImportedDevicePairing { + channel_id: row.channel_id, + pairing_id: id, + device_public_key: row.device_public_key, + core_public_key, + }); + } + if self + .records + .len() + .saturating_add(new_records.len()) + .saturating_add(self.revocations.len()) + .saturating_add( + self.pending + .values() + .filter(|pending| pending.pairing_id.is_none()) + .count(), + ) + > self.config.max_pairings as usize + { + return Err(Error::DeviceDenied); + } + let mut candidate_identities = self + .identities + .iter() + .map(|(id, identity)| (id.clone(), *identity.secret)) + .collect::>(); + candidate_identities.extend( + new_identities + .iter() + .map(|(id, identity)| (id.clone(), *identity.secret)), + ); + let mut candidate_records = self.records.clone(); + candidate_records.extend(new_records.clone()); + let candidate = Snapshot { + version: 2, + identities: candidate_identities, + scope_identities: self.scope_identities.clone(), + pairings: candidate_records.values().cloned().collect(), + revocations: self.revocations.iter().cloned().collect(), + }; + let candidate_bytes = + Zeroizing::new(serde_json::to_vec(&candidate).map_err(|_| Error::DeviceDenied)?); + if candidate_bytes.len() > self.config.max_state_bytes as usize { + return Err(Error::DeviceDenied); + } + drop(candidate); + new_identities.into_iter().for_each(|(id, identity)| { + self.identities.insert(id, identity); + }); + self.records.extend(new_records); + Ok(imported) + } + + /// Merge the latest authenticated persistence snapshot without discarding live tunnels. + /// This repairs the in-memory owner after a CAS committed but its response was lost. + /// # Errors + /// Rejects corrupt, foreign, conflicting or incompatible durable state. + pub fn merge_durable_state( + &mut self, + plaintext: &[u8], + context: &DevicePairingContextResponse2, + now: Time, + ) -> Result<()> { + self.check_time(now)?; + let binding = self.user_controller_context(context, now)?; + let durable = Self::restore(self.config.clone(), self.generation, now, plaintext)?; + if durable.scope_identities != self.scope_identities { + return Err(Error::DeviceDenied); + } + for (identity_id, identity) in &durable.identities { + if let Some(current) = self.identities.get(identity_id) { + if current.key.public_key() != identity.key.public_key() { + return Err(Error::DeviceDenied); + } + } + } + for record in durable.records.values() { + if record.scope != binding.scope || record.user != binding.user { + return Err(Error::DeviceDenied); + } + if durable + .revocations + .iter() + .chain(self.revocations.iter()) + .any(|revocation| { + revocation.scope == record.scope + && revocation.user == record.user + && revocation.channel == record.channel + }) + { + return Err(Error::DeviceDenied); + } + if let Some(current) = self.records.get(&record.id) { + if current != record { + return Err(Error::DeviceDenied); + } + } else if self + .records + .values() + .any(|current| current.channel == record.channel) + { + return Err(Error::DeviceDenied); + } + } + if self.records.values().any(|record| { + durable.revocations.iter().any(|revocation| { + revocation.scope == record.scope + && revocation.user == record.user + && revocation.channel == record.channel + }) + }) { + return Err(Error::DeviceDenied); + } + let mut identities = self + .identities + .iter() + .map(|(id, identity)| (id.clone(), *identity.secret)) + .collect::>(); + for (id, identity) in &durable.identities { + identities.entry(id.clone()).or_insert(*identity.secret); + } + let mut records = self.records.clone(); + records.extend(durable.records.clone()); + let mut revocations = self.revocations.clone(); + revocations.extend(durable.revocations.iter().cloned()); + let candidate = Snapshot { + version: 2, + identities, + scope_identities: self.scope_identities.clone(), + pairings: records.values().cloned().collect(), + revocations: revocations.iter().cloned().collect(), + }; + let bytes = + Zeroizing::new(serde_json::to_vec(&candidate).map_err(|_| Error::DeviceDenied)?); + if bytes.len() > self.config.max_state_bytes as usize + || records.len().saturating_add(revocations.len()) > self.config.max_pairings as usize + { + return Err(Error::DeviceDenied); + } + drop(candidate); + for (id, identity) in durable.identities { + self.identities.entry(id).or_insert(identity); + } + self.records.extend(durable.records); + self.revocations = revocations; + Ok(()) + } + + /// Recover public pairing metadata for one callback-authorized channel. + /// # Errors + /// Rejects missing, ambiguous, revoked or foreign pairings. + pub fn recover_channel( + &mut self, + channel_id: &str, + context: &DevicePairingContextResponse2, + now: Time, + ) -> Result { + self.check_time(now)?; + let binding = self.user_controller_context(context, now)?; + if !valid_channel(channel_id) || context.channel_id != channel_id { + return Err(Error::DeviceDenied); + } + let mut matches = self.records.values().filter(|record| { + record.scope == binding.scope + && record.user == binding.user + && record.channel == channel_id + }); + let record = matches.next().ok_or(Error::DeviceDenied)?; + if matches.next().is_some() { + return Err(Error::DeviceDenied); + } + let core_public_key = self + .identities + .get(&record.identity_id) + .ok_or(Error::DeviceDenied)? + .key + .public_key() + .encode(); + Ok(RecoveredDevicePairing { + pairing_id: record.id.clone(), + device_public_key: record.device_key.clone(), + core_public_key, + }) + } + + /// Begin initial enrollment from the authenticated user-controller source. + /// # Errors + /// Rejects invalid source, scope, context, expiry or capacity. + pub fn begin( + &mut self, + context: &DevicePairingContextResponse2, + ttl_ms: u32, + now: Time, + ) -> Result<(String, String, u64)> { + self.check_time(now)?; + let binding = self.context(context, DevicePairingSource2::AuthenticatedUserController)?; + if self.revocations.contains(&Revocation { + scope: binding.scope.clone(), + user: binding.user.clone(), + channel: binding.channel.clone(), + }) { + return Err(Error::DeviceDenied); + } + if !self.scope_identities.contains_key(&binding.scope) + || binding.channel.is_empty() + || binding.nonce.is_empty() + || self.records.len() + + self.revocations.len() + + self + .pending + .values() + .filter(|pending| pending.pairing_id.is_none()) + .count() + >= self.config.max_pairings as usize + { + return Err(Error::DeviceDenied); + } + let identity_id = self + .scope_identities + .get(&binding.scope) + .cloned() + .ok_or(Error::DeviceDenied)?; + self.new_pending( + binding, + None, + None, + identity_id, + ttl_ms, + context.expires_at_ms, + now, + ) + } + + /// Begin same-key renewal under a fresh backend reconnect attestation. + /// # Errors + /// Rejects wrong user/scope/source or stale pairing. + pub fn renew( + &mut self, + id: &str, + context: &DevicePairingContextResponse2, + ttl_ms: u32, + now: Time, + ) -> Result<(String, String, u64)> { + self.check_time(now)?; + let binding = self.context(context, DevicePairingSource2::BackendReconnect)?; + let record = self.records.get(id).ok_or(Error::DeviceDenied)?; + if record.scope != binding.scope || record.user != binding.user { + return Err(Error::DeviceDenied); + } + let tombstone = Revocation { + scope: record.scope.clone(), + user: record.user.clone(), + channel: record.channel.clone(), + }; + if !self.revocations.contains(&tombstone) + && self.revocations.len() >= self.config.max_pairings as usize + { + return Err(Error::DeviceDenied); + } + let expected_key = record.device_key.clone(); + let identity_id = record.identity_id.clone(); + let pairing_id = record.id.clone(); + self.new_pending( + binding, + Some(expected_key), + Some(pairing_id), + identity_id, + ttl_ms, + context.expires_at_ms, + now, + ) + } + + /// Open the unchanged v3 handshake and return its unchanged v4 acknowledgement. + /// # Errors + /// Rejects expired, replayed, substituted or unauthenticated handshakes. + pub fn handshake( + &mut self, + receipt: &str, + context: &DevicePairingContextResponse2, + frame: &[u8], + now: Time, + ) -> Result> { + self.check_time(now)?; + let binding = self.context(context, DevicePairingSource2::RegisteredSocket)?; + let pending = self.pending.get_mut(receipt).ok_or(Error::DeviceDenied)?; + validate_pending(pending, &binding, now)?; + if pending.handshake.is_some() { + return Err(Error::DeviceDenied); + } + let identity_id = pending.identity_id.clone(); + let identity = self + .identities + .get(&identity_id) + .ok_or(Error::DeviceDenied)?; + let (_client_handshake_ephemeral, plaintext) = identity.key.open_handshake(frame)?; + let payload: HandshakePayload = + serde_json::from_slice(&plaintext).map_err(|_| Error::DeviceDenied)?; + let peer_static = PublicKey::decode(&payload.device_pubkey)?; + let client_session_ephemeral = PublicKey::decode(&payload.client_ephemeral_pubkey)?; + if pending + .expected_key + .as_deref() + .is_some_and(|expected| expected != payload.device_pubkey) + { + return Err(Error::DeviceDenied); + } + let server_ephemeral = KeyPair::generate()?; + let server_public = server_ephemeral.public_key(); + let keys = identity.key.session_keys( + peer_static, + &server_ephemeral, + client_session_ephemeral, + client_session_ephemeral, + server_public, + )?; + let tunnel = Arc::new(Mutex::new(Tunnel::new(Role::Server, keys))); + let ack = serde_json::to_vec(&serde_json::json!({"kind":"handshake_ack", "server_ephemeral_pubkey":server_public.encode()})).map_err(|_| Error::DeviceDenied)?; + let ack = identity.key.seal_ack(client_session_ephemeral, &ack)?; + pending.handshake = Some(Handshake { + device_key: payload.device_pubkey, + tunnel: Some(tunnel), + }); + Ok(ack) + } + + /// Verify first static-DH-authenticated frame and prepare a durable transaction. + /// # Errors + /// Rejects missing or replayed proof, mismatched context, wrong renewal key or expiry. + pub fn prepare_confirmation( + &mut self, + receipt: &str, + context: &DevicePairingContextResponse2, + frame: &[u8], + now: Time, + ) -> Result { + self.check_time(now)?; + let binding = self.context(context, DevicePairingSource2::RegisteredSocket)?; + let record = { + let pending = self.pending.get_mut(receipt).ok_or(Error::DeviceDenied)?; + validate_pending(pending, &binding, now)?; + if pending.confirming { + return Err(Error::DeviceDenied); + } + let handshake = pending.handshake.as_mut().ok_or(Error::DeviceDenied)?; + handshake + .tunnel + .as_ref() + .ok_or(Error::DeviceDenied)? + .lock() + .map_err(|_| Error::DeviceDenied)? + .open(frame)?; + Record { + id: pending.pairing_id.clone().map_or_else(random_id, Ok)?, + scope: binding.scope.clone(), + user: binding.user.clone(), + device_key: handshake.device_key.clone(), + channel: binding.channel.clone(), + nonce: binding.nonce.clone(), + identity_id: pending.identity_id.clone(), + } + }; + let session_expires = + session_expiry(now, self.config.max_session_ttl_ms, context.expires_at_ms)?; + let mut records = self.records.clone(); + records.insert(record.id.clone(), record.clone()); + let state = self.snapshot(&records)?; + let session_id = random_id()?; + let pending = self.pending.get_mut(receipt).ok_or(Error::DeviceDenied)?; + pending.confirming = true; + let tunnel = pending + .handshake + .as_mut() + .and_then(|handshake| handshake.tunnel.take()) + .ok_or(Error::DeviceDenied)?; + Ok(PreparedConfirmation { + receipt: receipt.into(), + session_id, + record, + tunnel, + session_expires, + state, + }) + } + + /// Commit after ciphertext CAS storage succeeds; only then does the handle become usable. + /// # Errors + /// Rejects expired, stale-generation, substituted or absent confirmation state. + pub fn commit_confirmation( + &mut self, + prepared: PreparedConfirmation, + now: Time, + ) -> Result { + self.check_time(now)?; + let pending = self + .pending + .get(&prepared.receipt) + .ok_or(Error::DeviceDenied)?; + if !pending.confirming + || now.wall >= pending.expires_wall + || now.monotonic >= pending.expires_mono + || pending.binding.generation != self.generation + || pending + .pairing_id + .as_ref() + .is_some_and(|id| id != &prepared.record.id) + { + return Err(Error::DeviceDenied); + } + self.pending.remove(&prepared.receipt); + self.sessions + .retain(|_, pairing_id| pairing_id != &prepared.record.id); + self.tunnels + .retain(|session_id, _| self.sessions.contains_key(session_id)); + self.session_expiries + .retain(|session_id, _| self.sessions.contains_key(session_id)); + let session_id = prepared.session_id; + self.sessions + .insert(session_id.clone(), prepared.record.id.clone()); + self.tunnels.insert(session_id.clone(), prepared.tunnel); + self.session_expiries + .insert(session_id.clone(), prepared.session_expires); + self.records + .insert(prepared.record.id.clone(), prepared.record.clone()); + Ok(to_result( + &prepared.record, + session_id, + prepared.session_expires.0, + )) + } + + /// Cancel a failed persistence transaction and consume its receipt. + pub fn abort_confirmation(&mut self, receipt: &str) { + self.pending.remove(receipt); + } + + /// Retire only a socket session while preserving the durable pairing record. + /// # Errors + /// Rejects a closure from another scope, channel, nonce or source. + pub fn end_session( + &mut self, + session_id: &str, + context: &DevicePairingContextResponse2, + now: Time, + ) -> Result<()> { + self.check_time(now)?; + let binding = self.context(context, DevicePairingSource2::SocketClosed)?; + let id = self.sessions.get(session_id).ok_or(Error::DeviceDenied)?; + let record = self.records.get(id).ok_or(Error::DeviceDenied)?; + if record.scope != binding.scope + || record.user != binding.user + || record.channel != binding.channel + || record.nonce != binding.nonce + { + return Err(Error::DeviceDenied); + } + self.sessions.remove(session_id); + self.tunnels.remove(session_id); + self.session_expiries.remove(session_id); + Ok(()) + } + + /// Refresh a live session from a fresh attestation of its same authenticated socket. + /// This retains the existing tunnel keys and counters and does not alter durable pairing. + /// # Errors + /// Rejects an expired/closed handle or a foreign, stale, or unauthenticated socket. + pub fn refresh_session( + &mut self, + session_id: &str, + context: &DevicePairingContextResponse2, + now: Time, + ) -> Result<(u64, u64)> { + self.check_time(now)?; + let binding = self.context(context, DevicePairingSource2::RegisteredSocket)?; + let id = self.sessions.get(session_id).ok_or(Error::DeviceDenied)?; + let record = self.records.get(id).ok_or(Error::DeviceDenied)?; + let (current_wall, current_mono) = *self + .session_expiries + .get(session_id) + .ok_or(Error::DeviceDenied)?; + if record.scope != binding.scope + || record.user != binding.user + || record.channel != binding.channel + || record.nonce != binding.nonce + || now.wall >= current_wall + || now.monotonic >= current_mono + { + return Err(Error::DeviceDenied); + } + let expires = session_expiry(now, self.config.max_session_ttl_ms, context.expires_at_ms)?; + self.session_expiries.insert(session_id.into(), expires); + Ok(expires) + } + + /// Return all transient handles belonging to one durable pairing before revocation. + #[must_use] + pub fn sessions_for_pairing(&self, pairing_id: &str) -> Vec { + self.sessions + .iter() + .filter_map(|(session, pairing)| (pairing == pairing_id).then_some(session.clone())) + .collect() + } + + /// Return current transient handles that a pending renewal will replace. + #[must_use] + pub fn sessions_for_pending(&self, receipt: &str) -> Vec { + self.pending + .get(receipt) + .and_then(|pending| pending.pairing_id.as_deref()) + .map(|pairing_id| self.sessions_for_pairing(pairing_id)) + .unwrap_or_default() + } + + /// Return pending receipts tied to one pairing before revocation. + #[must_use] + pub fn pending_for_pairing(&self, pairing_id: &str) -> Vec { + self.pending + .iter() + .filter_map(|(receipt, pending)| { + (pending.pairing_id.as_deref() == Some(pairing_id)).then_some(receipt.clone()) + }) + .collect() + } + + /// Retire a newly committed but not yet published transient session. + /// Durable enrollment remains intact. + pub fn retire_session(&mut self, session_id: &str) { + self.sessions.remove(session_id); + self.tunnels.remove(session_id); + self.session_expiries.remove(session_id); + } + + /// Obtain the module-internal shared tunnel for registration in the existing `DeviceFrame2` + /// capability registry. The returned handle is never exposed through the bus. + /// # Errors + /// Rejects unknown, expired, or retired session capabilities. + pub fn session_tunnel( + &self, + session_id: &str, + now: Time, + ) -> Result<(String, Arc>, u64, u64)> { + if self.faulted || now.wall < self.last.wall || now.monotonic < self.last.monotonic { + return Err(Error::DeviceDenied); + } + let pairing_id = self.sessions.get(session_id).ok_or(Error::DeviceDenied)?; + let record = self.records.get(pairing_id).ok_or(Error::DeviceDenied)?; + let (expires_wall, expires_monotonic) = *self + .session_expiries + .get(session_id) + .ok_or(Error::DeviceDenied)?; + if now.wall >= expires_wall || now.monotonic >= expires_monotonic { + return Err(Error::DeviceDenied); + } + Ok(( + record.scope.clone(), + Arc::clone(self.tunnels.get(session_id).ok_or(Error::DeviceDenied)?), + expires_wall, + expires_monotonic, + )) + } + + /// Remove one pairing and every live tunnel, returning the state to persist before success. + /// # Errors + /// Rejects an unauthenticated or cross-scope controller. + pub fn revoke( + &mut self, + id: &str, + context: &DevicePairingContextResponse2, + now: Time, + ) -> Result>> { + self.check_time(now)?; + let binding = self.context(context, DevicePairingSource2::AuthenticatedUserController)?; + let record = self.records.get(id).ok_or(Error::DeviceDenied)?; + if record.scope != binding.scope || record.user != binding.user { + return Err(Error::DeviceDenied); + } + let tombstone = Revocation { + scope: record.scope.clone(), + user: record.user.clone(), + channel: record.channel.clone(), + }; + if !self.revocations.contains(&tombstone) + && self.revocations.len() >= self.config.max_pairings as usize + { + return Err(Error::DeviceDenied); + } + let removed = self + .sessions + .iter() + .filter_map(|(session, pairing)| (pairing == id).then_some(session.clone())) + .collect::>(); + self.pending + .retain(|_, pending| pending.pairing_id.as_deref() != Some(id)); + for session in removed { + self.sessions.remove(&session); + self.tunnels.remove(&session); + self.session_expiries.remove(&session); + } + let record = self.records.remove(id).ok_or(Error::DeviceDenied)?; + self.revocations.insert(tombstone); + self.remove_unreferenced_identity(&record.identity_id); + self.snapshot(&self.records) + } + + /// Seal one frame through a live tunnel capability. + /// # Errors + /// Rejects unknown, wrong-scope, expired or retired sessions. + pub fn seal( + &mut self, + session_id: &str, + scope: &str, + bytes: &[u8], + now: Time, + ) -> Result> { + self.check_time(now)?; + let id = self.sessions.get(session_id).ok_or(Error::DeviceDenied)?; + let record = self.records.get(id).ok_or(Error::DeviceDenied)?; + let (expires_wall, expires_monotonic) = *self + .session_expiries + .get(session_id) + .ok_or(Error::DeviceDenied)?; + if record.scope != scope || now.wall >= expires_wall || now.monotonic >= expires_monotonic { + return Err(Error::DeviceDenied); + } + self.tunnels + .get(session_id) + .ok_or(Error::DeviceDenied)? + .lock() + .map_err(|_| Error::DeviceDenied)? + .seal(bytes) + } + + /// Open one authenticated frame through a live tunnel capability. + /// # Errors + /// Rejects unknown, wrong-scope, expired, replayed or retired sessions. + pub fn open( + &mut self, + session_id: &str, + scope: &str, + frame: &[u8], + now: Time, + ) -> Result>> { + self.check_time(now)?; + let id = self.sessions.get(session_id).ok_or(Error::DeviceDenied)?; + let record = self.records.get(id).ok_or(Error::DeviceDenied)?; + let (expires_wall, expires_monotonic) = *self + .session_expiries + .get(session_id) + .ok_or(Error::DeviceDenied)?; + if record.scope != scope || now.wall >= expires_wall || now.monotonic >= expires_monotonic { + return Err(Error::DeviceDenied); + } + self.tunnels + .get(session_id) + .ok_or(Error::DeviceDenied)? + .lock() + .map_err(|_| Error::DeviceDenied)? + .open(frame) + } + + /// Wipe every private identity, pairing, pending receipt and tunnel at shutdown/reinit. + pub fn close(&mut self) { + self.faulted = true; + self.identities.clear(); + self.scope_identities.clear(); + self.records.clear(); + self.revocations.clear(); + self.sessions.clear(); + self.tunnels.clear(); + self.session_expiries.clear(); + self.pending.clear(); + } + + /// Permanently fault after an indeterminate persistence failure. + pub fn fail_persistence(&mut self) { + self.close(); + } + + fn context( + &self, + c: &DevicePairingContextResponse2, + source: DevicePairingSource2, + ) -> Result { + if c.source != source + || c.generation != self.generation + || c.scope_id.is_empty() + || c.user_binding.is_empty() + || (source != DevicePairingSource2::AuthenticatedUserController + && (c.channel_id.is_empty() || c.connection_nonce.is_empty())) + || c.expires_at_ms <= self.last.wall + || !self.scope_identities.contains_key(&c.scope_id) + { + return Err(Error::DeviceDenied); + } + Ok(Binding::from(c)) + } + fn user_controller_context( + &self, + context: &DevicePairingContextResponse2, + now: Time, + ) -> Result { + if context.source != DevicePairingSource2::AuthenticatedUserController + || context.generation != self.generation + || context.scope_id.is_empty() + || context.user_binding.is_empty() + || context.expires_at_ms <= now.wall + || !self.scope_identities.contains_key(&context.scope_id) + { + return Err(Error::DeviceDenied); + } + Ok(Binding::from(context)) + } + fn new_pending( + &mut self, + binding: Binding, + expected_key: Option, + pairing_id: Option, + identity_id: String, + ttl: u32, + context_expiry: u64, + now: Time, + ) -> Result<(String, String, u64)> { + if self.pending.len() >= self.config.max_pending as usize + || ttl == 0 + || ttl > self.config.max_pending_ttl_ms + { + return Err(Error::DeviceDenied); + } + let expires_wall = now + .wall + .checked_add(u64::from(ttl)) + .ok_or(Error::DeviceDenied)? + .min(context_expiry); + let expires_mono = now + .monotonic + .checked_add(u64::from(ttl)) + .ok_or(Error::DeviceDenied)?; + if expires_wall <= now.wall { + return Err(Error::DeviceDenied); + } + let receipt = random_id()?; + if self.pending.contains_key(&receipt) { + return Err(Error::DeviceDenied); + } + let public = self + .identities + .get(&identity_id) + .ok_or(Error::DeviceDenied)? + .key + .public_key() + .encode(); + self.pending.insert( + receipt.clone(), + Pending { + binding, + expected_key, + pairing_id, + identity_id, + expires_wall, + expires_mono, + handshake: None, + confirming: false, + }, + ); + Ok((receipt, public, expires_wall)) + } + fn check_time(&mut self, now: Time) -> Result<()> { + if self.faulted || now.wall < self.last.wall || now.monotonic < self.last.monotonic { + self.close(); + return Err(Error::DeviceDenied); + } + self.last = now; + self.pending + .retain(|_, p| now.wall < p.expires_wall && now.monotonic < p.expires_mono); + Ok(()) + } + fn snapshot(&self, records: &BTreeMap) -> Result>> { + if self.faulted { + return Err(Error::DeviceDenied); + } + let snapshot = Snapshot { + version: 2, + identities: self + .identities + .iter() + .map(|(id, i)| (id.clone(), *i.secret)) + .collect(), + scope_identities: self.scope_identities.clone(), + pairings: records.values().cloned().collect(), + revocations: self.revocations.iter().cloned().collect(), + }; + if records.len().saturating_add(self.revocations.len()) > self.config.max_pairings as usize + { + return Err(Error::DeviceDenied); + } + let bytes = serde_json::to_vec(&snapshot).map_err(|_| Error::DeviceDenied)?; + if bytes.len() > self.config.max_state_bytes as usize { + return Err(Error::DeviceDenied); + } + Ok(Zeroizing::new(bytes)) + } + + fn remove_unreferenced_identity(&mut self, identity_id: &str) { + let is_scope_identity = self.scope_identities.values().any(|id| id == identity_id); + let is_referenced = self + .records + .values() + .any(|record| record.identity_id == identity_id); + if !is_scope_identity && !is_referenced { + self.identities.remove(identity_id); + } + } +} + +fn validate_config(c: &DevicePairingConfig2, generation: u64) -> Result<()> { + if generation == 0 + || c.scopes.is_empty() + || c.scopes.len() > 128 + || !(1..=4096).contains(&c.max_pairings) + || !(1..=128).contains(&c.max_pending) + || !(1..=300_000).contains(&c.max_pending_ttl_ms) + || !(1..=604_800_000).contains(&c.max_session_ttl_ms) + || !(1..=1_048_576).contains(&c.max_state_bytes) + || !(1..=30_000).contains(&c.callback_timeout_ms) + { + return Err(Error::DeviceDenied); + } + let mut ids = BTreeSet::new(); + for s in &c.scopes { + if s.scope_id.len() != 32 + || !s.scope_id.bytes().all(|b| b.is_ascii_hexdigit()) + || s.secret_registration_id.len() != 32 + || s.persistence_backend_id.is_empty() + || !ids.insert(s.scope_id.as_str()) + { + return Err(Error::DeviceDenied); + } + match ( + s.legacy_device_backend_id.as_deref(), + s.legacy_source_registration_id.as_deref(), + ) { + (None, None) => {} + (Some(backend), Some(registration)) + if !backend.is_empty() + && registration.len() == 32 + && registration.bytes().all(|byte| byte.is_ascii_hexdigit()) => {} + _ => return Err(Error::DeviceDenied), + } + } + Ok(()) +} + +fn scope_identity_id(scope: &str) -> String { + format!("scope:{scope}") +} + +fn valid_channel(channel: &str) -> bool { + !channel.is_empty() + && channel.len() <= 128 + && channel + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':')) +} +fn validate_pending(p: &Pending, b: &Binding, now: Time) -> Result<()> { + if p.binding != *b || now.wall >= p.expires_wall || now.monotonic >= p.expires_mono { + Err(Error::DeviceDenied) + } else { + Ok(()) + } +} +fn session_expiry(now: Time, max_ttl_ms: u32, host_expiry: u64) -> Result<(u64, u64)> { + let ttl = u64::from(max_ttl_ms).min(host_expiry.saturating_sub(now.wall)); + if ttl == 0 { + return Err(Error::DeviceDenied); + } + Ok(( + now.wall.checked_add(ttl).ok_or(Error::DeviceDenied)?, + now.monotonic.checked_add(ttl).ok_or(Error::DeviceDenied)?, + )) +} +fn to_result(r: &Record, session_id: String, expires_at_ms: u64) -> PairingResult { + PairingResult { + pairing_id: r.id.clone(), + session_id, + device_public_key: r.device_key.clone(), + scope_id: r.scope.clone(), + channel_id: r.channel.clone(), + expires_at_ms, + } +} +fn random_id() -> Result { + let mut bytes = [0; 16]; + getrandom::fill(&mut bytes).map_err(|_| Error::Entropy)?; + let mut id = String::with_capacity(32); + for b in bytes { + use std::fmt::Write as _; + write!(&mut id, "{b:02x}").map_err(|_| Error::Entropy)?; + } + Ok(id) +} +#[derive(serde::Deserialize)] +struct HandshakePayload { + device_pubkey: String, + client_ephemeral_pubkey: String, +} + +#[cfg(test)] +#[path = "device_pairing_tests.rs"] +mod tests; diff --git a/crates/tinysecurity-crypto/src/device_pairing_tests.rs b/crates/tinysecurity-crypto/src/device_pairing_tests.rs new file mode 100644 index 00000000..5ee8783a --- /dev/null +++ b/crates/tinysecurity-crypto/src/device_pairing_tests.rs @@ -0,0 +1,865 @@ +// Small deterministic cryptographic fixtures use unwraps to keep each negative assertion focused; +// production paths remain fully fallible and the test-only allowance does not propagate upward. +#![allow(clippy::unwrap_used)] + +use super::*; +use crate::device::PublicKey; + +const SCOPE: &str = "0123456789abcdef0123456789abcdef"; + +fn config() -> DevicePairingConfig2 { + DevicePairingConfig2 { + scopes: vec![tinysecurity_bus::DevicePairingScope2 { + scope_id: SCOPE.into(), + secret_registration_id: "abcdef0123456789abcdef0123456789".into(), + persistence_backend_id: "user-secret-store".into(), + legacy_device_backend_id: None, + legacy_source_registration_id: None, + }], + max_pairings: 8, + max_pending: 4, + max_pending_ttl_ms: 60_000, + max_session_ttl_ms: 604_800_000, + max_state_bytes: 65_536, + callback_timeout_ms: 1000, + } +} +fn time(ms: u64) -> Time { + Time { + wall: ms, + monotonic: ms, + } +} +fn context( + source: DevicePairingSource2, + channel: &str, + nonce: &str, + expiry: u64, +) -> DevicePairingContextResponse2 { + DevicePairingContextResponse2 { + scope_id: SCOPE.into(), + user_binding: "user-binding".into(), + secret_master_handle: None, + legacy_import_handle: None, + channel_id: channel.into(), + connection_nonce: nonce.into(), + generation: 1, + source, + expires_at_ms: expiry, + } +} +fn owner() -> DevicePairingOwner { + DevicePairingOwner::new( + config(), + 1, + time(1_000), + [(SCOPE.into(), Zeroizing::new([0x41; 32]))].into(), + ) + .unwrap() +} +fn start_handshake( + owner: &mut DevicePairingOwner, + receipt: &str, + channel: &str, + nonce: &str, + device: &KeyPair, + now: u64, +) -> (Tunnel, Vec) { + let core = PublicKey::decode(&owner.public_key(SCOPE, time(now)).unwrap()).unwrap(); + let client_handshake_ephemeral = KeyPair::generate().unwrap(); + let client_session_ephemeral = KeyPair::generate().unwrap(); + let payload = serde_json::to_vec(&serde_json::json!({ + "device_pubkey": device.public_key().encode(), + "client_ephemeral_pubkey": client_session_ephemeral.public_key().encode(), + })) + .unwrap(); + let frame = client_handshake_ephemeral + .seal_handshake(core, &payload) + .unwrap(); + let ack = owner + .handshake( + receipt, + &context( + DevicePairingSource2::RegisteredSocket, + channel, + nonce, + 10_000, + ), + &frame, + time(now + 1), + ) + .unwrap(); + let ack_payload: serde_json::Value = + serde_json::from_slice(&client_session_ephemeral.open_ack(core, &ack).unwrap()).unwrap(); + let server_ephemeral = + PublicKey::decode(ack_payload["server_ephemeral_pubkey"].as_str().unwrap()).unwrap(); + let keys = device + .session_keys( + core, + &client_session_ephemeral, + server_ephemeral, + client_session_ephemeral.public_key(), + server_ephemeral, + ) + .unwrap(); + (Tunnel::new(Role::Client, keys), ack) +} + +fn start_handshake_with_core( + owner: &mut DevicePairingOwner, + receipt: &str, + core_public: &str, + channel: &str, + nonce: &str, + device: &KeyPair, + now: u64, +) -> Tunnel { + let core = PublicKey::decode(core_public).unwrap(); + let client_handshake_ephemeral = KeyPair::generate().unwrap(); + let client_session_ephemeral = KeyPair::generate().unwrap(); + let payload = serde_json::to_vec(&serde_json::json!({ + "device_pubkey": device.public_key().encode(), + "client_ephemeral_pubkey": client_session_ephemeral.public_key().encode(), + })) + .unwrap(); + let frame = client_handshake_ephemeral + .seal_handshake(core, &payload) + .unwrap(); + let ack = owner + .handshake( + receipt, + &context( + DevicePairingSource2::RegisteredSocket, + channel, + nonce, + 30_000, + ), + &frame, + time(now), + ) + .unwrap(); + let ack_payload: serde_json::Value = + serde_json::from_slice(&client_session_ephemeral.open_ack(core, &ack).unwrap()).unwrap(); + let server_ephemeral = + PublicKey::decode(ack_payload["server_ephemeral_pubkey"].as_str().unwrap()).unwrap(); + let keys = device + .session_keys( + core, + &client_session_ephemeral, + server_ephemeral, + client_session_ephemeral.public_key(), + server_ephemeral, + ) + .unwrap(); + Tunnel::new(Role::Client, keys) +} + +#[test] +fn enrollment_requires_first_static_key_authenticated_frame_and_durable_commit() { + let mut owner = owner(); + let (receipt, core_public, _) = owner + .begin( + &context( + DevicePairingSource2::AuthenticatedUserController, + "channel-a", + "nonce-a", + 10_000, + ), + 5_000, + time(1_000), + ) + .unwrap(); + assert_eq!(core_public, owner.public_key(SCOPE, time(1_001)).unwrap()); + let device = KeyPair::generate().unwrap(); + let (mut client, _) = + start_handshake(&mut owner, &receipt, "channel-a", "nonce-a", &device, 1_001); + let proof = client.seal(b"authenticated first frame").unwrap(); + let prepared = owner + .prepare_confirmation( + &receipt, + &context( + DevicePairingSource2::RegisteredSocket, + "channel-a", + "nonce-a", + 10_000, + ), + &proof, + time(1_003), + ) + .unwrap(); + assert_ne!(prepared.state_to_encrypt().first(), None); + assert!( + owner + .seal("not-yet-committed", SCOPE, b"x", time(1_003)) + .is_err() + ); + let result = owner.commit_confirmation(prepared, time(1_004)).unwrap(); + assert_eq!(result.device_public_key, device.public_key().encode()); + let sealed = owner + .seal( + &result.session_id, + SCOPE, + b"after durable commit", + time(1_005), + ) + .unwrap(); + assert_eq!(&*client.open(&sealed).unwrap(), b"after durable commit"); + assert!( + owner + .prepare_confirmation( + &receipt, + &context( + DevicePairingSource2::RegisteredSocket, + "channel-a", + "nonce-a", + 10_000 + ), + &proof, + time(1_006) + ) + .is_err() + ); +} + +#[test] +fn renewal_requires_same_static_key_and_fresh_context() { + let mut owner = owner(); + let device = KeyPair::generate().unwrap(); + let (receipt, _, _) = owner + .begin( + &context( + DevicePairingSource2::AuthenticatedUserController, + "channel-a", + "nonce-a", + 10_000, + ), + 5_000, + time(1_000), + ) + .unwrap(); + let (client, _) = start_handshake(&mut owner, &receipt, "channel-a", "nonce-a", &device, 1_001); + let proof = client.seal(b"proof").unwrap(); + let prepared = owner + .prepare_confirmation( + &receipt, + &context( + DevicePairingSource2::RegisteredSocket, + "channel-a", + "nonce-a", + 10_000, + ), + &proof, + time(1_003), + ) + .unwrap(); + let paired = owner.commit_confirmation(prepared, time(1_004)).unwrap(); + let (renewal, _, _) = owner + .renew( + &paired.pairing_id, + &context( + DevicePairingSource2::BackendReconnect, + "channel-b", + "nonce-b", + 20_000, + ), + 5_000, + time(1_005), + ) + .unwrap(); + let impostor = KeyPair::generate().unwrap(); + let core = PublicKey::decode(&owner.public_key(SCOPE, time(1_006)).unwrap()).unwrap(); + let handshake_ephemeral = KeyPair::generate().unwrap(); + let session_ephemeral = KeyPair::generate().unwrap(); + let payload = serde_json::to_vec(&serde_json::json!({ + "device_pubkey": impostor.public_key().encode(), + "client_ephemeral_pubkey": session_ephemeral.public_key().encode(), + })) + .unwrap(); + let frame = handshake_ephemeral.seal_handshake(core, &payload).unwrap(); + assert!( + owner + .handshake( + &renewal, + &context( + DevicePairingSource2::RegisteredSocket, + "channel-b", + "nonce-b", + 20_000 + ), + &frame, + time(1_007) + ) + .is_err() + ); +} + +#[test] +fn socket_close_retires_only_the_tunnel_and_reconnect_keeps_the_pairing() { + let mut owner = owner(); + let device = KeyPair::generate().unwrap(); + let (receipt, _, _) = owner + .begin( + &context( + DevicePairingSource2::AuthenticatedUserController, + "channel-a", + "nonce-a", + 10_000, + ), + 5_000, + time(1_000), + ) + .unwrap(); + let (client, _) = start_handshake(&mut owner, &receipt, "channel-a", "nonce-a", &device, 1_001); + let proof = client.seal(b"proof").unwrap(); + let prepared = owner + .prepare_confirmation( + &receipt, + &context( + DevicePairingSource2::RegisteredSocket, + "channel-a", + "nonce-a", + 10_000, + ), + &proof, + time(1_003), + ) + .unwrap(); + let paired = owner.commit_confirmation(prepared, time(1_004)).unwrap(); + owner + .end_session( + &paired.session_id, + &context( + DevicePairingSource2::SocketClosed, + "channel-a", + "nonce-a", + 10_000, + ), + time(1_005), + ) + .unwrap(); + assert!( + owner + .refresh_session( + &paired.session_id, + &context( + DevicePairingSource2::RegisteredSocket, + "channel-a", + "nonce-a", + 20_000, + ), + time(1_006), + ) + .is_err() + ); + assert!( + owner + .seal(&paired.session_id, SCOPE, b"closed", time(1_006)) + .is_err() + ); + assert!( + owner + .renew( + &paired.pairing_id, + &context( + DevicePairingSource2::BackendReconnect, + "channel-b", + "nonce-b", + 20_000 + ), + 5_000, + time(1_006) + ) + .is_ok() + ); +} + +#[test] +fn stale_socket_close_cannot_retire_a_newly_renewed_session() { + let mut owner = owner(); + let device = KeyPair::generate().unwrap(); + let (receipt, _, _) = owner + .begin( + &context( + DevicePairingSource2::AuthenticatedUserController, + "channel-a", + "nonce-a", + 10_000, + ), + 5_000, + time(1_000), + ) + .unwrap(); + let (client, _) = start_handshake(&mut owner, &receipt, "channel-a", "nonce-a", &device, 1_001); + let proof = client.seal(b"proof").unwrap(); + let prepared = owner + .prepare_confirmation( + &receipt, + &context( + DevicePairingSource2::RegisteredSocket, + "channel-a", + "nonce-a", + 10_000, + ), + &proof, + time(1_003), + ) + .unwrap(); + let first = owner.commit_confirmation(prepared, time(1_004)).unwrap(); + let (renewal, _, _) = owner + .renew( + &first.pairing_id, + &context( + DevicePairingSource2::BackendReconnect, + "channel-b", + "nonce-b", + 20_000, + ), + 5_000, + time(1_005), + ) + .unwrap(); + let (mut reconnect, _) = + start_handshake(&mut owner, &renewal, "channel-b", "nonce-b", &device, 1_006); + let proof = reconnect.seal(b"fresh proof").unwrap(); + let prepared = owner + .prepare_confirmation( + &renewal, + &context( + DevicePairingSource2::RegisteredSocket, + "channel-b", + "nonce-b", + 20_000, + ), + &proof, + time(1_008), + ) + .unwrap(); + let second = owner.commit_confirmation(prepared, time(1_009)).unwrap(); + assert_ne!(first.session_id, second.session_id); + assert_eq!(owner.session_expiries.len(), 1); + assert!(!owner.session_expiries.contains_key(&first.session_id)); + assert!( + owner + .end_session( + &first.session_id, + &context( + DevicePairingSource2::SocketClosed, + "channel-a", + "nonce-a", + 10_000 + ), + time(1_010) + ) + .is_err() + ); + let sealed = owner + .seal(&second.session_id, SCOPE, b"still live", time(1_011)) + .unwrap(); + assert_eq!(&*reconnect.open(&sealed).unwrap(), b"still live"); +} + +#[test] +fn live_session_refresh_preserves_tunnel_keys_and_replay_counters() { + let mut owner = owner(); + let device = KeyPair::generate().unwrap(); + let (receipt, _, _) = owner + .begin( + &context( + DevicePairingSource2::AuthenticatedUserController, + "channel-a", + "nonce-a", + 10_000, + ), + 5_000, + time(1_000), + ) + .unwrap(); + let (mut client, _) = + start_handshake(&mut owner, &receipt, "channel-a", "nonce-a", &device, 1_001); + let proof = client.seal(b"proof").unwrap(); + let prepared = owner + .prepare_confirmation( + &receipt, + &context( + DevicePairingSource2::RegisteredSocket, + "channel-a", + "nonce-a", + 10_000, + ), + &proof, + time(1_003), + ) + .unwrap(); + let paired = owner.commit_confirmation(prepared, time(1_004)).unwrap(); + let frame = client.seal(b"one-use frame").unwrap(); + assert_eq!( + &*owner + .open(&paired.session_id, SCOPE, &frame, time(1_005)) + .unwrap(), + b"one-use frame" + ); + let refreshed = owner + .refresh_session( + &paired.session_id, + &context( + DevicePairingSource2::RegisteredSocket, + "channel-a", + "nonce-a", + 50_000, + ), + time(1_006), + ) + .unwrap(); + assert!(refreshed.0 > paired.expires_at_ms); + assert!( + owner + .open(&paired.session_id, SCOPE, &frame, time(1_007)) + .is_err() + ); + let sealed = owner + .seal(&paired.session_id, SCOPE, b"same tunnel", time(1_008)) + .unwrap(); + assert_eq!(&*client.open(&sealed).unwrap(), b"same tunnel"); + assert!( + owner + .refresh_session( + &paired.session_id, + &context( + DevicePairingSource2::RegisteredSocket, + "channel-a", + "stale-nonce", + 60_000, + ), + time(1_009), + ) + .is_err() + ); + assert!( + owner + .refresh_session( + &paired.session_id, + &context( + DevicePairingSource2::RegisteredSocket, + "channel-a", + "nonce-a", + 60_000, + ), + time(refreshed.0), + ) + .is_err() + ); +} + +#[test] +fn restore_preserves_public_identity_and_rejects_corrupt_or_foreign_state() { + let mut owner = owner(); + let public = owner.public_key(SCOPE, time(1_001)).unwrap(); + let snapshot = owner.snapshot(&owner.records).unwrap(); + let mut restored = DevicePairingOwner::restore(config(), 2, time(2_000), &snapshot).unwrap(); + assert_eq!(restored.public_key(SCOPE, time(2_001)).unwrap(), public); + assert!(DevicePairingOwner::restore(config(), 2, time(2_000), b"tampered").is_err()); + let foreign = context( + DevicePairingSource2::AuthenticatedUserController, + "channel-x", + "nonce-x", + 10_000, + ); + assert!(owner.begin(&foreign, 5_000, time(1_002)).is_ok()); +} + +#[test] +fn imported_channel_identities_survive_reload_and_select_the_original_wire_key() { + let mut owner = owner(); + let first_secret = Zeroizing::new([0x51; 32]); + let second_secret = Zeroizing::new([0x62; 32]); + let first_public = KeyPair::from_secret(Zeroizing::new(*first_secret)) + .unwrap() + .public_key() + .encode(); + let second_public = KeyPair::from_secret(Zeroizing::new(*second_secret)) + .unwrap() + .public_key() + .encode(); + assert_ne!(first_public, second_public); + let first_device = KeyPair::generate().unwrap(); + let second_device = KeyPair::generate().unwrap(); + let imported = owner + .import_legacy_identities( + &context( + DevicePairingSource2::AuthenticatedUserController, + "", + "", + 10_000, + ), + vec![ + LegacyDeviceIdentity { + channel_id: "legacy-channel-a".into(), + device_public_key: first_device.public_key().encode(), + core_private_key: first_secret, + revoked: false, + }, + LegacyDeviceIdentity { + channel_id: "legacy-channel-b".into(), + device_public_key: second_device.public_key().encode(), + core_private_key: second_secret, + revoked: false, + }, + ], + time(1_010), + ) + .unwrap(); + assert_eq!(imported.len(), 2); + let first = imported + .iter() + .find(|record| record.channel_id == "legacy-channel-a") + .unwrap(); + let second = imported + .iter() + .find(|record| record.channel_id == "legacy-channel-b") + .unwrap(); + assert_eq!(first.core_public_key, first_public); + assert_eq!(second.core_public_key, second_public); + let snapshot = owner.snapshot_state().unwrap(); + let mut restored = DevicePairingOwner::restore(config(), 1, time(1_020), &snapshot).unwrap(); + assert_eq!( + restored + .core_public_key_for_pairing(&first.pairing_id, time(1_021)) + .unwrap(), + first_public + ); + assert_eq!( + restored + .core_public_key_for_pairing(&second.pairing_id, time(1_022)) + .unwrap(), + second_public + ); + for (ordinal, imported, device, expected_public) in [ + (0, first, &first_device, &first_public), + (1, second, &second_device, &second_public), + ] { + let channel = format!("legacy-renewal-{ordinal}"); + let nonce = format!("legacy-nonce-{ordinal}"); + let start = 1_030 + ordinal as u64 * 10; + let (receipt, core_public, _) = restored + .renew( + &imported.pairing_id, + &context( + DevicePairingSource2::BackendReconnect, + &channel, + &nonce, + 30_000, + ), + 5_000, + time(start), + ) + .unwrap(); + assert_eq!(&core_public, expected_public); + let client = start_handshake_with_core( + &mut restored, + &receipt, + expected_public, + &channel, + &nonce, + device, + start + 1, + ); + let proof = client.seal(b"legacy device possession").unwrap(); + let prepared = restored + .prepare_confirmation( + &receipt, + &context( + DevicePairingSource2::RegisteredSocket, + &channel, + &nonce, + 30_000, + ), + &proof, + time(start + 2), + ) + .unwrap(); + restored + .commit_confirmation(prepared, time(start + 3)) + .unwrap(); + } +} + +#[test] +fn legacy_import_is_atomic_idempotent_and_channel_recovery_is_user_scoped() { + let mut owner = owner(); + let secret = Zeroizing::new([0x73; 32]); + let device_key = KeyPair::generate().unwrap(); + let device = device_key.public_key().encode(); + let rows = || { + vec![LegacyDeviceIdentity { + channel_id: "legacy-channel-a".into(), + device_public_key: device.clone(), + core_private_key: Zeroizing::new(*secret), + revoked: false, + }] + }; + let authorized = context( + DevicePairingSource2::AuthenticatedUserController, + "", + "", + 10_000, + ); + let imported = owner + .import_legacy_identities(&authorized, rows(), time(1_010)) + .unwrap(); + let again = owner + .import_legacy_identities(&authorized, rows(), time(1_011)) + .unwrap(); + assert_eq!(again, imported); + let authorized_channel = DevicePairingContextResponse2 { + channel_id: "legacy-channel-a".into(), + ..authorized.clone() + }; + let recovered = owner + .recover_channel("legacy-channel-a", &authorized_channel, time(1_012)) + .unwrap(); + assert_eq!(recovered.pairing_id, imported[0].pairing_id); + assert_eq!(recovered.device_public_key, device); + let foreign = DevicePairingContextResponse2 { + user_binding: "another-user".into(), + ..authorized.clone() + }; + assert!( + owner + .recover_channel( + "legacy-channel-a", + &DevicePairingContextResponse2 { + channel_id: "legacy-channel-a".into(), + ..foreign + }, + time(1_013), + ) + .is_err() + ); + let conflicting = vec![LegacyDeviceIdentity { + channel_id: "legacy-channel-a".into(), + device_public_key: KeyPair::generate().unwrap().public_key().encode(), + core_private_key: Zeroizing::new([0x74; 32]), + revoked: false, + }]; + assert!( + owner + .import_legacy_identities(&authorized, conflicting, time(1_014)) + .is_err() + ); + assert_eq!(owner.records.len(), 1); + let (pending_renewal, renewed_core, _) = owner + .renew( + &imported[0].pairing_id, + &context( + DevicePairingSource2::BackendReconnect, + "legacy-channel-a", + "renewal-nonce", + 10_000, + ), + 5_000, + time(1_014), + ) + .unwrap(); + let renewal_tunnel = start_handshake_with_core( + &mut owner, + &pending_renewal, + &renewed_core, + "legacy-channel-a", + "renewal-nonce", + &device_key, + 1_014, + ); + let stale_proof = renewal_tunnel.seal(b"proof before revoke").unwrap(); + assert_eq!( + owner.pending_for_pairing(&imported[0].pairing_id), + vec![pending_renewal.clone()] + ); + let tombstone = owner + .revoke(&imported[0].pairing_id, &authorized, time(1_015)) + .unwrap(); + assert!( + owner + .pending_for_pairing(&imported[0].pairing_id) + .is_empty() + ); + assert!( + owner + .handshake( + &pending_renewal, + &context( + DevicePairingSource2::RegisteredSocket, + "legacy-channel-a", + "renewal-nonce", + 20_000, + ), + b"stale receipt", + time(1_016), + ) + .is_err(), + "revocation invalidates an already-issued renewal receipt" + ); + assert!( + owner + .prepare_confirmation( + &pending_renewal, + &context( + DevicePairingSource2::RegisteredSocket, + "legacy-channel-a", + "renewal-nonce", + 20_000, + ), + &stale_proof, + time(1_016), + ) + .is_err(), + "a proof from a revoked renewal cannot confirm" + ); + assert!( + owner + .begin( + &context( + DevicePairingSource2::AuthenticatedUserController, + "legacy-channel-a", + "replacement-nonce", + 20_000, + ), + 5_000, + time(1_016), + ) + .is_err(), + "a fresh enrollment cannot reuse a tombstoned channel" + ); + let (_, _, expiry) = owner + .begin( + &context( + DevicePairingSource2::AuthenticatedUserController, + "different-channel", + "fresh-nonce", + 20_000, + ), + 5_000, + time(1_016), + ) + .unwrap(); + assert!(expiry > 1_016, "a different channel remains available"); + assert!(PublicKey::decode(&renewed_core).is_ok()); + let mut reloaded = DevicePairingOwner::restore(config(), 1, time(1_016), &tombstone).unwrap(); + let stale_legacy = vec![LegacyDeviceIdentity { + channel_id: "legacy-channel-a".into(), + device_public_key: device, + core_private_key: Zeroizing::new([0x73; 32]), + revoked: false, + }]; + assert!( + reloaded + .import_legacy_identities(&authorized, stale_legacy, time(1_017)) + .is_err() + ); + assert!(reloaded.records.is_empty()); + assert_eq!(reloaded.revocations.len(), 1); + assert!( + reloaded + .pending_for_pairing(&imported[0].pairing_id) + .is_empty() + ); +} diff --git a/crates/tinysecurity-crypto/src/lib.rs b/crates/tinysecurity-crypto/src/lib.rs index ccc60bcf..d1c83b17 100644 --- a/crates/tinysecurity-crypto/src/lib.rs +++ b/crates/tinysecurity-crypto/src/lib.rs @@ -16,6 +16,7 @@ //! ``` pub mod device; pub mod device_handles; +pub mod device_pairing; mod error; pub mod keyring; pub mod native_handles; diff --git a/crates/tinysecurity-crypto/src/native_handles/cipher.rs b/crates/tinysecurity-crypto/src/native_handles/cipher.rs index 8453d093..b45df6fa 100644 --- a/crates/tinysecurity-crypto/src/native_handles/cipher.rs +++ b/crates/tinysecurity-crypto/src/native_handles/cipher.rs @@ -297,6 +297,66 @@ pub struct CipherOwner { identity: [u8; 32], pending: Arc, } +/// Module-internal persistent-state cipher retained from a live `HumanOnly` master. +/// It never crosses a bus boundary and is fenced by registration epoch and module +/// generation, while the original alias expiry continues to govern only unlock. +pub struct DevicePairingStateCipher { + key: Zeroizing<[u8; 32]>, + registry: Registry, + registration: String, + scope_id: String, + persistence_backend_id: String, + generation: u64, + epoch: u64, +} +impl std::fmt::Debug for DevicePairingStateCipher { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("DevicePairingStateCipher([REDACTED])") + } +} +impl DevicePairingStateCipher { + /// Check the immutable pairing slot binding without exposing master material. + #[must_use] + pub fn is_bound_to( + &self, + registration: &str, + scope_id: &str, + persistence_backend_id: &str, + generation: u64, + ) -> bool { + self.registration == registration + && self.scope_id == scope_id + && self.persistence_backend_id == persistence_backend_id + && self.generation == generation + } + fn validate(&self) -> Result<()> { + let (state, _) = self.registry.checked_lock()?; + if state.closed + || state.generation != self.generation + || state + .scopes + .get(&self.registration) + .is_none_or(|scope| scope.epoch != self.epoch) + { + return Err(Error::NativeDenied); + } + Ok(()) + } + /// Encrypt a module-owned persistent record using the existing enc2 format. + /// # Errors + /// Denies stale generation/registration authority and oversized values. + pub fn encrypt(&self, plaintext: &str) -> Result> { + self.validate()?; + SecretCipher::new(Zeroizing::new(*self.key), true).encrypt_confidential(plaintext) + } + /// Decrypt an internal persistent record without exposing plaintext to a host callback. + /// # Errors + /// Denies stale generation/registration authority and invalid ciphertext. + pub fn decrypt(&self, ciphertext: &str) -> Result> { + self.validate()?; + SecretCipher::new(Zeroizing::new(*self.key), true).decrypt(ciphertext) + } +} impl CipherOwner { /// Capture immutable native configuration and fixed consumer. /// # Errors @@ -495,6 +555,47 @@ impl CipherOwner { } self.validate(pending.work.as_ref().ok_or(Error::NativeDenied)?) } + /// Retain the selected master for one module-owned persistence identity. + /// The returned cipher survives alias expiry but is fenced by generation, + /// registration epoch, registry shutdown and owner drop. + /// # Errors + /// Denies disabled encryption, stale authority or non-encryption reservations. + pub fn retain_device_pairing_state( + &self, + pending: CipherPending, + scope_id: &str, + persistence_backend_id: &str, + ) -> Result { + if !self.config.enabled { + return Err(Error::NativeDenied); + } + if scope_id.is_empty() + || scope_id.len() > 128 + || persistence_backend_id.is_empty() + || persistence_backend_id.len() > 128 + { + return Err(Error::NativeDenied); + } + let work = self.consume(pending)?; + let binding = work.binding.cipher.as_ref().ok_or(Error::NativeDenied)?; + if binding.kind != CipherKind::Encrypt || !binding.enabled { + return Err(Error::NativeDenied); + } + let (key, epoch) = { + let (state, now) = self.registry.checked_lock()?; + let key = self.validate_locked(&state, &work, now)?; + (Zeroizing::new(*key.bytes()), binding.master_epoch) + }; + Ok(DevicePairingStateCipher { + key, + registry: self.registry.clone(), + registration: work.binding.registration.clone(), + scope_id: scope_id.into(), + persistence_backend_id: persistence_backend_id.into(), + generation: work.binding.generation, + epoch, + }) + } /// Consume pending authority once and synchronously execute before delivery. /// No lock spans delivery; the original reservation remains owned throughout. /// # Errors diff --git a/crates/tinysecurity-crypto/src/native_handles/cipher_tests.rs b/crates/tinysecurity-crypto/src/native_handles/cipher_tests.rs index 309c8fe6..17137b58 100644 --- a/crates/tinysecurity-crypto/src/native_handles/cipher_tests.rs +++ b/crates/tinysecurity-crypto/src/native_handles/cipher_tests.rs @@ -334,6 +334,57 @@ async fn disabled_plaintext_empty_and_legacy_replacements_use_confidential_deliv } Ok(()) } + +#[tokio::test] +async fn pairing_state_cipher_keeps_plaintext_inside_module_without_host_delivery() -> TestResult { + let f = Fixture::new(true, Limits::default()).await?; + let internal = f.owner.retain_device_pairing_state( + f.pending(CipherKind::Encrypt, b"seed")?, + "scope-a", + "backend-a", + )?; + assert!(internal.is_bound_to("a", "scope-a", "backend-a", 1)); + assert!(!internal.is_bound_to("b", "scope-a", "backend-a", 1)); + assert!(!internal.is_bound_to("a", "scope-b", "backend-a", 1)); + assert!(!internal.is_bound_to("a", "scope-a", "backend-b", 1)); + let encrypted = internal.encrypt(r#"{"private_identity":"module-only"}"#)?; + assert!(encrypted.starts_with("enc2:")); + let decrypted = internal.decrypt(&encrypted)?; + assert_eq!(decrypted.as_str(), r#"{"private_identity":"module-only"}"#); + assert_eq!(f.capture.calls.load(Ordering::SeqCst), 0); + + let disabled = Fixture::new(false, Limits::default()).await?; + assert!(matches!( + disabled.owner.retain_device_pairing_state( + disabled.pending(CipherKind::Encrypt, b"seed")?, + "scope-a", + "backend-a" + ), + Err(Error::NativeDenied) + )); + assert_eq!(disabled.capture.calls.load(Ordering::SeqCst), 0); + Ok(()) +} + +#[tokio::test] +async fn retained_pairing_cipher_outlives_alias_expiry_but_not_generation() -> TestResult { + let f = Fixture::new(true, Limits::default()).await?; + let internal = f.owner.retain_device_pairing_state( + f.pending(CipherKind::Encrypt, b"seed")?, + "scope-a", + "backend-a", + )?; + f.clock.elapsed.store(2_000, Ordering::SeqCst); + let ciphertext = internal.encrypt("module state")?; + assert_eq!(internal.decrypt(&ciphertext)?.as_str(), "module state"); + f.reg.advance_generation(2)?; + assert!(matches!( + internal.decrypt(&ciphertext), + Err(Error::NativeDenied) + )); + Ok(()) +} + #[tokio::test] async fn malformed_tampered_wrong_master_and_non_utf8_inputs_never_deliver() -> TestResult { let f = Fixture::new(true, Limits::default()).await?; diff --git a/crates/tinysecurity-module/Cargo.toml b/crates/tinysecurity-module/Cargo.toml index bbe25c2a..3c553066 100644 --- a/crates/tinysecurity-module/Cargo.toml +++ b/crates/tinysecurity-module/Cargo.toml @@ -21,10 +21,12 @@ tinysecurity-bus = { workspace = true } tinybus = { workspace = true } tinybus-module = { workspace = true } serde_json = { workspace = true } +serde = { workspace = true } # Wipe unpublished native output on every error or canceled worker join. zeroize.workspace = true # Independent opaque aliases and operation correlations; no key export. getrandom.workspace = true +base64.workspace = true # Stable module-derived original-content correlation for redacted audit retries. sha2.workspace = true # Filesystem policy runs on blocking workers without blocking the module runtime. diff --git a/crates/tinysecurity-module/examples/secret_cipher_native_contract/fixture.rs b/crates/tinysecurity-module/examples/secret_cipher_native_contract/fixture.rs index e18ffc14..76dc1f98 100644 --- a/crates/tinysecurity-module/examples/secret_cipher_native_contract/fixture.rs +++ b/crates/tinysecurity-module/examples/secret_cipher_native_contract/fixture.rs @@ -7,7 +7,10 @@ use async_trait::async_trait; use std::{ collections::BTreeMap, path::Path, - sync::{Arc, Mutex, OnceLock, atomic::AtomicBool}, + sync::{ + Arc, Mutex, OnceLock, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, time::Duration, }; use tinybus::{ @@ -16,26 +19,33 @@ use tinybus::{ }; use tinysecurity_bus::{ AccessTier, ApprovalChoice2, ApprovalConfig2, ApprovalLifecycle2, ApprovalRow2, ApprovalScope2, - AuditConfig2, AuditCorrelation2, AuditSinkRequest2, DecideRequest2, - EvaluateSecretCipherRequest2, EvaluateSecretRequest2, ExecuteSecretCipherRequest2, - ExecuteSecretRequest2, MasterPurpose2, MasterSlotKind2, ModuleConfig, Origin, PolicyInfo, - RedactorConfig, SecretBackendLoadRequest2, SecretBackendLoadResponse2, - SecretBackendLoadStatus2, SecretBackendSetRequest2, SecretBackendSetResponse2, - SecretBackendSetStatus2, SecretBinding2, SecretCipherConfig2, SecretCipherDeliveryIdentity2, - SecretCipherDeliveryRequest2, SecretCipherDeliveryResponse2, SecretCipherKind2, - SecretCipherPending2, SecretCipherRequest2, SecretCipherResponse2, SecretConfig2, - SecretLimits2, SecretOperation2, SecretPhase2, SecretRegistration2, SecretRequest2, - SecretResponse2, SubjectScope, callback_names2, names, + AuditConfig2, AuditCorrelation2, AuditSinkRequest2, DecideRequest2, DeviceConfig2, + DevicePairingConfig2, DevicePairingContextRequest2, DevicePairingContextResponse2, + DevicePairingLegacyFormat2, DevicePairingLegacyLoadRequest2, DevicePairingLegacyLoadResponse2, + DevicePairingLegacyRecord2, DevicePairingPersistenceOperation2, + DevicePairingPersistenceRequest2, DevicePairingPersistenceResponse2, DevicePairingPurpose2, + DevicePairingScope2, DevicePairingSource2, DeviceScope2, EvaluateSecretCipherRequest2, + EvaluateSecretRequest2, ExecuteSecretCipherRequest2, ExecuteSecretRequest2, MasterPurpose2, + MasterSlotKind2, ModuleConfig, Origin, PolicyInfo, RedactorConfig, SecretBackendLoadRequest2, + SecretBackendLoadResponse2, SecretBackendLoadStatus2, SecretBackendSetRequest2, + SecretBackendSetResponse2, SecretBackendSetStatus2, SecretBinding2, SecretCipherConfig2, + SecretCipherDeliveryIdentity2, SecretCipherDeliveryRequest2, SecretCipherDeliveryResponse2, + SecretCipherKind2, SecretCipherPending2, SecretCipherRequest2, SecretCipherResponse2, + SecretConfig2, SecretLimits2, SecretOperation2, SecretPhase2, SecretRegistration2, + SecretRequest2, SecretResponse2, SubjectScope, callback_names2, names, }; use zeroize::Zeroizing; fn denied() -> tinybus::Error { tinybus::Error::failed("native fixture denied") } fn audit(sequence: u64) -> AuditCorrelation2 { + audit_at(sequence, 1) +} +fn audit_at(sequence: u64, generation: u64) -> AuditCorrelation2 { AuditCorrelation2 { event_id: format!("{sequence:032x}"), expected_tail: sequence - 1, - generation: 1, + generation, } } fn registration() -> ApprovalScope2 { @@ -66,6 +76,17 @@ struct State { target: Mutex>>>, slots: Mutex>, prompts: Mutex, + master_alias: Mutex>, + pairing_record: Mutex>>, + pairing_revision: Mutex, + legacy_device_records: Mutex>, + pairing_pause_load: AtomicBool, + pairing_load_entered: tokio::sync::Notify, + pairing_release_load: tokio::sync::Notify, + pairing_pause_store: AtomicBool, + pairing_store_entered: tokio::sync::Notify, + pairing_release_store: tokio::sync::Notify, + generation: AtomicU64, } static CALLBACK_STATE: OnceLock> = OnceLock::new(); static CALLBACK_READY: OnceLock = OnceLock::new(); @@ -82,6 +103,7 @@ mod admitted_callbacks { callback_names2::AUDIT_SINK, callback_names2::SECRET_BACKEND, callback_names2::SECRET_CIPHER_CONSUMER, + callback_names2::DEVICE_PAIRING_HOST, ] { connection .serve_at( @@ -121,6 +143,11 @@ impl Interface for Callback { &[callback_names2::PROMPT, callback_names2::VERIFY_HUMAN] } callback_names2::SECRET_BACKEND => &[callback_names2::LOAD, callback_names2::SET], + callback_names2::DEVICE_PAIRING_HOST => &[ + callback_names2::RESOLVE_PAIRING_EVENT, + callback_names2::PERSIST_DEVICE_PAIRING, + callback_names2::LOAD_LEGACY_DEVICE_IDENTITIES, + ], callback_names2::AUDIT_SINK => &[callback_names2::APPEND], _ => &[callback_names2::DELIVER], }; @@ -132,6 +159,7 @@ impl Interface for Callback { callback_names2::SECRET_BACKEND | callback_names2::SECRET_CIPHER_CONSUMER | callback_names2::AUDIT_SINK + | callback_names2::DEVICE_PAIRING_HOST ) } async fn call( @@ -181,11 +209,326 @@ impl Interface for Callback { callback_names2::LOAD => self.load_secret(args), callback_names2::SET => self.set_secret(args), callback_names2::DELIVER => self.stage(args).await, + callback_names2::RESOLVE_PAIRING_EVENT => self.resolve_pairing(args), + callback_names2::PERSIST_DEVICE_PAIRING => { + let operation = + serde_json::from_value::<(DevicePairingPersistenceRequest2,)>(args.clone()) + .map(|(request,)| request.operation) + .ok(); + let should_pause = match &operation { + Some(DevicePairingPersistenceOperation2::Load) => { + self.state.pairing_pause_load.swap(false, Ordering::AcqRel) + } + Some(DevicePairingPersistenceOperation2::Store { .. }) => { + self.state.pairing_pause_store.swap(false, Ordering::AcqRel) + } + None => false, + }; + if should_pause { + let (entered, release) = match &operation { + Some(DevicePairingPersistenceOperation2::Load) => ( + &self.state.pairing_load_entered, + &self.state.pairing_release_load, + ), + _ => ( + &self.state.pairing_store_entered, + &self.state.pairing_release_store, + ), + }; + entered.notify_one(); + release.notified().await; + } + self.persist_pairing(args) + } + callback_names2::LOAD_LEGACY_DEVICE_IDENTITIES => self.load_legacy_pairings(args), _ => Err(denied()), } } } impl Callback { + #[allow(clippy::too_many_lines)] // The fixture's source-to-context table is kept explicit for audit. + fn resolve_pairing(&self, args: serde_json::Value) -> tinybus::Result { + let (request,): (DevicePairingContextRequest2,) = serde_json::from_value(args)?; + let (source, alias, channel, nonce) = + match (request.purpose, request.host_event_id.as_str()) { + (DevicePairingPurpose2::UserController, "user-event") => ( + DevicePairingSource2::AuthenticatedUserController, + self.state + .master_alias + .lock() + .map_err(|_| denied())? + .clone(), + "fixture-channel-1", + "fixture-nonce-1", + ), + (DevicePairingPurpose2::UserController, "recover-legacy-a") => ( + DevicePairingSource2::AuthenticatedUserController, + self.state + .master_alias + .lock() + .map_err(|_| denied())? + .clone(), + "legacy-channel-a", + "", + ), + (DevicePairingPurpose2::UserController, "recover-legacy-b") => ( + DevicePairingSource2::AuthenticatedUserController, + self.state + .master_alias + .lock() + .map_err(|_| denied())? + .clone(), + "legacy-channel-b", + "", + ), + (DevicePairingPurpose2::LegacyImport, "legacy-import-event") => ( + DevicePairingSource2::AuthenticatedUserController, + self.state + .master_alias + .lock() + .map_err(|_| denied())? + .clone(), + "", + "", + ), + ( + DevicePairingPurpose2::LegacyImport, + "legacy-import-agent" + | "legacy-import-expired" + | "legacy-import-foreign-source" + | "legacy-import-corrupt" + | "legacy-import-revoked", + ) => ( + if request.host_event_id == "legacy-import-agent" { + DevicePairingSource2::Agent + } else { + DevicePairingSource2::AuthenticatedUserController + }, + self.state + .master_alias + .lock() + .map_err(|_| denied())? + .clone(), + "", + "", + ), + (DevicePairingPurpose2::UserController, "agent-event") => ( + DevicePairingSource2::Agent, + self.state + .master_alias + .lock() + .map_err(|_| denied())? + .clone(), + "fixture-channel-1", + "fixture-nonce-1", + ), + (DevicePairingPurpose2::UserController, "expired-user-event") => ( + DevicePairingSource2::AuthenticatedUserController, + self.state + .master_alias + .lock() + .map_err(|_| denied())? + .clone(), + "fixture-channel-1", + "fixture-nonce-1", + ), + (DevicePairingPurpose2::UserController, "foreign-scope-event") => ( + DevicePairingSource2::AuthenticatedUserController, + self.state + .master_alias + .lock() + .map_err(|_| denied())? + .clone(), + "fixture-channel-1", + "fixture-nonce-1", + ), + ( + DevicePairingPurpose2::SocketEvent, + "socket-event" | "socket-event-new" | "socket-event-confirm-race", + ) => { + if request.host_event_id == "socket-event-new" { + ( + DevicePairingSource2::RegisteredSocket, + None, + "fixture-channel-2", + "fixture-nonce-2", + ) + } else if request.host_event_id == "socket-event-confirm-race" { + ( + DevicePairingSource2::RegisteredSocket, + None, + "fixture-channel-1", + "fixture-nonce-1", + ) + } else { + ( + DevicePairingSource2::RegisteredSocket, + None, + "fixture-channel-1", + "fixture-nonce-1", + ) + } + } + (DevicePairingPurpose2::Reconnect, "reconnect-event") => ( + DevicePairingSource2::BackendReconnect, + None, + "fixture-channel-2", + "fixture-nonce-2", + ), + (DevicePairingPurpose2::Reconnect, "reconnect-legacy-a") => ( + DevicePairingSource2::BackendReconnect, + None, + "legacy-channel-a", + "legacy-renewal-nonce-a", + ), + (DevicePairingPurpose2::Reconnect, "reconnect-legacy-b") => ( + DevicePairingSource2::BackendReconnect, + None, + "legacy-channel-b", + "legacy-renewal-nonce-b", + ), + (DevicePairingPurpose2::SocketEvent, "legacy-socket-a") => ( + DevicePairingSource2::RegisteredSocket, + None, + "legacy-channel-a", + "legacy-renewal-nonce-a", + ), + (DevicePairingPurpose2::SocketEvent, "legacy-socket-b") => ( + DevicePairingSource2::RegisteredSocket, + None, + "legacy-channel-b", + "legacy-renewal-nonce-b", + ), + (DevicePairingPurpose2::SocketClosed, "socket-close-old") => ( + DevicePairingSource2::SocketClosed, + None, + "fixture-channel-1", + "fixture-nonce-1", + ), + ( + DevicePairingPurpose2::UserLogout, + "logout-event" | "logout-event-confirm-race", + ) => ( + DevicePairingSource2::AuthenticatedUserLogout, + None, + "fixture-channel-1", + "fixture-nonce-1", + ), + _ => return Err(denied()), + }; + let legacy_import_handle = if request.purpose == DevicePairingPurpose2::LegacyImport { + Some( + match request.host_event_id.as_str() { + "legacy-import-foreign-source" => "fixture-legacy-foreign-source", + "legacy-import-corrupt" => "fixture-legacy-corrupt", + _ => "fixture-legacy-import-handle", + } + .to_string(), + ) + } else { + None + }; + if request + .requested_channel_id + .as_deref() + .is_some_and(|requested| requested != channel) + { + return Err(denied()); + } + let expires_at_ms = if matches!( + request.host_event_id.as_str(), + "expired-user-event" | "legacy-import-expired" + ) { + 0 + } else { + u64::MAX + }; + let scope_id = if request.host_event_id == "foreign-scope-event" { + "9".repeat(32) + } else { + "a".repeat(32) + }; + serde_json::to_value(DevicePairingContextResponse2 { + scope_id, + user_binding: "fixture-user-binding".into(), + secret_master_handle: alias, + legacy_import_handle, + channel_id: channel.into(), + connection_nonce: nonce.into(), + generation: self.state.generation.load(Ordering::Acquire), + source, + expires_at_ms, + }) + .map_err(Into::into) + } + + fn load_legacy_pairings(&self, args: serde_json::Value) -> tinybus::Result { + let (request,): (DevicePairingLegacyLoadRequest2,) = serde_json::from_value(args)?; + if !matches!( + request.handle.as_str(), + "fixture-legacy-import-handle" + | "fixture-legacy-foreign-source" + | "fixture-legacy-corrupt" + ) { + return Err(denied()); + } + let mut records = self + .state + .legacy_device_records + .lock() + .map_err(|_| denied())? + .clone(); + if request.handle == "fixture-legacy-corrupt" { + records.first_mut().ok_or_else(denied)?.ciphertext[0] ^= 1; + } + serde_json::to_value(DevicePairingLegacyLoadResponse2 { + scope_id: "a".repeat(32), + user_binding: "fixture-user-binding".into(), + generation: self.state.generation.load(Ordering::Acquire), + backend_id: if request.handle == "fixture-legacy-foreign-source" { + "9".repeat(32) + } else { + "e".repeat(32) + }, + source_registration_id: "b".repeat(32), + records, + }) + .map_err(Into::into) + } + + fn persist_pairing(&self, args: serde_json::Value) -> tinybus::Result { + let (request,): (DevicePairingPersistenceRequest2,) = serde_json::from_value(args)?; + if request.backend_id != "c".repeat(32) + || request.scope_id != "a".repeat(32) + || request.secret_registration_id != "b".repeat(32) + { + return Err(denied()); + } + let mut revision = self.state.pairing_revision.lock().map_err(|_| denied())?; + let mut record = self.state.pairing_record.lock().map_err(|_| denied())?; + let ciphertext = match request.operation { + DevicePairingPersistenceOperation2::Load => record.clone(), + DevicePairingPersistenceOperation2::Store { + expected_revision, + ciphertext, + } => { + if expected_revision != revision.to_string() || ciphertext.is_empty() { + return Err(denied()); + } + *record = Some(ciphertext); + *revision += 1; + record.clone() + } + }; + serde_json::to_value(DevicePairingPersistenceResponse2 { + backend_id: request.backend_id, + scope_id: request.scope_id, + secret_registration_id: request.secret_registration_id, + revision: revision.to_string(), + ciphertext, + }) + .map_err(Into::into) + } fn append(&self, args: serde_json::Value) -> tinybus::Result { let (request,): (AuditSinkRequest2,) = serde_json::from_value(args)?; let phase: serde_json::Value = serde_json::from_str(&request.events[0].arguments_json)?; @@ -199,7 +542,9 @@ impl Callback { } fn load_secret(&self, args: serde_json::Value) -> tinybus::Result { let (request,): (SecretBackendLoadRequest2,) = serde_json::from_value(args)?; - if request.identity.registration_id != "b".repeat(32) || request.identity.generation != 1 { + if request.identity.registration_id != "b".repeat(32) + || request.identity.generation != self.state.generation.load(Ordering::Acquire) + { return Err(denied()); } let mut identity = request.identity; @@ -268,7 +613,7 @@ impl Callback { if now >= identity.expires_at_ms || identity.consumer_id != "f".repeat(32) || identity.registration_id != "b".repeat(32) - || identity.generation != 1 + || identity.generation != self.state.generation.load(Ordering::Acquire) || identity.slot >= 2 || plain.len() > 2_097_213 || replacement.as_ref().is_some_and(|p| p.len() > 2_097_213) @@ -328,16 +673,33 @@ fn make_state( ), slots: Mutex::new(BTreeMap::new()), prompts: Mutex::new(0), + master_alias: Mutex::new(None), + pairing_record: Mutex::new(None), + pairing_revision: Mutex::new(0), + legacy_device_records: Mutex::new(Vec::new()), + pairing_pause_load: AtomicBool::new(false), + pairing_load_entered: tokio::sync::Notify::new(), + pairing_release_load: tokio::sync::Notify::new(), + pairing_pause_store: AtomicBool::new(false), + pairing_store_entered: tokio::sync::Notify::new(), + pairing_release_store: tokio::sync::Notify::new(), + generation: AtomicU64::new(1), })) } +#[allow(clippy::too_many_lines)] // Mode-specific config makes the native compatibility matrix explicit. fn configuration( host: &Connection, mode: u32, ) -> std::result::Result> { + let mut approval_registration = registration(); + if mode == 18 { + approval_registration.require_approval = true; + approval_registration.auto_approve_all = false; + } let mut config = ModuleConfig { trusted_host: host.unique_name().map(|n| n.to_string()), approvals: Some(ApprovalConfig2 { - scopes: vec![registration()], + scopes: vec![approval_registration], timeout_ms: 10000, }), audit: Some(AuditConfig2 { @@ -379,6 +741,35 @@ fn configuration( slots: 2, timeout_ms: 1000, }), + device: (mode == 18).then(|| DeviceConfig2 { + scopes: vec![DeviceScope2 { + scope_id: "a".repeat(32), + origin: Origin::Operator, + static_secret: Some([7; 32]), + }], + max_handles: 32, + max_in_flight: 4, + operations_per_minute: 4096, + frames_per_minute: 12_000, + max_ttl_ms: 300_000, + max_message_bytes: 1024, + max_bootstrap_nonces: 64, + }), + device_pairing: (mode == 18).then(|| DevicePairingConfig2 { + scopes: vec![DevicePairingScope2 { + scope_id: "a".repeat(32), + secret_registration_id: "b".repeat(32), + persistence_backend_id: "c".repeat(32), + legacy_device_backend_id: Some("e".repeat(32)), + legacy_source_registration_id: Some("b".repeat(32)), + }], + max_pairings: 32, + max_pending: 8, + max_pending_ttl_ms: 300_000, + max_session_ttl_ms: 604_800_000, + max_state_bytes: 1_048_576, + callback_timeout_ms: 10_000, + }), ..Default::default() }; if mode == 17 { @@ -409,10 +800,16 @@ fn configuration( } Ok(config) } -async fn acquire_master(proxy: &tinybus::Proxy, state: &State) -> tinybus::Result { +async fn acquire_master( + proxy: &tinybus::Proxy, + state: &State, + generation: u64, + call_id: &str, + audit_sequence: &mut u64, +) -> tinybus::Result { let request = SecretRequest2 { registration_id: "b".repeat(32), - call_id: "native-master".into(), + call_id: call_id.into(), ttl_ms: 30000, operation: if state.mode == 17 { SecretOperation2::ResolveAppMaster @@ -425,7 +822,7 @@ async fn acquire_master(proxy: &tinybus::Proxy, state: &State) -> tinybus::Resul names::methods::EVALUATE_SECRET2, (EvaluateSecretRequest2 { request: request.clone(), - audit: audit(1), + audit: audit_at(*audit_sequence, generation), },), ) .await?; @@ -442,20 +839,22 @@ async fn acquire_master(proxy: &tinybus::Proxy, state: &State) -> tinybus::Resul scope_id: "a".repeat(32), request_id: row.request_id.clone(), decision_ticket: ticket, - audit: audit(2), + audit: audit_at(*audit_sequence + 1, generation), },), ) .await?; - proxy + let response = proxy .call_confidential::( names::methods::EXECUTE_SECRET2, (ExecuteSecretRequest2 { request, request_id: row.request_id, - audit: audit(3), + audit: audit_at(*audit_sequence + 2, generation), },), ) - .await + .await?; + *audit_sequence += 3; + Ok(response) } async fn exercise_cipher( proxy: &tinybus::Proxy, @@ -537,6 +936,1042 @@ async fn exercise_cipher( exercise_migration(proxy, state, handle, &mut sequence).await?; Ok(sequence) } + +#[allow(clippy::too_many_lines)] // One real bus flow proves the composed lifecycle and protocol. +async fn device_pairing_roundtrip( + lifecycle: &ModuleHost, + module_name: &str, + configuration: &ModuleConfig, + proxy: &tinybus::Proxy, + state: &State, + master_alias: &str, +) -> std::result::Result<(), Box> { + use tinysecurity::crypto::device::{KeyPair, PublicKey, Role, Tunnel}; + use tinysecurity_bus::{ + DeviceFrameOperation2, DeviceFrameRequest2, DeviceFrameResponse2, DevicePairingOperation2, + DevicePairingRequest2, DevicePairingResponse2, + }; + let call = |call_id: &str, operation| DevicePairingRequest2 { + call_id: call_id.into(), + operation, + }; + for (call_id, event_id) in [ + ("pair-unlock-agent-denied", "agent-event"), + ("pair-unlock-expired-denied", "expired-user-event"), + ("pair-unlock-foreign-scope-denied", "foreign-scope-event"), + ("pair-unlock-unknown-event-denied", "foreign-user-event"), + ] { + assert!( + proxy + .call_confidential::( + names::methods::DEVICE_PAIRING2, + (call( + call_id, + DevicePairingOperation2::Unlock { + host_event_id: event_id.into(), + }, + ),), + ) + .await + .is_err(), + "untrusted, expired, and unknown controller events must not unlock" + ); + } + // Pause the first encrypted-state load while logout removes the not-yet-published + // owner. The unlock must fail after the callback resumes instead of resurrecting + // a loaded identity after logout has returned. + state.pairing_pause_load.store(true, Ordering::Release); + let unlock_proxy = proxy.clone(); + let unlock_call = call( + "pair-unlock-racing-logout", + DevicePairingOperation2::Unlock { + host_event_id: "user-event".into(), + }, + ); + let racing_unlock = tokio::spawn(async move { + unlock_proxy + .call_confidential::( + names::methods::DEVICE_PAIRING2, + (unlock_call,), + ) + .await + }); + state.pairing_load_entered.notified().await; + proxy + .call_confidential::( + names::methods::DEVICE_PAIRING2, + (call( + "pair-lock-before-first-load-publishes", + DevicePairingOperation2::Lock { + host_event_id: "logout-event".into(), + }, + ),), + ) + .await?; + state.pairing_release_load.notify_one(); + assert!( + racing_unlock.await?.is_err(), + "logout fences an in-flight unlock" + ); + + let unlock: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + "pair-unlock", + DevicePairingOperation2::Unlock { + host_event_id: "user-event".into(), + }, + ),), + ) + .await?; + let initial_public = unlock.core_public_key.ok_or("pairing public identity")?; + let legacy_secrets = [[0x31; 32], [0x32; 32]]; + let legacy_devices = [KeyPair::generate()?, KeyPair::generate()?]; + let mut cipher_sequence = 4; + let mut legacy_records = Vec::new(); + for (index, (secret, device)) in legacy_secrets.iter().zip(&legacy_devices).enumerate() { + let encoded = + base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, secret); + let response = execute( + proxy, + cipher_request( + master_alias, + 200 + index as u64, + SecretCipherKind2::Encrypt, + encoded.into_bytes(), + ), + &mut cipher_sequence, + ) + .await?; + let SecretCipherResponse2::Ciphertext { ciphertext } = response else { + return Err("legacy key ciphertext".into()); + }; + legacy_records.push(DevicePairingLegacyRecord2 { + channel_id: format!("legacy-channel-{}", if index == 0 { "a" } else { "b" }), + device_public_key: device.public_key().encode(), + format: DevicePairingLegacyFormat2::Enc2, + ciphertext: ciphertext.into_bytes(), + revoked: false, + }); + } + *state.legacy_device_records.lock().map_err(|_| denied())? = legacy_records; + state + .legacy_device_records + .lock() + .map_err(|_| denied())? + .first_mut() + .ok_or("legacy fixture record")? + .revoked = true; + assert!( + proxy + .call_confidential::( + names::methods::DEVICE_PAIRING2, + (call( + "pair-legacy-revoked-row-denied", + DevicePairingOperation2::ImportLegacy { + host_event_id: "legacy-import-revoked".into(), + }, + ),), + ) + .await + .is_err(), + "revoked legacy metadata is not imported" + ); + state + .legacy_device_records + .lock() + .map_err(|_| denied())? + .first_mut() + .ok_or("legacy fixture record")? + .revoked = false; + for (index, event) in [ + "legacy-import-agent", + "legacy-import-expired", + "legacy-import-foreign-source", + "legacy-import-corrupt", + ] + .into_iter() + .enumerate() + { + assert!( + proxy + .call_confidential::( + names::methods::DEVICE_PAIRING2, + (call( + &format!("pair-legacy-denied-{index}"), + DevicePairingOperation2::ImportLegacy { + host_event_id: event.into(), + }, + ),), + ) + .await + .is_err(), + "untrusted, expired, foreign-source and corrupted legacy imports fail closed" + ); + } + let _: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + "pair-import-legacy-identities", + DevicePairingOperation2::ImportLegacy { + host_event_id: "legacy-import-event".into(), + }, + ),), + ) + .await?; + assert!( + proxy + .call_confidential::( + names::methods::DEVICE_PAIRING2, + (call( + "pair-recover-foreign-channel-denied", + DevicePairingOperation2::Recover { + channel_id: "legacy-channel-a".into(), + host_event_id: "recover-legacy-b".into(), + }, + ),), + ) + .await + .is_err(), + "a host event authorizing another channel cannot recover this pairing" + ); + let mut legacy_pairing_ids = Vec::new(); + for (index, secret) in legacy_secrets.iter().enumerate() { + let channel = format!("legacy-channel-{}", if index == 0 { "a" } else { "b" }); + let event = format!("recover-legacy-{}", if index == 0 { "a" } else { "b" }); + let recovered: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + &format!("pair-recover-legacy-{index}"), + DevicePairingOperation2::Recover { + channel_id: channel, + host_event_id: event, + }, + ),), + ) + .await?; + let expected = KeyPair::from_secret(Zeroizing::new(*secret))? + .public_key() + .encode(); + assert_eq!( + recovered.core_public_key.as_deref(), + Some(expected.as_str()) + ); + let expected_device = legacy_devices[index].public_key().encode(); + assert_eq!( + recovered.device_public_key.as_deref(), + Some(expected_device.as_str()) + ); + legacy_pairing_ids.push(recovered.pairing_id.ok_or("legacy pairing id")?); + } + for (index, pairing_id) in legacy_pairing_ids.iter().enumerate() { + let suffix = if index == 0 { "a" } else { "b" }; + let expected_core = KeyPair::from_secret(Zeroizing::new(legacy_secrets[index]))? + .public_key() + .encode(); + let renewed: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + &format!("pair-renew-legacy-{suffix}"), + DevicePairingOperation2::Renew { + pairing_id: pairing_id.clone(), + host_event_id: format!("reconnect-legacy-{suffix}"), + }, + ),), + ) + .await?; + assert_eq!( + renewed.core_public_key.as_deref(), + Some(expected_core.as_str()) + ); + let receipt = renewed.receipt.ok_or("legacy renewal receipt")?; + let core = PublicKey::decode(&expected_core)?; + let bootstrap = KeyPair::generate()?; + let session_ephemeral = KeyPair::generate()?; + let device = &legacy_devices[index]; + let payload = serde_json::to_vec(&serde_json::json!({ + "device_pubkey": device.public_key().encode(), + "client_ephemeral_pubkey": session_ephemeral.public_key().encode(), + }))?; + let frame = bootstrap.seal_handshake(core, &payload)?; + let socket_event = format!("legacy-socket-{suffix}"); + let ack: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + &format!("pair-renew-handshake-{suffix}"), + DevicePairingOperation2::Handshake { + receipt: receipt.clone(), + host_event_id: socket_event.clone(), + frame, + }, + ),), + ) + .await?; + let ack_payload: serde_json::Value = + serde_json::from_slice(&session_ephemeral.open_ack(core, &ack.frame)?)?; + let server_ephemeral = PublicKey::decode( + ack_payload["server_ephemeral_pubkey"] + .as_str() + .ok_or("legacy renewal server ephemeral")?, + )?; + let keys = device.session_keys( + core, + &session_ephemeral, + server_ephemeral, + session_ephemeral.public_key(), + server_ephemeral, + )?; + let client_tunnel = Tunnel::new(Role::Client, keys); + let proof = client_tunnel.seal(b"legacy same-key renewal proof")?; + let confirmed: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + &format!("pair-renew-confirm-{suffix}"), + DevicePairingOperation2::Confirm { + receipt, + host_event_id: socket_event, + frame: proof, + }, + ),), + ) + .await?; + assert_eq!(confirmed.pairing_id.as_deref(), Some(pairing_id.as_str())); + assert_eq!( + confirmed.device_public_key.as_deref(), + Some(device.public_key().encode().as_str()) + ); + } + let _: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + "pair-revoke-legacy-import", + DevicePairingOperation2::Revoke { + pairing_id: legacy_pairing_ids[0].clone(), + host_event_id: "user-event".into(), + }, + ),), + ) + .await?; + let _: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + "pair-lock-before-stale-import", + DevicePairingOperation2::Lock { + host_event_id: "logout-event".into(), + }, + ),), + ) + .await?; + let _: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + "pair-unlock-before-stale-import", + DevicePairingOperation2::Unlock { + host_event_id: "user-event".into(), + }, + ),), + ) + .await?; + assert!( + proxy + .call_confidential::( + names::methods::DEVICE_PAIRING2, + (call( + "pair-deny-revoked-legacy-reimport", + DevicePairingOperation2::ImportLegacy { + host_event_id: "legacy-import-event".into(), + }, + ),), + ) + .await + .is_err(), + "module tombstones prevent stale legacy metadata from reviving a revoked channel" + ); + let reloaded_legacy: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + "pair-recover-legacy-after-reload", + DevicePairingOperation2::Recover { + channel_id: "legacy-channel-b".into(), + host_event_id: "recover-legacy-b".into(), + }, + ),), + ) + .await?; + let expected_legacy = KeyPair::from_secret(Zeroizing::new(legacy_secrets[1]))? + .public_key() + .encode(); + assert_eq!( + reloaded_legacy.core_public_key.as_deref(), + Some(expected_legacy.as_str()), + "the second channel retains its distinct core identity after module reload" + ); + let begin: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + "pair-begin", + DevicePairingOperation2::Begin { + host_event_id: "user-event".into(), + }, + ),), + ) + .await?; + assert_eq!( + begin.core_public_key.as_deref(), + Some(initial_public.as_str()) + ); + let receipt = begin.receipt.ok_or("pairing receipt")?; + let core = PublicKey::decode(&initial_public)?; + let device_static = KeyPair::generate()?; + let bootstrap = KeyPair::generate()?; + let session_ephemeral = KeyPair::generate()?; + let handshake_payload = serde_json::to_vec(&serde_json::json!({ + "device_pubkey": device_static.public_key().encode(), + "client_ephemeral_pubkey": session_ephemeral.public_key().encode(), + }))?; + let handshake = bootstrap.seal_handshake(core, &handshake_payload)?; + let response: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + "pair-handshake", + DevicePairingOperation2::Handshake { + receipt: receipt.clone(), + host_event_id: "socket-event".into(), + frame: handshake, + }, + ),), + ) + .await?; + let ack: serde_json::Value = + serde_json::from_slice(&session_ephemeral.open_ack(core, &response.frame)?)?; + let server_ephemeral = PublicKey::decode( + ack["server_ephemeral_pubkey"] + .as_str() + .ok_or("ack server ephemeral")?, + )?; + let keys = device_static.session_keys( + core, + &session_ephemeral, + server_ephemeral, + session_ephemeral.public_key(), + server_ephemeral, + )?; + let mut device_tunnel = Tunnel::new(Role::Client, keys); + let proof = device_tunnel.seal(b"static-device-possession")?; + let confirmed: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + "pair-confirm", + DevicePairingOperation2::Confirm { + receipt: receipt.clone(), + host_event_id: "socket-event".into(), + frame: proof.clone(), + }, + ),), + ) + .await?; + assert!( + proxy + .call_confidential::( + names::methods::DEVICE_PAIRING2, + (call( + "pair-confirm-replay", + DevicePairingOperation2::Confirm { + receipt, + host_event_id: "socket-event".into(), + frame: proof, + }, + ),), + ) + .await + .is_err(), + "a confirmed receipt must not be reusable" + ); + let pairing_id = confirmed.pairing_id.ok_or("confirmed pairing id")?; + let session_id = confirmed.session_id.ok_or("confirmed session handle")?; + let sealed: DeviceFrameResponse2 = proxy + .call_confidential( + names::methods::DEVICE_FRAME2, + (DeviceFrameRequest2 { + scope_id: "a".repeat(32), + handle: session_id.clone(), + call_id: "pair-frame-seal".into(), + operation: DeviceFrameOperation2::Seal { + plaintext: b"paired-frame-secret".to_vec(), + }, + },), + ) + .await?; + assert_eq!(&*device_tunnel.open(&sealed.bytes)?, b"paired-frame-secret"); + let incoming = device_tunnel.seal(b"reply")?; + let opened: DeviceFrameResponse2 = proxy + .call_confidential( + names::methods::DEVICE_FRAME2, + (DeviceFrameRequest2 { + scope_id: "a".repeat(32), + handle: session_id.clone(), + call_id: "pair-frame-open".into(), + operation: DeviceFrameOperation2::Open { + frame: incoming.clone(), + }, + },), + ) + .await?; + assert_eq!(opened.bytes, b"reply"); + assert!( + proxy + .call_confidential::( + names::methods::DEVICE_FRAME2, + (DeviceFrameRequest2 { + scope_id: "a".repeat(32), + handle: session_id.clone(), + call_id: "pair-frame-replay".into(), + operation: DeviceFrameOperation2::Open { frame: incoming } + },) + ) + .await + .is_err() + ); + + let refreshed: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + "pair-refresh", + DevicePairingOperation2::RefreshSession { + session_id: session_id.clone(), + host_event_id: "socket-event".into(), + }, + ),), + ) + .await?; + assert_eq!(refreshed.session_id.as_deref(), Some(session_id.as_str())); + + let renewed: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + "pair-renew", + DevicePairingOperation2::Renew { + pairing_id: pairing_id.clone(), + host_event_id: "reconnect-event".into(), + }, + ),), + ) + .await?; + let renewal_receipt = renewed.receipt.ok_or("renewal receipt")?; + let impostor = KeyPair::generate()?; + let wrong_ephemeral = KeyPair::generate()?; + let wrong_session = KeyPair::generate()?; + let wrong_payload = serde_json::to_vec(&serde_json::json!({ + "device_pubkey": impostor.public_key().encode(), + "client_ephemeral_pubkey": wrong_session.public_key().encode(), + }))?; + let wrong_handshake = wrong_ephemeral.seal_handshake(core, &wrong_payload)?; + assert!( + proxy + .call_confidential::( + names::methods::DEVICE_PAIRING2, + (call( + "pair-renew-wrong-key", + DevicePairingOperation2::Handshake { + receipt: renewal_receipt.clone(), + host_event_id: "socket-event-new".into(), + frame: wrong_handshake, + }, + ),), + ) + .await + .is_err(), + "renewal must reject a different static device key" + ); + let new_bootstrap = KeyPair::generate()?; + let new_session_ephemeral = KeyPair::generate()?; + let new_payload = serde_json::to_vec(&serde_json::json!({ + "device_pubkey": device_static.public_key().encode(), + "client_ephemeral_pubkey": new_session_ephemeral.public_key().encode(), + }))?; + let new_handshake = new_bootstrap.seal_handshake(core, &new_payload)?; + let new_ack: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + "pair-renew-handshake", + DevicePairingOperation2::Handshake { + receipt: renewal_receipt.clone(), + host_event_id: "socket-event-new".into(), + frame: new_handshake, + }, + ),), + ) + .await?; + let ack: serde_json::Value = + serde_json::from_slice(&new_session_ephemeral.open_ack(core, &new_ack.frame)?)?; + let server_ephemeral = PublicKey::decode( + ack["server_ephemeral_pubkey"] + .as_str() + .ok_or("renew ack ephemeral")?, + )?; + let keys = device_static.session_keys( + core, + &new_session_ephemeral, + server_ephemeral, + new_session_ephemeral.public_key(), + server_ephemeral, + )?; + let mut renewed_tunnel = Tunnel::new(Role::Client, keys); + let renewed_proof = renewed_tunnel.seal(b"same-static-key-renewal")?; + let result: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + "pair-renew-confirm", + DevicePairingOperation2::Confirm { + receipt: renewal_receipt, + host_event_id: "socket-event-new".into(), + frame: renewed_proof, + }, + ),), + ) + .await?; + let new_session = result.session_id.ok_or("renewed session")?; + assert_eq!(result.pairing_id.as_deref(), Some(pairing_id.as_str())); + assert!( + proxy + .call_confidential::( + names::methods::DEVICE_FRAME2, + (DeviceFrameRequest2 { + scope_id: "a".repeat(32), + handle: session_id.clone(), + call_id: "pair-old-session-retired".into(), + operation: DeviceFrameOperation2::Seal { plaintext: vec![1] } + },) + ) + .await + .is_err() + ); + assert!( + proxy + .call_confidential::( + names::methods::DEVICE_PAIRING2, + (call( + "pair-stale-close", + DevicePairingOperation2::EndSession { + session_id: session_id.clone(), + host_event_id: "socket-close-old".into() + } + ),) + ) + .await + .is_err() + ); + let still_live: DeviceFrameResponse2 = proxy + .call_confidential( + names::methods::DEVICE_FRAME2, + (DeviceFrameRequest2 { + scope_id: "a".repeat(32), + handle: new_session.clone(), + call_id: "pair-new-session-live".into(), + operation: DeviceFrameOperation2::Seal { + plaintext: b"still-live".to_vec(), + }, + },), + ) + .await?; + assert_eq!(&*renewed_tunnel.open(&still_live.bytes)?, b"still-live"); + + let mut reinit = configuration.clone(); + for scope in &mut reinit + .device + .as_mut() + .ok_or("pairing device config")? + .scopes + { + scope.static_secret = None; + } + lifecycle + .reinitialize(module_name, serde_json::to_value(&reinit)?) + .await?; + state.generation.store(2, Ordering::Release); + let info: PolicyInfo = proxy.call(names::methods::POLICY_INFO, ()).await?; + assert_eq!(info.generation, 2); + assert!( + proxy + .call_confidential::( + names::methods::DEVICE_FRAME2, + (DeviceFrameRequest2 { + scope_id: "a".repeat(32), + handle: new_session.clone(), + call_id: "pair-frame-after-reinit".into(), + operation: DeviceFrameOperation2::Seal { plaintext: vec![1] }, + },), + ) + .await + .is_err() + ); + let resolved = acquire_master( + proxy, + state, + 2, + "native-master-after-reinit", + &mut cipher_sequence, + ) + .await?; + let SecretResponse2::MasterHandle { handle, .. } = resolved; + *state.master_alias.lock().map_err(|_| denied())? = Some(handle); + let unlocked: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + "pair-unlock-after-reinit", + DevicePairingOperation2::Unlock { + host_event_id: "user-event".into(), + }, + ),), + ) + .await?; + assert_eq!( + unlocked.core_public_key.as_deref(), + Some(initial_public.as_str()) + ); + let recovered_after_reinit: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + "pair-recover-channel-after-module-reinit", + DevicePairingOperation2::Recover { + channel_id: "legacy-channel-b".into(), + host_event_id: "recover-legacy-b".into(), + }, + ),), + ) + .await?; + let expected_legacy_after_reinit = KeyPair::from_secret(Zeroizing::new([0x32; 32]))? + .public_key() + .encode(); + assert_eq!( + recovered_after_reinit.core_public_key.as_deref(), + Some(expected_legacy_after_reinit.as_str()), + "legacy channel identity survives module generation reinitialization" + ); + let before = *state.prompts.lock().map_err(|_| denied())?; + assert_eq!(before, 2, "only the explicit Secret2 unlocks prompt"); + let _: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + "pair-revoke", + DevicePairingOperation2::Revoke { + pairing_id: pairing_id.clone(), + host_event_id: "user-event".into(), + }, + ),), + ) + .await?; + assert!( + proxy + .call_confidential::( + names::methods::DEVICE_FRAME2, + (DeviceFrameRequest2 { + scope_id: "a".repeat(32), + handle: new_session, + call_id: "pair-frame-after-revoke".into(), + operation: DeviceFrameOperation2::Seal { plaintext: vec![1] } + },) + ) + .await + .is_err() + ); + let retry_begin: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + "pair-begin-confirm-logout-race", + DevicePairingOperation2::Begin { + host_event_id: "user-event".into(), + }, + ),), + ) + .await?; + let retry_receipt = retry_begin.receipt.ok_or("retry pairing receipt")?; + let retry_core = PublicKey::decode( + retry_begin + .core_public_key + .as_deref() + .ok_or("retry core public key")?, + )?; + let retry_session = KeyPair::generate()?; + let retry_bootstrap = KeyPair::generate()?; + let retry_payload = serde_json::to_vec(&serde_json::json!({ + "device_pubkey": device_static.public_key().encode(), + "client_ephemeral_pubkey": retry_session.public_key().encode(), + }))?; + let retry_handshake: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + "pair-handshake-confirm-logout-race", + DevicePairingOperation2::Handshake { + receipt: retry_receipt.clone(), + host_event_id: "socket-event-confirm-race".into(), + frame: retry_bootstrap.seal_handshake(retry_core, &retry_payload)?, + }, + ),), + ) + .await?; + let retry_ack: serde_json::Value = + serde_json::from_slice(&retry_session.open_ack(retry_core, &retry_handshake.frame)?)?; + let retry_server_ephemeral = PublicKey::decode( + retry_ack["server_ephemeral_pubkey"] + .as_str() + .ok_or("retry server ephemeral")?, + )?; + let retry_keys = device_static.session_keys( + retry_core, + &retry_session, + retry_server_ephemeral, + retry_session.public_key(), + retry_server_ephemeral, + )?; + let retry_tunnel = Tunnel::new(Role::Client, retry_keys); + let retry_proof = retry_tunnel.seal(b"logout wins confirmation publication")?; + state.pairing_pause_store.store(true, Ordering::Release); + let confirm_proxy = proxy.clone(); + let confirm_receipt = retry_receipt.clone(); + let racing_confirm = tokio::spawn(async move { + confirm_proxy + .call_confidential::( + names::methods::DEVICE_PAIRING2, + (call( + "pair-confirm-racing-logout", + DevicePairingOperation2::Confirm { + receipt: confirm_receipt, + host_event_id: "socket-event-confirm-race".into(), + frame: retry_proof, + }, + ),), + ) + .await + }); + state.pairing_store_entered.notified().await; + let logout_proxy = proxy.clone(); + let mut racing_logout = tokio::spawn(async move { + logout_proxy + .call_confidential::( + names::methods::DEVICE_PAIRING2, + (call( + "pair-logout-racing-confirm", + DevicePairingOperation2::Lock { + host_event_id: "logout-event".into(), + }, + ),), + ) + .await + }); + let second_logout_proxy = proxy.clone(); + let mut second_logout = tokio::spawn(async move { + second_logout_proxy + .call_confidential::( + names::methods::DEVICE_PAIRING2, + (call( + "pair-logout-confirm-race-fence", + DevicePairingOperation2::Lock { + host_event_id: "logout-event-confirm-race".into(), + }, + ),), + ) + .await + }); + let logout_a_finished = tokio::select! { + result = &mut racing_logout => { result??; true }, + result = &mut second_logout => { result??; false }, + }; + state.pairing_release_store.notify_one(); + assert!( + racing_confirm.await?.is_err(), + "logout fences confirmation publication after persistence" + ); + if logout_a_finished { + second_logout.await??; + } else { + racing_logout.await??; + } + assert!( + state + .pairing_record + .lock() + .map_err(|_| denied())? + .as_ref() + .is_some_and(|ciphertext| !String::from_utf8_lossy(ciphertext).contains("device_key")) + ); + let _: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + "pair-lock-for-corrupt-state-check", + DevicePairingOperation2::Lock { + host_event_id: "logout-event".into(), + }, + ),), + ) + .await?; + let _: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + "pair-unlock-before-recovering-lost-confirm", + DevicePairingOperation2::Unlock { + host_event_id: "user-event".into(), + }, + ),), + ) + .await?; + let orphan = proxy + .call_confidential::( + names::methods::DEVICE_PAIRING2, + (call( + "pair-recover-lost-confirm-response", + DevicePairingOperation2::Recover { + channel_id: "fixture-channel-1".into(), + host_event_id: "user-event".into(), + }, + ),), + ) + .await?; + assert!(orphan.pairing_id.is_some()); + assert!(orphan.device_public_key.is_some()); + let _: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + "pair-revoke-recovered-lost-confirm", + DevicePairingOperation2::Revoke { + pairing_id: orphan.pairing_id.ok_or("recovered pairing id")?, + host_event_id: "user-event".into(), + }, + ),), + ) + .await?; + let _: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + "pair-lock-after-recovered-lost-confirm", + DevicePairingOperation2::Lock { + host_event_id: "logout-event".into(), + }, + ),), + ) + .await?; + let persisted_pairing_state = state + .pairing_record + .lock() + .map_err(|_| denied())? + .clone() + .ok_or("encrypted pairing record")?; + state + .pairing_record + .lock() + .map_err(|_| denied())? + .as_mut() + .ok_or("encrypted pairing record")?[0] ^= 1; + assert!( + proxy + .call_confidential::( + names::methods::DEVICE_PAIRING2, + (call( + "pair-unlock-corrupt-state-denied", + DevicePairingOperation2::Unlock { + host_event_id: "user-event".into(), + }, + ),), + ) + .await + .is_err(), + "corrupted ciphertext must never load an identity" + ); + assert_eq!(*state.prompts.lock().map_err(|_| denied())?, before); + + // Reinitialize after the trusted persistence callback has loaded the + // ciphertext but before the loaded scope can be published. + *state.pairing_record.lock().map_err(|_| denied())? = Some(persisted_pairing_state); + state.pairing_pause_load.store(true, Ordering::Release); + let unlock_proxy = proxy.clone(); + let unlock_call = call( + "pair-unlock-racing-reinitialize", + DevicePairingOperation2::Unlock { + host_event_id: "user-event".into(), + }, + ); + let racing_unlock = tokio::spawn(async move { + unlock_proxy + .call_confidential::( + names::methods::DEVICE_PAIRING2, + (unlock_call,), + ) + .await + }); + state.pairing_load_entered.notified().await; + let mut reinit = configuration.clone(); + for scope in &mut reinit + .device + .as_mut() + .ok_or("pairing device config")? + .scopes + { + scope.static_secret = None; + } + lifecycle + .reinitialize(module_name, serde_json::to_value(&reinit)?) + .await?; + state.generation.store(3, Ordering::Release); + state.pairing_release_load.notify_one(); + assert!( + racing_unlock.await?.is_err(), + "reinitialization fences an in-flight unlock" + ); + let resolved = acquire_master( + proxy, + state, + 3, + "native-master-after-pending-load-reinit", + &mut cipher_sequence, + ) + .await?; + let SecretResponse2::MasterHandle { handle, .. } = resolved; + *state.master_alias.lock().map_err(|_| denied())? = Some(handle); + let unlocked: DevicePairingResponse2 = proxy + .call_confidential( + names::methods::DEVICE_PAIRING2, + (call( + "pair-unlock-after-pending-load-reinit", + DevicePairingOperation2::Unlock { + host_event_id: "user-event".into(), + }, + ),), + ) + .await?; + assert_eq!( + unlocked.core_public_key.as_deref(), + Some(initial_public.as_str()), + "a fresh approved alias recovers the same persisted module identity" + ); + + Ok(()) +} async fn exercise_migration( proxy: &tinybus::Proxy, state: &State, @@ -724,6 +2159,7 @@ async fn reject_invalid_config( } Ok(()) } +#[allow(clippy::too_many_lines)] // Retains the existing native contract modes beside the new mode. pub(super) async fn run( library: &Path, mode: u32, @@ -765,15 +2201,20 @@ pub(super) async fn run( *state.module.lock().map_err(|_| denied())? = Some(module.clone()); let proxy = host .proxy(module.as_str(), names::OBJECT_PATH, names::INTERFACE)? - .with_timeout(Duration::from_secs(5)); + .with_timeout(Duration::from_secs(if mode == 18 { 30 } else { 5 })); assert!(proxy.attestation().await?.is_some()); - let resolved = acquire_master(&proxy, &state).await; + if mode == 18 { + let _: PolicyInfo = proxy.call(names::methods::POLICY_INFO, ()).await?; + } + let mut audit_sequence = 1; + let resolved = acquire_master(&proxy, &state, 1, "native-master", &mut audit_sequence).await; if (3..=6).contains(&mode) { assert!(resolved.is_err()); task.abort(); return Ok(()); } let SecretResponse2::MasterHandle { handle, .. } = resolved?; + *state.master_alias.lock().map_err(|_| denied())? = Some(handle.clone()); assert_eq!(*state.prompts.lock().map_err(|_| denied())?, 1); if matches!(mode, 14 | 17) { assert!( @@ -796,6 +2237,16 @@ pub(super) async fn run( task.abort(); return Ok(()); } + if mode == 18 { + device_pairing_roundtrip(&lifecycle, &loaded.name, &config, &proxy, &state, &handle) + .await?; + lifecycle.shutdown(Duration::from_secs(5)).await; + task.abort(); + println!( + "native pairing: reinit-stable identity, approved Secret2 master, possession proof, confidential frame session, refresh, renewal, close and revoke passed" + ); + return Ok(()); + } let sequence = exercise_cipher(&proxy, &state, &handle).await?; assert_eq!(*state.prompts.lock().map_err(|_| denied())?, 1); // routine operations never prompt reject_wrong_peer(&bus, &module, &handle).await?; diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 0c394e91..9ac6a75e 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -309,6 +309,13 @@ impl PolicyService { self.device_execute(request).await } #[tinybus(confidential)] + async fn device_pairing2( + &self, + request: tinysecurity_bus::DevicePairingRequest2, + ) -> BusResult { + device_pairing::invoke(request).await + } + #[tinybus(confidential)] async fn device_frame2( &self, request: tinysecurity_bus::DeviceFrameRequest2, @@ -1730,6 +1737,7 @@ struct ConfidentialConfigs { secret: Option, cipher: Option, crypto: Option, + device_pairing: Option, } impl ConfidentialConfigs { fn take(config: &mut ModuleConfig) -> Self { @@ -1737,6 +1745,7 @@ impl ConfidentialConfigs { secret: config.secret.take(), cipher: config.secret_cipher.take(), crypto: config.crypto.take(), + device_pairing: config.device_pairing.take(), } } } @@ -1757,12 +1766,14 @@ struct ConfidentialAuthorities { secret: Option>, secret_cipher: Option>, crypto: Option>, + device_pairing: Option>, } /// Build the init-only confidential authorities that sit above the approval engine. fn configured_confidential( connection: &Connection, configs: ConfidentialConfigs, context: &ConfidentialContext<'_>, + device: Option<&Arc>, ) -> BusResult { let secret = secret::configured( configs.secret, @@ -1793,12 +1804,23 @@ fn configured_confidential( clock: Arc::clone(context.clock), }, )?; + let device_pairing = device_pairing::configured( + configs.device_pairing, + connection, + context.peer, + secret.as_ref(), + device, + Arc::clone(context.clock), + context.generation, + )?; Ok(ConfidentialAuthorities { secret, secret_cipher, crypto, + device_pairing, }) } +#[allow(clippy::too_many_lines)] // Setup validates and atomically wires the module's authorities. async fn setup(connection: Connection, mut config: ModuleConfig) -> BusResult<()> { // Validate before activation. The SDK serializes reinitializations and // preserves a fault latch; this adapter never provides retry RPCs. @@ -1841,6 +1863,7 @@ async fn setup(connection: Connection, mut config: ModuleConfig) -> BusResult<() secret, secret_cipher, crypto, + device_pairing, } = configured_confidential( &connection, confidential_configs, @@ -1853,31 +1876,34 @@ async fn setup(connection: Connection, mut config: ModuleConfig) -> BusResult<() generation: candidate.info().generation, clock: &device_clock, }, + device.as_ref(), )?; let judge = configured_automatic(judge_config, approvals.as_ref().map(|(_, e)| e))?; if let Some(active) = ACTIVE.get() { - let mut guard = active - .write() - .map_err(|_| failure(&tinysecurity_bus::Error::Faulted))?; - let generation = candidate.info().generation; - activate_authorities( - device.as_ref(), - device_limits, - secret.as_ref(), - generation, - || { - publish_policy_generation( - &connection, - &candidate, - audit.as_ref(), - approvals.as_ref().map(|(_, engine)| engine), - judge.clone(), - )?; - Ok(()) - }, - )?; - *guard = candidate; + { + let mut guard = active + .write() + .map_err(|_| failure(&tinysecurity_bus::Error::Faulted))?; + activate_authorities( + device.as_ref(), + device_limits, + secret.as_ref(), + generation, + || { + publish_policy_generation( + &connection, + &candidate, + audit.as_ref(), + approvals.as_ref().map(|(_, engine)| engine), + judge.clone(), + )?; + Ok(()) + }, + )?; + *guard = candidate; + } + device_pairing::transition(device_pairing.as_ref(), generation).await?; return Ok(()); } let policy = Arc::new(RwLock::new(candidate)); @@ -1898,7 +1924,13 @@ async fn setup(connection: Connection, mut config: ModuleConfig) -> BusResult<() engine.replace_automatic(judge).map_err(approval_failure)?; } serve_policy(&connection, service, binding.clone()).await?; - activate_confidential(device.as_ref(), crypto.as_ref(), secret, secret_cipher)?; + activate_confidential( + device.as_ref(), + crypto.as_ref(), + secret, + secret_cipher, + device_pairing, + )?; activate_policy(policy, audit, binding, sandbox, approvals) } fn activate_confidential( @@ -1906,11 +1938,13 @@ fn activate_confidential( crypto: Option<&Arc>, secret: Option>, secret_cipher: Option>, + device_pairing: Option>, ) -> BusResult<()> { device::activate(device)?; crypto::activate(crypto)?; secret::activate(secret)?; - cipher::activate(secret_cipher) + cipher::activate(secret_cipher)?; + device_pairing::activate(device_pairing) } async fn serve_policy( connection: &Connection, @@ -1961,10 +1995,10 @@ tinybus_module::module_export! { config = ModuleConfig, worker_threads = 1, provides = ["ai.tinyhumans.tinysecurity.Policy"], - methods = ["EvaluateSecretCipher2", "ExecuteSecretCipher2", "EvaluateSecret2", "ExecuteSecret2", "PasswordCrypt2", "KeyringAccess2", "PairingControl2", "EvaluateDevice2", "ExecuteDevice2", "DeviceFrame2", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "GrantFlowTool2", "RevokeFlowTool2", "ListFlowTrust2", "RegisterApprovalScope2", "ReplaceApprovalScope2", "RetireApprovalScope2", "RegisterSandboxScope2", "RetireSandboxScope2", "Audit2", "RecordAudit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "CheckPaths2", "ProtectedPath2", "ProtectedFloor2", "CredentialRoots2", "SpendAction2", "PeekBudget2", "ClassifyHostAction2", "PrivacyCheck2", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "UrlGuard", "EgressCheck", "Redact", "RedactWith", "ScanPrompt", "ScanPrompt2", "ScanToolDefinition"], + methods = ["EvaluateSecretCipher2", "ExecuteSecretCipher2", "EvaluateSecret2", "ExecuteSecret2", "PasswordCrypt2", "KeyringAccess2", "PairingControl2", "EvaluateDevice2", "ExecuteDevice2", "DeviceFrame2", "DevicePairing2", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "GrantFlowTool2", "RevokeFlowTool2", "ListFlowTrust2", "RegisterApprovalScope2", "ReplaceApprovalScope2", "RetireApprovalScope2", "RegisterSandboxScope2", "RetireSandboxScope2", "Audit2", "RecordAudit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "CheckPaths2", "ProtectedPath2", "ProtectedFloor2", "CredentialRoots2", "SpendAction2", "PeekBudget2", "ClassifyHostAction2", "PrivacyCheck2", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "UrlGuard", "EgressCheck", "Redact", "RedactWith", "ScanPrompt", "ScanPrompt2", "ScanToolDefinition"], signals = ["PolicyChanged"], requires = [], - optional = ["ai.tinyhumans.tinysecurity.KeyringConsent2", "ai.tinyhumans.tinysecurity.SecretCipherConsumer2", "ai.tinyhumans.tinysecurity.SecretBackend2", "ai.tinyhumans.tinysecurity.AuditSink2", "ai.tinyhumans.tinysecurity.ApprovalStore2", "ai.tinyhumans.tinysecurity.ApprovalPrompt2"], + optional = ["ai.tinyhumans.tinysecurity.KeyringConsent2", "ai.tinyhumans.tinysecurity.SecretCipherConsumer2", "ai.tinyhumans.tinysecurity.SecretBackend2", "ai.tinyhumans.tinysecurity.DevicePairingHost2", "ai.tinyhumans.tinysecurity.AuditSink2", "ai.tinyhumans.tinysecurity.ApprovalStore2", "ai.tinyhumans.tinysecurity.ApprovalPrompt2"], lazy = false, } #[cfg(test)] @@ -1980,6 +2014,9 @@ use sandbox::sandbox_configured; #[path = "adapter_device.rs"] mod device; +#[path = "adapter_device_pairing.rs"] +mod device_pairing; + #[path = "adapter_crypto.rs"] mod crypto; diff --git a/crates/tinysecurity-module/src/adapter_device_pairing.rs b/crates/tinysecurity-module/src/adapter_device_pairing.rs new file mode 100644 index 00000000..c9880d70 --- /dev/null +++ b/crates/tinysecurity-module/src/adapter_device_pairing.rs @@ -0,0 +1,1223 @@ +//! Host-attested pairing lifecycle over the existing `DeviceFrame2` registry. + +use super::Arc; +use std::sync::OnceLock; +use std::{ + collections::BTreeMap, + sync::Mutex, + sync::atomic::{AtomicU64, Ordering}, +}; +use tinysecurity::crypto::{ + device_handles::{PairedTunnelRegistration, Registry}, + device_pairing::{DevicePairingOwner, LegacyDeviceIdentity, Time}, + native_handles::cipher::DevicePairingStateCipher, +}; +use tinysecurity_bus::{ + DevicePairingConfig2, DevicePairingContextRequest2, DevicePairingContextResponse2, + DevicePairingLegacyFormat2, DevicePairingLegacyLoadRequest2, DevicePairingLegacyLoadResponse2, + DevicePairingOperation2, DevicePairingPersistenceOperation2, DevicePairingPersistenceRequest2, + DevicePairingPersistenceResponse2, DevicePairingPurpose2, DevicePairingRequest2, + DevicePairingResponse2, callback_names2, +}; +use zeroize::{Zeroize, Zeroizing}; + +fn denied() -> tinybus::Error { + tinybus::Error::failed("device pairing authority denied") +} + +fn time(clock: &dyn tinysecurity::approval::Clock) -> tinybus::Result