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/.github/workflows/release.yml b/.github/workflows/release.yml index 51562181..eed6eb8b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -355,6 +355,7 @@ jobs: package_name="${CRATE_NAME}-${VERSION}-${BUNDLE_ID}" package_root="dist/${package_name}" mkdir -p "$package_root" + chmod 755 "$package_root" install -m 755 "$module" "$package_root/" install -m 644 LICENSE MODULE.md "$package_root/" @@ -364,6 +365,11 @@ jobs: > "$package_root/modules.toml" tar -C "$package_root" -czf "dist/${package_name}.tar.gz" . + archive_root_mode="$(tar -tvzf "dist/${package_name}.tar.gz" | awk 'NR == 1 { print substr($0, 1, 10) }')" + if [[ "$archive_root_mode" != "drwxr-xr-x" ]]; then + echo "unsafe module archive root mode: ${archive_root_mode}" >&2 + exit 1 + fi echo "archive=dist/${package_name}.tar.gz" >> "$GITHUB_OUTPUT" - name: Assemble Windows module package @@ -513,6 +519,7 @@ jobs: package_name="${CRATE_NAME}-${VERSION}-${BUNDLE_ID}" package_root="dist/${package_name}" mkdir -p "$package_root" + chmod 755 "$package_root" install -m 755 "$module" "$package_root/" install -m 644 LICENSE MODULE.md "$package_root/" @@ -522,6 +529,11 @@ jobs: > "$package_root/modules.toml" tar -C "$package_root" -czf "dist/${package_name}.tar.gz" . + archive_root_mode="$(tar -tvzf "dist/${package_name}.tar.gz" | awk 'NR == 1 { print substr($0, 1, 10) }')" + if [[ "$archive_root_mode" != "drwxr-xr-x" ]]; then + echo "unsafe module archive root mode: ${archive_root_mode}" >&2 + exit 1 + fi echo "archive=dist/${package_name}.tar.gz" >> "$GITHUB_OUTPUT" - name: Upload distribution package diff --git a/.gitignore b/.gitignore index 007b32b6..b109a3d3 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,8 @@ worktrees/* .idea/ .vscode/ *.swp + +# Python tooling bytecode +__pycache__/ +*.pyc +*.pyo diff --git a/Cargo.lock b/Cargo.lock index efd0c6c5..fd90681a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1330,9 +1330,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -1786,6 +1786,7 @@ name = "tinysecurity-module" version = "0.15.0" dependencies = [ "async-trait", + "base64 0.22.1", "getrandom 0.3.4", "serde", "serde_json", @@ -2285,9 +2286,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/MODULE.md b/MODULE.md index 3a6440e1..ae60761c 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). + ## Confidential injected master resolution Full details: [`Confidential injected master resolution`](docs/module-crypto.md#confidential-injected-master-resolution). diff --git a/README.md b/README.md index e3585034..7b2d3681 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..2b8aff2c --- /dev/null +++ b/crates/tinysecurity-bus/src/device_pairing.rs @@ -0,0 +1,354 @@ +//! 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: + /// 1..=128 UTF-8 bytes, with no Unicode control characters. + 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..6712c680 100644 --- a/crates/tinysecurity-bus/tests/scoped_wire.rs +++ b/crates/tinysecurity-bus/tests/scoped_wire.rs @@ -83,9 +83,10 @@ 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, 24))); 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/README.md b/crates/tinysecurity-crypto/README.md index 89451274..e6a942b2 100644 --- a/crates/tinysecurity-crypto/README.md +++ b/crates/tinysecurity-crypto/README.md @@ -113,6 +113,12 @@ acceptance gates, not established by Linux or cross-compilation. ## Scoped device handles +The separate `device_pairing` owner composes these primitives into durable +enrollment, proof-based confirmation, renewal and revocation. Its state model, +callback boundaries and persistence constraints are documented in the +[device pairing module design](src/device_pairing/README.md); the contract +specification remains the authority for host integration requirements. + `device_handles::Registry` owns immutable approval registrations and the actual verified TinyBus peer, user, workspace, agent and origin captured at SDK init. Calls contain an existing scope reference and operation, never identity claims. diff --git a/crates/tinysecurity-crypto/src/device_handles.rs b/crates/tinysecurity-crypto/src/device_handles.rs index a0209728..05a0efcd 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,30 @@ 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, + /// Fallback clock reading for [`Registry::register_paired_tunnel`]. The + /// clock-closure variant ignores this value and samples while holding the registry lock. + 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])") @@ -365,7 +390,9 @@ impl Registry { DeviceOperation2::Seal { handle, .. } | DeviceOperation2::Open { handle, .. } => { match &state.entry(handle, work)?.native { Native::Tunnel(tunnel, _, _) => Some(Arc::clone(tunnel)), - Native::Key(..) => return Err(Error::DeviceDenied), + Native::Key(..) | Native::PairedTunnel(..) => { + return Err(Error::DeviceDenied); + } } } _ => return state.execute(work, operation), @@ -440,7 +467,7 @@ impl Registry { .filter(|entry| entry.scope == request.scope_id && entry.epoch == scope.epoch) .ok_or(Error::DeviceDenied)?; let tunnel = match &entry.native { - Native::Tunnel(tunnel, _, _) => Arc::clone(tunnel), + Native::Tunnel(tunnel, _, _) | Native::PairedTunnel(tunnel) => Arc::clone(tunnel), Native::Key(..) => return Err(Error::DeviceDenied), }; match &request.operation { @@ -528,17 +555,163 @@ 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(..)) { + 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 now = registration.now; + self.register_paired_tunnel_with_clock(registration, || Some(now)) + } + /// Register a paired session while sampling trusted time under the registry lock. + /// The registration's `now` field is ignored by this method. + /// # Errors + /// Rejects an unavailable or rolled-back clock and the same invalid registrations as + /// [`Self::register_paired_tunnel`]. + pub fn register_paired_tunnel_with_clock( + &self, + registration: PairedTunnelRegistration<'_>, + sample: F, + ) -> Result<()> + where + F: FnOnce() -> Option