diff --git a/Cargo.lock b/Cargo.lock index cb007fd..298383f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -181,6 +181,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -275,6 +281,32 @@ dependencies = [ "cc", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -876,12 +908,37 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "linux-keyutils", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags 2.13.1", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1308,6 +1365,42 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -1788,6 +1881,7 @@ dependencies = [ "clack-private-fs", "fs4", "hkdf", + "keyring", "rustix 1.1.4", "serde", "serde_json", @@ -2258,7 +2352,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -2276,14 +2379,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -2292,48 +2412,96 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 7115bdb..1073552 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,8 +25,8 @@ license = "GPL-3.0-only" repository = "https://github.com/tinyhumansai/tinysecurity" [workspace.dependencies] -# Accepted driver owns secret ports and historical ChaCha formats; no native backend here. -tinystoragedrivers = { path = "vendor/tinystoragedrivers/crates/tinystoragedrivers", default-features = false, features = ["secrets"] } +# Accepted driver owns secret ports, historical ChaCha formats and the native OS keyring. +tinystoragedrivers = { path = "vendor/tinystoragedrivers/crates/tinystoragedrivers", default-features = false, features = ["secrets", "keyring"] } # Private password, credential, device and pairing engines, never host-linked. tinysecurity-crypto = { path = "crates/tinysecurity-crypto" } # Safe native HKDF owns and wipes IKM/PRK/HMAC contexts for device derivation. diff --git a/MODULE.md b/MODULE.md index f2f63a0..5714e31 100644 --- a/MODULE.md +++ b/MODULE.md @@ -79,8 +79,8 @@ Durable approval lifecycle/flow/reply, redaction/scanning, scoped egress guards and audit have implemented members in the generated method inventory. Contract 1.7 approvals use explicit initialization and authenticated callbacks; see [durable exact approvals](crates/tinysecurity-approval/README.md). -Network/DNS connection pinning, sandbox isolation, judge and keyring consent -remain unavailable. Their reserved payloads do not advertise callable members. +Network/DNS connection pinning, sandbox isolation and judge remain unavailable; +keyring consent is implemented by the confidential crypto members below. Their reserved payloads do not advertise callable members. Unsupported member calls return the bus's unknown-member error. ## Packaging and smoke checks @@ -289,3 +289,40 @@ availability, and the active scoped-policy count; `ScopedPolicyInfo(PolicyId)` returns one policy's effective settings and rules. No credential, endpoint or scope handle appears in either. `max_cost_per_day_cents` is not part of the contract: see ADR 0006. + +## Confidential password, keyring and pairing members (contract 1.14) + +Optional `ModuleConfig.crypto: CryptoConfig2` adds three confidential members, +`PasswordCrypt2`, `KeyringAccess2` and `PairingControl2`. Like the device and +secret members they require TinyBus confidential transport and the immutable +verified `trusted_host` peer; an unconfigured module denies all three. Config is +init-only and immutable across reinit. It bounds payload bytes (at most 1 MiB) +and operations per monotonic minute (at most 4096). + +`PasswordCrypt2` preserves the existing Argon2id v0x13 (64 MiB, 3 passes, 1 lane) +plus AES-256-GCM format. The payload keeps the legacy byte-array shape +(`ciphertext`, `nonce`, `salt`); encrypt generates a sixteen-byte salt unless the +caller supplies one for the legacy separate-salt layout. Frozen host fixtures +decrypt unchanged. At most two derivations run at once and there is no wait queue. + +`KeyringAccess2` reads, writes or deletes a named secret on a backend chosen at +init: platform default (Keychain, Credential Manager, Linux kernel keyring), +Linux kernel keyring, or a protected encrypted file in an absolute directory. +Linux Secret Service is not compiled into this module and fails closed. Every +operation first calls the host's `ai.tinyhumans.tinysecurity.KeyringConsent2` +`Consent` callback with the verified caller, purpose, access kind and secret name +(never the value), and proceeds only on an explicit allow. Invalid names or +purposes, oversized values and denied or timed-out consent never reach the backend. + +`PairingControl2` exposes `Status`, `Authenticate` (constant-time bearer check +against legacy tokens), `Invalidate` and `Revoke` for accounts registered at init. +Challenge issuance and numeric verification (`Begin`/`Verify`) are not exposed: +they need confidential delivery to an admitted native destination, the same open +TinyBus gap as the config cipher consumer. Their ports fail closed. + +Passwords, plaintext, secret values and bearers are wiped after use and never +appear in `Debug`, errors, `PolicyInfo` (only `callbacks.crypto`) or audit rows. +The X25519/HKDF device tunnel remains the `EvaluateDevice2`/`ExecuteDevice2` +surface above. The OS keyring test runs on every platform and is required to +use the real credential store when `TINYSECURITY_REQUIRE_OS_KEYRING=1`; it +always removes its entry. diff --git a/README.md b/README.md index 164375b..b056bb9 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Blocked original content denies; suspicious content denies with approval-unavailable in unbound policy methods. Scan verdicts never grant permission, and redaction never clears an existing scan finding. -Contract 1.13 adds `EvaluateScopedAudited` and `CheckScopedAudited` for registered policies under required audit (see [`docs/specs/scoped-policies.md`](docs/specs/scoped-policies.md)). +Contract 1.14 adds confidential `PasswordCrypt2`, `KeyringAccess2` and `PairingControl2` (see [MODULE.md](MODULE.md)). Contract 1.13 added `EvaluateScopedAudited` and `CheckScopedAudited` for registered policies under required audit (see [`docs/specs/scoped-policies.md`](docs/specs/scoped-policies.md)). Contract 1.6 supplies `Audit2`, `EvaluateAudited2` and `CheckAudited2` with authenticated exact audit callbacks and a bounded [native journal](crates/tinysecurity-audit/README.md). @@ -33,7 +33,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, password RPCs, keyring backends and pairing remain unavailable. 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 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. 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 d77595e..5d5668b 100644 --- a/crates/tinysecurity-bus/src/callbacks.rs +++ b/crates/tinysecurity-bus/src/callbacks.rs @@ -145,6 +145,10 @@ pub mod callback_names2 { pub const SECRET_BACKEND: &str = "ai.tinyhumans.tinysecurity.SecretBackend2"; /// Exact confidential target mutation. pub const SET: &str = "Set"; + /// Confidential-transport-free human consent for native keyring access. + pub const KEYRING_CONSENT: &str = "ai.tinyhumans.tinysecurity.KeyringConsent2"; + /// Exact keyring consent member. + pub const CONSENT: &str = "Consent"; /// Versioned durable audit callback. pub const AUDIT_SINK: &str = "ai.tinyhumans.tinysecurity.AuditSink2"; /// Versioned scoped storage callback seam. diff --git a/crates/tinysecurity-bus/src/crypto_ops.rs b/crates/tinysecurity-bus/src/crypto_ops.rs new file mode 100644 index 0000000..fc332d7 --- /dev/null +++ b/crates/tinysecurity-bus/src/crypto_ops.rs @@ -0,0 +1,290 @@ +//! Confidential password, keyring and pairing members (contract 1.14). +//! +//! Every payload here travels only over `TinyBus` confidential transport from +//! the immutable verified `trusted_host` peer. Secrets are never echoed in +//! `Debug`, `PolicyInfo` or audit rows. Handles and identities come from SDK +//! initialization, never from the request. +use crate::CallerContext; +use serde::{Deserialize, Serialize}; + +/// Largest accepted password, keyring value or plaintext, in bytes. +pub const MAX_CRYPTO_PAYLOAD_BYTES: u32 = 1_048_576; + +/// Native OS or file-backed secret store selected at initialization. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum KeyringBackend2 { + /// Platform default: Keychain, Credential Manager or the Linux kernel keyring. + Os, + /// Linux kernel keyring, independent of the compiled default provider. + LinuxKernel, + /// Linux Secret Service over encrypted D-Bus; unavailable unless built in. + SecretService, + /// Protected encrypted local file store in a module-owned directory. + EncryptedFile { + /// Absolute directory holding the store and its key file. + directory: String, + }, +} + +/// Init-only keyring settings; consent is mandatory for every operation. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct KeyringConfig2 { + /// Credential service namespace, 1..=128 bytes. + pub service: String, + /// Backend selection. + pub backend: KeyringBackend2, + /// Bounded `KeyringConsent2` callback wait, in 1..=300000 milliseconds. + pub consent_timeout_ms: u32, +} + +/// One registered pairing account; tokens are legacy configuration bearer values. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PairingAccount2 { + /// Trusted scope. + pub scope: String, + /// Registered provider. + pub provider: String, + /// Immutable account name used by `PairingRequest2`. + pub account: String, + /// Account lifecycle label. + pub lifecycle: String, + /// Whether an empty token set requires numeric pairing. + pub require_pairing: bool, + /// Existing bearer tokens, plaintext or lowercase SHA-256 hex. + pub tokens: Vec, +} + +/// Init-only pairing engine bounds and accounts. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PairingConfig2 { + /// Registered accounts, at most 256. + pub accounts: Vec, + /// Concurrent engine operations, in 1..=32. + pub in_flight: u32, + /// Per-account operations per minute, in 1..=4096. + pub rate: u32, +} + +/// Init-only crypto surface. Absent preserves older payloads. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CryptoConfig2 { + /// Payload bound for passwords, plaintext and secret values, in 1..=1048576. + pub max_payload_bytes: u32, + /// Password, keyring and pairing operations per monotonic minute, in 1..=4096. + pub operations_per_minute: u32, + /// Keyring backend; absent leaves keyring members denied. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub keyring: Option, + /// Pairing registry; absent leaves pairing members denied. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pairing: Option, +} + +/// Existing `OpenHuman` password payload shape: byte arrays with a separate salt. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PasswordPayload2 { + /// Ciphertext followed by the sixteen-byte GCM tag. + pub ciphertext: Vec, + /// Twelve-byte GCM nonce. + pub nonce: Vec, + /// Sixteen-byte Argon2id salt. + pub salt: Vec, +} + +/// Password operation over the legacy Argon2id and AES-256-GCM format. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum PasswordOperation2 { + /// Encrypt with a fresh salt unless a sixteen-byte salt is supplied. + Encrypt { + /// Confidential password, at most 4096 bytes. + password: String, + /// Confidential plaintext. + plaintext: Vec, + /// Optional caller-held salt for the legacy separate-salt layout. + salt: Option>, + }, + /// Authenticate and decrypt a payload carrying its salt. + Decrypt { + /// Confidential password. + password: String, + /// Payload to open. + payload: PasswordPayload2, + }, +} + +/// Password request. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PasswordRequest2 { + /// Exact operation. + pub operation: PasswordOperation2, +} + +/// Password result; exactly one of the fields is populated. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PasswordResponse2 { + /// Sealed payload for encrypt. + pub payload: Option, + /// Plaintext for decrypt. + pub bytes: Vec, +} + +/// Bounded keyring operation on a named secret. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum KeyringOperation2 { + /// Read a secret; absence is reported only when confirmed. + Get { + /// Secret name accepted by the storage driver. + name: String, + }, + /// Store a secret. + Set { + /// Secret name. + name: String, + /// Confidential value. + value: Vec, + }, + /// Remove a secret. + Delete { + /// Secret name. + name: String, + }, +} + +/// Keyring request with verified caller facts for the consent prompt. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct KeyringRequest2 { + /// Verified host caller facts shown to the human. + pub caller: CallerContext, + /// Human-readable purpose, 1..=256 bytes without control characters. + pub purpose: String, + /// Exact operation. + pub operation: KeyringOperation2, +} + +/// Keyring result. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct KeyringResponse2 { + /// Get found a value, or Delete removed one. + pub found: bool, + /// Value for Get; empty otherwise. + pub bytes: Vec, +} + +/// Kind of access shown to the consenting human. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum KeyringAccess2 { + /// Read a secret. + Read, + /// Write a secret. + Write, + /// Delete a secret. + Delete, +} + +/// `KeyringConsent2.Consent` callback request; carries no secret value. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct KeyringConsentRequest2 { + /// Verified requesting identity. + pub caller: CallerContext, + /// Redacted purpose. + pub purpose: String, + /// Access kind. + pub access: KeyringAccess2, + /// Secret name, never its value. + pub name: String, + /// Bounded wait. + pub timeout_ms: u32, +} + +/// `KeyringConsent2.Consent` callback reply. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct KeyringConsentResponse2 { + /// Verified human consent for exactly this request. + pub allowed: bool, +} + +/// Pairing operation on a registered account. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum PairingOperation2 { + /// Nonsecret lifecycle state. + Status, + /// Constant-time check of a presented bearer against stored tokens. + Authenticate { + /// Presented bearer; never echoed. + bearer: String, + }, + /// Invalidate a pending challenge. + Invalidate, + /// Revoke all accepted authority for the account. + Revoke, +} + +/// Pairing request. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PairingRequest2 { + /// Registered account name. + pub account: String, + /// Exact operation. + pub operation: PairingOperation2, +} + +/// Pairing result; state is nonsecret. +/// +/// `Authenticate` charges one engine admission and so reports only its outcome; +/// `is_paired` and `active` are then absent rather than a second rate-limited read. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PairingResponse2 { + /// Configuration flag. + pub require_pairing: bool, + /// Whether any token exists; absent for `Authenticate`. + pub is_paired: Option, + /// Whether an acknowledged challenge can be verified; absent for `Authenticate`. + pub active: Option, + /// Authenticate outcome, absent for other operations. + pub authenticated: Option, +} + +macro_rules! redacted_debug { + ($($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_debug!( + KeyringBackend2, + KeyringConfig2, + PairingAccount2, + PairingConfig2, + CryptoConfig2, + PasswordPayload2, + PasswordOperation2, + PasswordRequest2, + PasswordResponse2, + KeyringOperation2, + KeyringRequest2, + KeyringResponse2, + PairingRequest2, + PairingOperation2, +); +#[cfg(test)] +#[path = "crypto_ops_tests.rs"] +mod tests; diff --git a/crates/tinysecurity-bus/src/crypto_ops_tests.rs b/crates/tinysecurity-bus/src/crypto_ops_tests.rs new file mode 100644 index 0000000..ba9c29d --- /dev/null +++ b/crates/tinysecurity-bus/src/crypto_ops_tests.rs @@ -0,0 +1,104 @@ +use super::*; +use serde_json::json; + +#[test] +fn password_wire_matches_legacy_byte_array_shape() -> Result<(), Box> { + let payload = PasswordPayload2 { + ciphertext: vec![1], + nonce: vec![2], + salt: vec![3], + }; + assert_eq!( + serde_json::to_value(&payload)?, + json!({"ciphertext":[1],"nonce":[2],"salt":[3]}) + ); + let op = PasswordOperation2::Encrypt { + password: "p".into(), + plaintext: vec![9], + salt: None, + }; + assert_eq!( + serde_json::to_value(&op)?, + json!({"kind":"encrypt","password":"p","plaintext":[9],"salt":null}) + ); + Ok(()) +} + +#[test] +fn unknown_fields_and_identity_claims_are_rejected() { + let forged = json!({"account":"a","operation":{"kind":"status"},"peer":"x"}); + assert!(serde_json::from_value::(forged).is_err()); + let request = json!({ + "operation": {"kind":"get","name":"n"}, + "purpose": "p", + "caller": {"agent":"a","thread":null,"flow":null,"call_id":"c","origin":"operator","tier":"full"}, + "subject": "forged" + }); + assert!(serde_json::from_value::(request).is_err()); +} + +#[test] +fn secret_bearing_types_never_debug_their_contents() { + let request = PasswordRequest2 { + operation: PasswordOperation2::Decrypt { + password: "hunter2".into(), + payload: PasswordPayload2 { + ciphertext: vec![0xAA; 20], + nonce: vec![0; 12], + salt: vec![0; 16], + }, + }, + }; + let shown = format!("{request:?}"); + assert!(!shown.contains("hunter2")); + assert_eq!(shown, "PasswordRequest2([REDACTED])"); + let account = PairingAccount2 { + scope: "s".into(), + provider: "p".into(), + account: "a".into(), + lifecycle: "l".into(), + require_pairing: true, + tokens: vec!["bearer-secret".into()], + }; + assert!(!format!("{account:?}").contains("bearer-secret")); +} + +#[test] +fn config_round_trips_and_omits_absent_sections() -> Result<(), Box> { + let config = CryptoConfig2 { + max_payload_bytes: 4096, + operations_per_minute: 60, + keyring: Some(KeyringConfig2 { + service: "openhuman".into(), + backend: KeyringBackend2::EncryptedFile { + directory: "/tmp/x".into(), + }, + consent_timeout_ms: 1000, + }), + pairing: None, + }; + let wire = serde_json::to_value(&config)?; + assert_eq!( + wire, + json!({ + "max_payload_bytes":4096, + "operations_per_minute":60, + "keyring":{"service":"openhuman","backend":{"kind":"encrypted_file","directory":"/tmp/x"},"consent_timeout_ms":1000} + }) + ); + assert_eq!(serde_json::from_value::(wire)?, config); + Ok(()) +} + +#[test] +fn pairing_bearer_never_appears_in_debug_output() { + let operation = PairingOperation2::Authenticate { + bearer: "bearer-secret".into(), + }; + assert_eq!(format!("{operation:?}"), "PairingOperation2([REDACTED])"); + let request = PairingRequest2 { + account: "a".into(), + operation, + }; + assert!(!format!("{request:?}").contains("bearer-secret")); +} diff --git a/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs index 68de716..9c19013 100644 --- a/crates/tinysecurity-bus/src/lib.rs +++ b/crates/tinysecurity-bus/src/lib.rs @@ -2,6 +2,8 @@ //! //! Hosts link this crate; the policy engine and native module stay isolated. //! Implements bootstrap checks plus immutable native filesystem scope contracts. +mod crypto_ops; +pub use crypto_ops::*; mod secret_cipher; pub use secret_cipher::*; mod secret; @@ -40,7 +42,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, 13); +pub const CONTRACT_VERSION: (u32, u32) = (1, 14); /// 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 f2c9d55..e716715 100644 --- a/crates/tinysecurity-bus/src/names.rs +++ b/crates/tinysecurity-bus/src/names.rs @@ -5,6 +5,12 @@ pub const INTERFACE: &str = "ai.tinyhumans.tinysecurity.Policy"; pub const OBJECT_PATH: &str = "/ai/tinyhumans/tinysecurity/Policy"; /// Method names; availability is reported by `PolicyInfo`. pub mod methods { + /// Password encryption and decryption over the legacy Argon2id/AES-256-GCM format. + pub const PASSWORD_CRYPT2: &str = "PasswordCrypt2"; + /// Consent-gated named secret access on the configured native keyring backend. + pub const KEYRING_ACCESS2: &str = "KeyringAccess2"; + /// Nonsecret pairing lifecycle and constant-time bearer authentication. + pub const PAIRING_CONTROL2: &str = "PairingControl2"; /// Admit a bounded routine operation against a real acquired config master. pub const EVALUATE_SECRET_CIPHER2: &str = "EvaluateSecretCipher2"; /// Consume exact private pending cipher authority once. @@ -122,6 +128,9 @@ pub const METHODS: &[&str] = &[ methods::EXECUTE_SECRET_CIPHER2, methods::EVALUATE_SECRET2, methods::EXECUTE_SECRET2, + methods::PASSWORD_CRYPT2, + methods::KEYRING_ACCESS2, + methods::PAIRING_CONTROL2, methods::EVALUATE_DEVICE2, methods::EXECUTE_DEVICE2, methods::PLAN, diff --git a/crates/tinysecurity-bus/src/policy.rs b/crates/tinysecurity-bus/src/policy.rs index 6e091a2..74bb601 100644 --- a/crates/tinysecurity-bus/src/policy.rs +++ b/crates/tinysecurity-bus/src/policy.rs @@ -297,6 +297,9 @@ pub struct ModuleConfig { /// Immutable fixed confidential native config cipher consumer. #[serde(default, skip_serializing_if = "Option::is_none")] pub secret_cipher: Option, + /// Init-only confidential password, keyring and pairing surface. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub crypto: Option, /// Init-only metadata for confidential native master resolution. #[serde(default, skip_serializing_if = "Option::is_none")] pub secret: Option, @@ -331,6 +334,7 @@ impl Default for ModuleConfig { Self { contract_version: crate::CONTRACT_VERSION, device: None, + crypto: None, secret: None, secret_cipher: None, sandbox: None, @@ -414,6 +418,9 @@ pub struct CallbackAvailability { pub secret_cipher: bool, /// Confidential device authority configured. pub device: bool, + /// Confidential password, keyring and pairing authority configured. + #[serde(default)] + pub crypto: bool, } /// Accepted configuration change notification. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/tinysecurity-bus/tests/scoped_wire.rs b/crates/tinysecurity-bus/tests/scoped_wire.rs index 4f19998..c61a994 100644 --- a/crates/tinysecurity-bus/tests/scoped_wire.rs +++ b/crates/tinysecurity-bus/tests/scoped_wire.rs @@ -82,10 +82,11 @@ 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, 13)); - // A host built against 1.13 accepts this module, and this host accepts newer minors. - assert!(is_compatible((1, 13))); + assert_eq!(CONTRACT_VERSION, (1, 14)); + // A host built against 1.14 accepts this module and newer minors, not a 1.13 module. assert!(is_compatible((1, 14))); + assert!(is_compatible((1, 15))); + assert!(!is_compatible((1, 13))); // A 1.12 module cannot supply the audited scoped members. assert!(!is_compatible((1, 12))); assert!(!is_compatible((1, 11))); diff --git a/crates/tinysecurity-bus/tests/wire.rs b/crates/tinysecurity-bus/tests/wire.rs index 2c6c72c..a45762b 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, 13)); + assert_eq!(CONTRACT_VERSION, (1, 14)); 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 f0e17d0..8945127 100644 --- a/crates/tinysecurity-crypto/README.md +++ b/crates/tinysecurity-crypto/README.md @@ -16,8 +16,9 @@ malformed nonce and tag shapes return errors before reaching the cipher. `encrypt` returns an empty salt, as the existing derived-key helper does. The native device surface described below exposes scoped opaque handles over -these internal keys. Hosts continue to link `tinysecurity-bus` alone. Password -RPCs, keyring backends and pairing remain separate, unavailable capabilities. +these internal keys. Hosts continue to link `tinysecurity-bus` alone. Password, +keyring and pairing status operations are the confidential contract 1.14 members +documented in MODULE.md; pairing challenge delivery remains unavailable. The config-secret RPC uses TinyStorageDrivers' existing ChaCha20-Poly1305 storage format and is not implemented by this device surface. diff --git a/crates/tinysecurity-crypto/src/lib.rs b/crates/tinysecurity-crypto/src/lib.rs index 60b44ae..ccc60bc 100644 --- a/crates/tinysecurity-crypto/src/lib.rs +++ b/crates/tinysecurity-crypto/src/lib.rs @@ -19,6 +19,7 @@ pub mod device_handles; mod error; pub mod keyring; pub mod native_handles; +pub mod native_store; pub mod pairing; pub mod password; pub mod secretstore; diff --git a/crates/tinysecurity-crypto/src/native_store.rs b/crates/tinysecurity-crypto/src/native_store.rs new file mode 100644 index 0000000..f7bf869 --- /dev/null +++ b/crates/tinysecurity-crypto/src/native_store.rs @@ -0,0 +1,72 @@ +//! Native secret backends behind the accepted `SecretStore` port. +//! +//! Construction is lazy for OS stores; availability is established only by an +//! operation. Errors are content-free and never carry names or values. +use crate::{Error, Result}; +use sha2::Digest; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; +use tinystoragedrivers::secrets::{EncryptedFileSecrets, KeyringSecrets, SecretStore}; + +/// Trusted backend selection supplied during initialization. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Backend { + /// Keychain, Credential Manager or the platform default credential store. + Os, + /// Linux kernel keyring. + LinuxKernel, + /// Linux Secret Service; not compiled into this module, so opening it fails closed. + SecretService, + /// Protected encrypted file store with a module-owned key file. + EncryptedFile(PathBuf), +} + +/// Open a backend under a credential service namespace. +/// +/// # Errors +/// Returns `KeyringUnavailable` for an unsupported selection on this build or +/// platform, or when the encrypted file store cannot be admitted. +pub fn open(service: &str, backend: Backend) -> Result> { + match backend { + Backend::Os => Ok(Arc::new(KeyringSecrets::new(service))), + #[cfg(target_os = "linux")] + Backend::LinuxKernel => Ok(Arc::new(KeyringSecrets::linux_kernel(service))), + Backend::EncryptedFile(dir) => { + if !dir.is_absolute() || service.is_empty() { + return Err(Error::InvalidInput); + } + // Distinct services never share a store or key file: each owns a + // subdirectory named by a digest of the service, not by caller text. + let digest = sha2::Sha256::digest(service.as_bytes()); + let name: String = digest.iter().take(16).fold(String::new(), |mut out, b| { + use std::fmt::Write; + let _ = write!(out, "{b:02x}"); + out + }); + let dir = dir.join(format!("svc-{name}")); + create_private_dir(&dir)?; + EncryptedFileSecrets::with_key_file(&dir) + .map(|store| Arc::new(store) as Arc) + .map_err(|_| Error::KeyringUnavailable) + } + #[allow(unreachable_patterns)] + _ => Err(Error::KeyringUnavailable), + } +} + +fn create_private_dir(dir: &Path) -> Result<()> { + std::fs::create_dir_all(dir).map_err(|_| Error::KeyringUnavailable)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) + .map_err(|_| Error::KeyringUnavailable)?; + } + Ok(()) +} + +#[cfg(test)] +#[path = "native_store_tests.rs"] +mod tests; diff --git a/crates/tinysecurity-crypto/src/native_store_tests.rs b/crates/tinysecurity-crypto/src/native_store_tests.rs new file mode 100644 index 0000000..9b7619e --- /dev/null +++ b/crates/tinysecurity-crypto/src/native_store_tests.rs @@ -0,0 +1,110 @@ +use super::*; + +type TestResult = std::result::Result<(), Box>; + +fn unique(label: &str) -> std::result::Result> { + let mut bytes = [0u8; 8]; + getrandom::fill(&mut bytes).map_err(|_| "entropy")?; + let suffix = u64::from_le_bytes(bytes); + Ok(format!("tinysecurity-test-{label}-{suffix:016x}")) +} + +#[tokio::test] +async fn encrypted_file_backend_round_trips_and_deletes() -> TestResult { + // The driver rejects symlinked components (macOS /var is one); use the real path. + let dir = std::env::temp_dir().canonicalize()?.join(unique("dir")?); + std::fs::create_dir_all(&dir)?; + let store = open("tinysecurity-test", Backend::EncryptedFile(dir.clone()))?; + assert!(store.get("user:token").await?.is_none()); + store.set("user:token", b"v1").await?; + assert_eq!( + store.get("user:token").await?.as_deref().map(Vec::as_slice), + Some(b"v1".as_slice()) + ); + assert!(store.delete("user:token").await?); + assert!(!store.delete("user:token").await?); + std::fs::remove_dir_all(dir)?; + Ok(()) +} + +#[test] +fn relative_directory_and_unbuilt_secret_service_fail_closed() { + assert_eq!( + open("s", Backend::EncryptedFile("relative".into())).err(), + Some(Error::InvalidInput) + ); + assert_eq!( + open("s", Backend::SecretService).err(), + Some(Error::KeyringUnavailable) + ); +} + +// Real platform credential store (macOS Keychain, Windows Credential Manager, +// Linux kernel keyring). TINYSECURITY_REQUIRE_OS_KEYRING=1 makes an unavailable +// store a failure; otherwise it is reported and skipped. The entry is always +// removed, even when an assertion fails. +struct Cleanup(Arc, String); +impl Drop for Cleanup { + fn drop(&mut self) { + let store = Arc::clone(&self.0); + let name = self.1.clone(); + // Drop may run inside a runtime; clean up on a separate thread. + let _ = std::thread::spawn(move || { + if let Ok(runtime) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + let _ = runtime.block_on(store.delete(&name)); + } + }) + .join(); + } +} + +#[tokio::test] +async fn os_keyring_round_trip_cleans_up() -> TestResult { + let required = std::env::var("TINYSECURITY_REQUIRE_OS_KEYRING").is_ok_and(|v| v == "1"); + let service = unique("svc")?; + let name = format!("user:{}", unique("entry")?); + let store = open(&service, Backend::Os)?; + let _cleanup = Cleanup(Arc::clone(&store), name.clone()); + if let Err(error) = store.set(&name, b"os-secret").await { + if required { + return Err(format!("OS keyring required but unavailable: {:?}", error.kind()).into()); + } + eprintln!("skipping: OS keyring unavailable ({:?})", error.kind()); + return Ok(()); + } + assert_eq!( + store.get(&name).await?.as_deref().map(Vec::as_slice), + Some(b"os-secret".as_slice()) + ); + assert!(store.delete(&name).await?); + assert!(store.get(&name).await?.is_none()); + Ok(()) +} + +#[tokio::test] +async fn encrypted_file_services_in_one_directory_are_isolated() -> TestResult { + let dir = std::env::temp_dir().canonicalize()?.join(unique("shared")?); + std::fs::create_dir_all(&dir)?; + let first = open("service-one", Backend::EncryptedFile(dir.clone()))?; + let second = open("service-two", Backend::EncryptedFile(dir.clone()))?; + first.set("user:shared-name", b"one").await?; + assert!(second.get("user:shared-name").await?.is_none()); + assert!(!second.delete("user:shared-name").await?); + assert_eq!( + first + .get("user:shared-name") + .await? + .as_deref() + .map(Vec::as_slice), + Some(b"one".as_slice()) + ); + assert_eq!( + open("", Backend::EncryptedFile(dir.clone())).err(), + Some(Error::InvalidInput) + ); + std::fs::remove_dir_all(dir)?; + Ok(()) +} diff --git a/crates/tinysecurity-crypto/src/pairing.rs b/crates/tinysecurity-crypto/src/pairing.rs index c039a4c..5bc108a 100644 --- a/crates/tinysecurity-crypto/src/pairing.rs +++ b/crates/tinysecurity-crypto/src/pairing.rs @@ -1076,11 +1076,15 @@ impl PairingOwner { self.inner.sample(&mut state)?; let index = Inner::index(&state, request)?; let permit = self.inner.admit(&mut state, index, false, false)?; - let result = !text.trim().is_empty() - && state.accounts[index] - .tokens - .iter() - .any(|h| **h == *hash(bearer)); + // Hash once, compare every stored digest in constant time, and never stop early. + let presented = hash(bearer); + let mut matched = false; + for stored in &state.accounts[index].tokens { + matched |= + constant_time::verify_slices_are_equal(stored.as_bytes(), presented.as_bytes()) + .is_ok(); + } + let result = !text.trim().is_empty() && matched; drop(state); drop(permit); Ok(result) diff --git a/crates/tinysecurity-crypto/src/pairing_tests.rs b/crates/tinysecurity-crypto/src/pairing_tests.rs index 70e2699..9fec03d 100644 --- a/crates/tinysecurity-crypto/src/pairing_tests.rs +++ b/crates/tinysecurity-crypto/src/pairing_tests.rs @@ -255,6 +255,20 @@ fn disabled_pairing_still_requires_exact_configured_bearer() { .checked() ); } +#[test] +fn bearer_authentication_scans_every_token_and_matches_any_position() { + let tokens: Vec = (0..8).map(|n| format!("token-{n}")).collect(); + let (owner, _, _, _, _) = setup(false, tokens, Limits::default()); + for n in 0..8 { + assert!( + owner + .authenticate(&account_request(), format!("token-{n}").as_bytes()) + .checked() + ); + } + assert!(!owner.authenticate(&account_request(), b"token-8").checked()); + assert!(!owner.authenticate(&account_request(), b"token-").checked()); +} #[tokio::test] async fn secret_staging_uses_one_permitted_slot_without_reentrant_lock() { let (owner, _, _, _, _) = setup(true, vec![], Limits::default()); diff --git a/crates/tinysecurity-crypto/src/password.rs b/crates/tinysecurity-crypto/src/password.rs index 1a9ddb6..a304cc4 100644 --- a/crates/tinysecurity-crypto/src/password.rs +++ b/crates/tinysecurity-crypto/src/password.rs @@ -132,6 +132,44 @@ impl PasswordKey { } } +/// Encrypt with a derived key and return a payload that carries its salt. +/// +/// A supplied salt must be sixteen bytes (the legacy separate-salt layout); +/// otherwise a fresh one is generated. +/// +/// # Errors +/// Limit, input, entropy or derivation failures; none retain the inputs. +pub fn encrypt_with_password( + password: &str, + plaintext: &[u8], + salt: Option<&[u8]>, +) -> Result { + if plaintext.len() > MAX_PLAINTEXT_BYTES { + return Err(Error::Limit); + } + let salt = match salt { + Some(salt) => salt.to_vec(), + None => PasswordKey::generate_salt()?.to_vec(), + }; + let mut payload = PasswordKey::derive(password, &salt)?.encrypt(plaintext)?; + payload.salt = salt; + Ok(payload) +} + +/// Derive from the payload's salt, authenticate and decrypt. +/// +/// # Errors +/// Limit, input or authentication failures; none retain the inputs. +pub fn decrypt_with_password( + password: &str, + payload: &PasswordPayload, +) -> Result>> { + if payload.ciphertext.len() > MAX_PLAINTEXT_BYTES + TAG_BYTES { + return Err(Error::Limit); + } + PasswordKey::derive(password, &payload.salt)?.decrypt(payload) +} + #[cfg(test)] #[path = "password_tests.rs"] mod tests; diff --git a/crates/tinysecurity-crypto/src/password_tests.rs b/crates/tinysecurity-crypto/src/password_tests.rs index f9f6274..ef7eabd 100644 --- a/crates/tinysecurity-crypto/src/password_tests.rs +++ b/crates/tinysecurity-crypto/src/password_tests.rs @@ -201,3 +201,41 @@ fn resolved_authentication_state_runs_cleanup_on_drop() { assert!(std::mem::needs_drop::()); assert!(std::mem::needs_drop::()); } + +#[test] +fn one_shot_helpers_round_trip_with_embedded_or_supplied_salt() +-> std::result::Result<(), Box> { + let sealed = encrypt_with_password("pw", b"bytes", None)?; + assert_eq!(sealed.salt.len(), 16); + assert_eq!(&*decrypt_with_password("pw", &sealed)?, b"bytes"); + assert_eq!( + decrypt_with_password("other", &sealed), + Err(Error::Authentication) + ); + let fixed = encrypt_with_password("pw", b"bytes", Some(&[5; 16]))?; + assert_eq!(fixed.salt, vec![5; 16]); + assert_eq!( + encrypt_with_password("pw", b"x", Some(&[1; 3])), + Err(Error::InvalidInput) + ); + let mut bad = sealed.clone(); + bad.salt = vec![0; 15]; + assert_eq!(decrypt_with_password("pw", &bad), Err(Error::InvalidInput)); + Ok(()) +} + +#[test] +fn one_shot_decrypt_opens_the_frozen_host_fixture() +-> std::result::Result<(), Box> { + let fixture: serde_json::Value = + serde_json::from_str(include_str!("../fixtures/openhuman-password.json"))?; + let password = fixture["password"].as_str().ok_or("password")?; + let mut payload: PasswordPayload = serde_json::from_value(fixture["payload"].clone())?; + let salt: Vec = serde_json::from_value(fixture["salt"].clone())?; + payload.salt = salt; + assert_eq!( + &*decrypt_with_password(password, &payload)?, + b"host AES/password fixture\0legacy bytes" + ); + Ok(()) +} diff --git a/crates/tinysecurity-crypto/src/secretstore/mod.rs b/crates/tinysecurity-crypto/src/secretstore/mod.rs index 0f09f9e..cd21ee6 100644 --- a/crates/tinysecurity-crypto/src/secretstore/mod.rs +++ b/crates/tinysecurity-crypto/src/secretstore/mod.rs @@ -1,7 +1,7 @@ //! Injected secret storage; native I/O belongs to the driver. use crate::{Error, Result}; /// Existing owner storage port, re-exported for internal callback adapters. -pub use tinystoragedrivers::secrets::{SecretStore, Zeroizing}; +pub use tinystoragedrivers::secrets::{SecretStore, Zeroizing, validate_name}; /// Existing owner error vocabulary; adapters project only static diagnostics. pub use tinystoragedrivers::{ErrorKind, StorageError}; diff --git a/crates/tinysecurity-module/examples/audit_native_contract/manifest.rs b/crates/tinysecurity-module/examples/audit_native_contract/manifest.rs index 0ee6b38..b6d6dd2 100644 --- a/crates/tinysecurity-module/examples/audit_native_contract/manifest.rs +++ b/crates/tinysecurity-module/examples/audit_native_contract/manifest.rs @@ -38,6 +38,7 @@ pub(super) async fn verify( callback_names2::APPROVAL_PROMPT, callback_names2::SECRET_BACKEND, callback_names2::SECRET_CIPHER_CONSUMER, + callback_names2::KEYRING_CONSENT, ] .into_iter() .collect(); diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index c6f0307..3bc1adf 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -31,6 +31,7 @@ static ACTIVE: OnceLock>> = OnceLock::new(); struct PolicyService { sandbox_clock: Arc, device: Option>, + crypto: Option>, secret: Option>, secret_cipher: Option>, sandbox: Option>, @@ -214,6 +215,27 @@ impl PolicyService { self.secret_execute(request).await } #[tinybus(confidential)] + async fn password_crypt2( + &self, + request: tinysecurity_bus::PasswordRequest2, + ) -> BusResult { + self.password_crypt(request).await + } + #[tinybus(confidential)] + async fn keyring_access2( + &self, + request: tinysecurity_bus::KeyringRequest2, + ) -> BusResult { + self.keyring_access(request).await + } + #[tinybus(confidential)] + async fn pairing_control2( + &self, + request: tinysecurity_bus::PairingRequest2, + ) -> BusResult { + std::future::ready(self.pairing_control(request)).await + } + #[tinybus(confidential)] async fn evaluate_device2( &self, request: tinysecurity_bus::EvaluateDeviceRequest2, @@ -758,6 +780,7 @@ impl PolicyService { info.callbacks.secret = self.secret.is_some(); info.callbacks.secret_cipher = self.secret_cipher.is_some(); info.callbacks.device = self.device.is_some(); + info.callbacks.crypto = self.crypto.is_some(); info.scoped_policies = self .scoped .lock() @@ -1084,6 +1107,7 @@ fn service_for( PolicyService { sandbox_clock: Arc::new(tinysecurity::approval::SystemClock::default()), device: None, + crypto: None, secret: None, secret_cipher: None, sandbox: None, @@ -1332,14 +1356,86 @@ fn publish_policy_generation( } Ok(()) } +struct ConfidentialConfigs { + secret: Option, + cipher: Option, + crypto: Option, +} +impl ConfidentialConfigs { + fn take(config: &mut ModuleConfig) -> Self { + Self { + secret: config.secret.take(), + cipher: config.secret_cipher.take(), + crypto: config.crypto.take(), + } + } +} +type ApprovalEngine = ( + tinysecurity_bus::ApprovalConfig2, + Arc, +); +struct ConfidentialContext<'a> { + peer: Option<&'a str>, + approvals: Option<&'a ApprovalEngine>, + approval_config: Option<&'a tinysecurity_bus::ApprovalConfig2>, + audit_config: Option<&'a tinysecurity_bus::AuditConfig2>, + audit: Option<&'a Arc>, + generation: u64, + clock: &'a Arc, +} +struct ConfidentialAuthorities { + secret: Option>, + secret_cipher: Option>, + crypto: Option>, +} +/// Build the init-only confidential authorities that sit above the approval engine. +fn configured_confidential( + connection: &Connection, + configs: ConfidentialConfigs, + context: &ConfidentialContext<'_>, +) -> BusResult { + let secret = secret::configured( + configs.secret, + secret::Initialization { + connection, + peer: context.peer, + approvals: context.approval_config, + engine: context.approvals.map(|(_, engine)| engine), + audit: context.audit_config, + generation: context.generation, + clock: Arc::clone(context.clock), + }, + )?; + let secret_cipher = cipher::configured( + configs.cipher, + connection, + context.peer, + secret.as_ref(), + context.approvals.map(|(_, engine)| engine), + context.audit, + )?; + let crypto = crypto::configured( + configs.crypto, + &crypto::Initialization { + connection, + peer: context.peer, + generation: context.generation, + clock: Arc::clone(context.clock), + }, + )?; + Ok(ConfidentialAuthorities { + secret, + secret_cipher, + crypto, + }) +} 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. let mut device_config = tinysecurity::crypto::device_handles::ConfidentialConfig::new(config.device.take()); let device_clock = default_clock(); - let secret_config = config.secret.take(); - let cipher_config = config.secret_cipher.take(); + let confidential_configs = ConfidentialConfigs::take(&mut config); let judge_config = config.judge.clone(); let audit_config = config.audit.clone(); let approval_config = config.approvals.clone(); @@ -1371,26 +1467,23 @@ async fn setup(connection: Connection, mut config: ModuleConfig) -> BusResult<() &candidate, APPROVALS.get(), )?; - let secret = secret::configured( - secret_config, - secret::Initialization { - connection: &connection, + let ConfidentialAuthorities { + secret, + secret_cipher, + crypto, + } = configured_confidential( + &connection, + confidential_configs, + &ConfidentialContext { peer: requested_host.as_deref(), - approvals: approval_config.as_ref(), - engine: approvals.as_ref().map(|(_, engine)| engine), - audit: audit_config.as_ref(), + approvals: approvals.as_ref(), + approval_config: approval_config.as_ref(), + audit_config: audit_config.as_ref(), + audit: audit.as_ref(), generation: candidate.info().generation, - clock: device_clock.clone(), + clock: &device_clock, }, )?; - let secret_cipher = cipher::configured( - cipher_config, - &connection, - requested_host.as_deref(), - secret.as_ref(), - approvals.as_ref().map(|(_, engine)| engine), - audit.as_ref(), - )?; let judge = configured_automatic(judge_config, approvals.as_ref().map(|(_, e)| e))?; if let Some(active) = ACTIVE.get() { let mut guard = active @@ -1420,6 +1513,7 @@ async fn setup(connection: Connection, mut config: ModuleConfig) -> BusResult<() let policy = Arc::new(RwLock::new(candidate)); let mut service = service_for(Arc::clone(&policy), audit.clone()); service.device.clone_from(&device); + service.crypto.clone_from(&crypto); service.secret.clone_from(&secret); service.secret_cipher.clone_from(&secret_cipher); service.signals = Some(connection.clone()); @@ -1431,6 +1525,7 @@ async fn setup(connection: Connection, mut config: ModuleConfig) -> BusResult<() } serve_policy(&connection, service, binding.clone()).await?; device::activate(device.as_ref())?; + crypto::activate(crypto.as_ref())?; secret::activate(secret)?; cipher::activate(secret_cipher)?; activate_policy(policy, audit, binding, sandbox, approvals) @@ -1484,10 +1579,10 @@ tinybus_module::module_export! { config = ModuleConfig, worker_threads = 1, provides = ["ai.tinyhumans.tinysecurity.Policy"], - methods = ["EvaluateSecretCipher2", "ExecuteSecretCipher2", "EvaluateSecret2", "ExecuteSecret2", "EvaluateDevice2", "ExecuteDevice2", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "Audit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "UrlGuard", "EgressCheck", "Redact", "ScanPrompt", "ScanToolDefinition"], + methods = ["EvaluateSecretCipher2", "ExecuteSecretCipher2", "EvaluateSecret2", "ExecuteSecret2", "PasswordCrypt2", "KeyringAccess2", "PairingControl2", "EvaluateDevice2", "ExecuteDevice2", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "Audit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "UrlGuard", "EgressCheck", "Redact", "ScanPrompt", "ScanToolDefinition"], signals = ["PolicyChanged"], requires = [], - optional = ["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.AuditSink2", "ai.tinyhumans.tinysecurity.ApprovalStore2", "ai.tinyhumans.tinysecurity.ApprovalPrompt2"], lazy = false, } #[cfg(test)] @@ -1501,6 +1596,9 @@ use sandbox::sandbox_configured; #[path = "adapter_device.rs"] mod device; +#[path = "adapter_crypto.rs"] +mod crypto; + #[path = "adapter_secret.rs"] pub(crate) mod secret; diff --git a/crates/tinysecurity-module/src/adapter_crypto.rs b/crates/tinysecurity-module/src/adapter_crypto.rs new file mode 100644 index 0000000..f6bc08b --- /dev/null +++ b/crates/tinysecurity-module/src/adapter_crypto.rs @@ -0,0 +1,613 @@ +//! Confidential password, keyring and pairing members. +//! +//! Authority comes from SDK initialization and the authenticated trusted-host +//! peer. Secrets travel only over confidential transport, are wiped after use +//! and never enter `PolicyInfo`, logs, errors or audit rows. Keyring access +//! requires a fresh human consent callback for every operation. +use super::{Arc, BusResult, PolicyService}; +use std::sync::{Mutex, OnceLock, Weak}; +use std::time::Duration; +use tinysecurity::crypto::{ + native_handles::{NativeClock, Time, Verification}, + native_store::{self, Backend}, + pairing::{ + DeliveryAck, DurableGrantAck, DurablePairingVerifier, Limits, NativePairingConsumer, + PairingOwner, Registration, StageAck, StagedDelivery, TrustedAccountRequest, + VerifiedGrantBinding, + }, + password::{self, PasswordPayload}, + secretstore::{SecretStore, Zeroizing}, +}; +use tinysecurity_bus::{ + CryptoConfig2, KeyringAccess2, KeyringBackend2, KeyringConsentRequest2, + KeyringConsentResponse2, KeyringOperation2, KeyringRequest2, KeyringResponse2, + MAX_CRYPTO_PAYLOAD_BYTES, PairingOperation2, PairingRequest2, PairingResponse2, + PasswordOperation2, PasswordPayload2, PasswordRequest2, PasswordResponse2, callback_names2, +}; +use zeroize::Zeroize; + +// A weak lookup does not extend native credentials past runtime shutdown. +static CRYPTO: OnceLock>> = OnceLock::new(); +// Argon2id uses 64 MiB per derivation; never run more than two at once. +const PASSWORD_PERMITS: usize = 2; + +fn denied() -> tinybus::Error { + tinybus::Error::failed("crypto authority denied") +} +fn control_free(text: &str, max: usize) -> bool { + !text.is_empty() && text.len() <= max && !text.chars().any(char::is_control) +} + +struct Window { + minute: u64, + count: u32, +} +struct Consent { + connection: tinybus::Connection, + peer: tinybus::BusName, + timeout_ms: u32, +} +struct KeyringState { + store: Arc, + consent: Consent, +} +struct Deny; +impl NativePairingConsumer for Deny { + fn stage<'a>(&'a self, _delivery: &'a StagedDelivery) -> Verification<'a, StageAck> { + Box::pin(async { Err(tinysecurity::crypto::Error::NativeDenied) }) + } + fn complete<'a>(&'a self, _delivery: &'a StagedDelivery) -> Verification<'a, DeliveryAck> { + Box::pin(async { Err(tinysecurity::crypto::Error::NativeDenied) }) + } +} +// Challenge delivery and durable grants need an admitted confidential native +// destination; until then Begin/Verify are not exposed and these ports fail closed. +impl DurablePairingVerifier for Deny { + fn persist<'a>( + &'a self, + _binding: &'a VerifiedGrantBinding, + ) -> Verification<'a, DurableGrantAck> { + Box::pin(async { Err(tinysecurity::crypto::Error::NativeDenied) }) + } +} +struct SystemTime(Arc); +impl NativeClock for SystemTime { + fn now(&self) -> tinysecurity::crypto::Result