diff --git a/Cargo.lock b/Cargo.lock index 27263e2..cbe930c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,22 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[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 = "errno" version = "0.3.14" @@ -64,6 +80,7 @@ name = "local-custody" version = "0.8.0" dependencies = [ "libc", + "security-framework", "serde", "serde_json", "tempfile", @@ -119,6 +136,29 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "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 = "serde" version = "1.0.229" diff --git a/dist/custody-rust.js b/dist/custody-rust.js index 34dbfdf..7d595ac 100644 --- a/dist/custody-rust.js +++ b/dist/custody-rust.js @@ -351,6 +351,30 @@ async function rustRequestControlSocket(binary, options) { throw new Error("Invalid control response."); } } +var GENERIC_PASSWORD_FIELD_MAX_BYTES = 256; +var GENERIC_PASSWORD_VALUE_MAX_BYTES = 4096; +function validateGenericPasswordSelector(value, field) { + if (typeof value !== "string" || value.length === 0 || Buffer.byteLength(value, "utf8") > GENERIC_PASSWORD_FIELD_MAX_BYTES || [...value].some((character) => { + const code = character.codePointAt(0) ?? 0; + return code < 32 || code === 127; + })) { + throw new TypeError(`${field} must be 1-256 UTF-8 bytes without control characters`); + } +} +async function readGenericPassword(binaryPath, service, account) { + validateGenericPasswordSelector(service, "service"); + validateGenericPasswordSelector(account, "account"); + const parsed = await runRequest(binaryPath, { op: "generic_password_read", service, account }, FIXED_REQUEST_BYTES, base64Bound(GENERIC_PASSWORD_VALUE_MAX_BYTES) + ENVELOPE_SLACK_BYTES); + const candidate = parsed; + if (!isRecord(parsed) || typeof candidate.contentBase64 !== "string" || !BASE64_PATTERN.test(candidate.contentBase64)) { + throw new CustodySidecarProtocolError(new Error("invalid generic_password_read fields"), JSON.stringify(parsed)); + } + const bytes = Buffer.from(candidate.contentBase64, "base64"); + if (bytes.length > GENERIC_PASSWORD_VALUE_MAX_BYTES) { + throw new CustodySidecarProtocolError(new Error("generic_password_read payload exceeds its bound"), JSON.stringify(parsed)); + } + return bytes; +} function fallbackNotice(reason, inputClass) { emitLocalCustodyFallback(inputClass === undefined ? { tag: FALLBACK_TAG, reason } : { tag: FALLBACK_TAG, reason, inputClass }); } @@ -470,7 +494,10 @@ async function loadLocalCustodyRustEngine() { } export { sidecarBinaryPath, + readGenericPassword, loadLocalCustodyRustEngine, + GENERIC_PASSWORD_VALUE_MAX_BYTES, + GENERIC_PASSWORD_FIELD_MAX_BYTES, CustodySidecarTimeoutError, CustodySidecarProtocolError, CustodySidecarNotFoundError, diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 668ae1d..2411ba5 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -14,6 +14,9 @@ serde_json = "1" [target.'cfg(unix)'.dependencies] libc = "0.2" +[target.'cfg(target_os = "macos")'.dependencies] +security-framework = "3" + [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_SystemServices", "Win32_System_Threading"] } diff --git a/rust/src/lib.rs b/rust/src/lib.rs index dbbf6fc..7623fdc 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -2174,6 +2174,96 @@ pub fn request_control_socket>( )) } +// --------------------------------------------------------------------------- +// Generic-password keychain read (macOS) +// --------------------------------------------------------------------------- + +/// Upper bound for a generic-password `service` or `account` selector, in +/// UTF-8 bytes. The Security framework accepts longer names, but custody +/// callers never need them and the sidecar protocol stays predictable. +const GENERIC_PASSWORD_FIELD_MAX_BYTES: usize = 256; + +/// Upper bound for a returned generic-password secret. Browser safe-storage +/// passwords are short ASCII strings; the bound keeps a malformed or +/// attacker-shaped item from flooding the caller. Only macOS performs the +/// read, so the bound is defined there. +#[cfg(target_os = "macos")] +const GENERIC_PASSWORD_VALUE_MAX_BYTES: usize = 4096; + +fn validate_generic_password_selector(value: &str, field: &str) -> Result<(), CustodyError> { + if value.is_empty() + || value.len() > GENERIC_PASSWORD_FIELD_MAX_BYTES + || value.chars().any(|c| c.is_control() || c == '\u{7f}') + { + return Err(CustodyError::new( + "invalid-request", + format!("{field} must be 1-256 UTF-8 bytes without control characters"), + )); + } + Ok(()) +} + +fn validate_generic_password_request(service: &str, account: &str) -> Result<(), CustodyError> { + validate_generic_password_selector(service, "service")?; + validate_generic_password_selector(account, "account") +} + +#[cfg(target_os = "macos")] +fn generic_password_failure(status: i32) -> CustodyError { + // Security framework OSStatus codes. Only the status number crosses the + // boundary; no keychain contents or OS internals leak into the message. + let (code, detail) = match status { + // errSecItemNotFound + -25300 => ("missing", "no such generic-password item"), + // errSecUserCanceled: the person declined the access prompt. + -128 => ("denied", "the keychain read was declined"), + // errSecAuthFailed: authentication or authorization failed. + -25293 => ("denied", "the keychain read was declined"), + // errSecInteractionNotAllowed: the keychain is locked or this + // process is not allowed to raise the access prompt. + -25318 => ( + "interaction-not-allowed", + "the keychain is locked or this process cannot ask for access", + ), + _ => ("keychain-error", "the keychain read failed"), + }; + CustodyError::new(code, format!("{detail} (status {status})")) +} + +/// Reads one generic-password item's secret bytes from the current user's +/// default keychain. macOS attributes the read to this process's code +/// signature, so a signed helper gets its own access-list entry and prompt +/// instead of borrowing a system tool's. +/// +/// `service` and `account` select the item exactly; both are required so a +/// caller can never enumerate secrets. The returned bytes are the item's +/// secret verbatim — encoding decisions belong to the caller. +#[cfg(target_os = "macos")] +pub fn read_generic_password(service: &str, account: &str) -> Result, CustodyError> { + validate_generic_password_request(service, account)?; + let bytes = security_framework::passwords::get_generic_password(service, account) + .map_err(|error| generic_password_failure(error.code()))?; + if bytes.len() > GENERIC_PASSWORD_VALUE_MAX_BYTES { + return Err(CustodyError::new( + "limit", + "generic-password value exceeds the 4096-byte bound", + )); + } + Ok(bytes) +} + +/// Generic-password reads exist only on macOS; other platforms report +/// `unsupported` after validating the selector so malformed requests fail +/// identically everywhere. +#[cfg(not(target_os = "macos"))] +pub fn read_generic_password(service: &str, account: &str) -> Result, CustodyError> { + validate_generic_password_request(service, account)?; + Err(CustodyError::new( + "unsupported", + "generic-password keychain reads require macOS", + )) +} + #[cfg(all(test, windows))] mod windows_acl_tests { use super::*; diff --git a/rust/src/main.rs b/rust/src/main.rs index 96cc2ef..81d7115 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -16,6 +16,8 @@ //! - `{"op":"read_protected_stdin","maximumBytes":N}` //! - `{"op":"control_socket_request","socketPath":"...","request":{...}, //! "maximumResponseBytes":N,"timeoutMs":N}` +//! - `{"op":"generic_password_read","service":"...","account":"..."}` +//! (macOS only; the read is attributed to this binary's code signature) //! //! `assert_owned_fd` and guarded publish are library-only: descriptor passing //! and in-process commit guards cannot cross the sidecar's process boundary. @@ -93,6 +95,8 @@ enum Request { #[serde(rename = "timeoutMs")] timeout_ms: u64, }, + #[serde(rename = "generic_password_read")] + GenericPasswordRead { service: String, account: String }, } fn parse_mode(s: &str) -> Result { @@ -288,6 +292,11 @@ fn dispatch(req: Request) -> Result { .map_err(|e| (e.code, e.message))?; Ok(response) } + Request::GenericPasswordRead { service, account } => { + let bytes = local_custody::read_generic_password(&service, &account) + .map_err(|e| (e.code, e.message))?; + Ok(json!({ "contentBase64": base64_encode(&bytes) })) + } } } diff --git a/rust/tests/sidecar.rs b/rust/tests/sidecar.rs index 5b6c602..9356650 100644 --- a/rust/tests/sidecar.rs +++ b/rust/tests/sidecar.rs @@ -200,3 +200,20 @@ fn stable_read_round_trips_content() { // "payload" in base64. assert_eq!(response["contentBase64"], "cGF5bG9hZA=="); } + +#[test] +fn generic_password_read_reports_missing_for_absent_item() { + // A random service name never exists, so no access prompt can appear on + // macOS; other platforms report the op as unsupported. + let service = format!("local-custody-test-{}", std::process::id()); + let request = format!( + "{{\"op\":\"generic_password_read\",\"service\":\"{service}-missing\",\"account\":\"none\"}}" + ); + let responses = run_sidecar(&[request]); + let response = &responses[0]; + assert!(is_failure_envelope(response), "unexpected {response}"); + #[cfg(target_os = "macos")] + assert_eq!(response["code"], "missing"); + #[cfg(not(target_os = "macos"))] + assert_eq!(response["code"], "unsupported"); +} diff --git a/spec/custody.md b/spec/custody.md index c93e30e..a7f988b 100644 --- a/spec/custody.md +++ b/spec/custody.md @@ -188,7 +188,9 @@ behind a newline-delimited JSON protocol on stdio. refuses descriptors 0–2 and `read_protected_stdin` with `kind`, because its stdio carries this protocol; `control_socket_request` `{socketPath, request, maximumResponseBytes, - timeoutMs}` → the socket's raw response value. + timeoutMs}` → the socket's raw response value; + `generic_password_read` `{service, account}` → `{contentBase64}` (macOS + only; see "Generic password read"). 3. `exactMode` is an octal **string** (for example `"0600"`); byte bounds are unsigned integers; payloads are base64. 4. A domain failure is `{"ok":false,"code","message"}` — `code` names the @@ -208,6 +210,28 @@ behind a newline-delimited JSON protocol on stdio. `beforeCommit` publishes to the in-process implementation for the same reason. +### Generic password read + +macOS only. The sidecar's `generic_password_read` op reads one +generic-password item's secret bytes from the current user's default +keychain with `SecItemCopyMatching`, so the read is attributed to the +sidecar binary's own code signature: a product that ships a signed copy as +an application helper gets its own prompt and access-list entry instead of +a system tool's. + +1. `service` and `account` are required selectors, each 1–256 UTF-8 bytes + without control characters; there is no enumeration form. +2. The value is returned as `contentBase64` and never exceeds 4096 bytes + (`limit`). +3. Failure codes are typed: `missing` when the item does not exist, + `denied` when the person declines or authentication fails, + `interaction-not-allowed` when the keychain is locked or the process may + not raise the prompt, `keychain-error` for other OS failures carrying + only the numeric status, and `unsupported` off macOS. +4. There is no TypeScript implementation: the op exists so the read happens + inside the caller-chosen signed binary, and a native fallback would break + that attribution. + ### Execution flavor Every filesystem rule applies identically in the asynchronous and the diff --git a/spec/vectors.json b/spec/vectors.json index 4c6678c..467f5b1 100644 --- a/spec/vectors.json +++ b/spec/vectors.json @@ -278,6 +278,31 @@ "name": "control request to missing socket", "request": { "op": "control_socket_request", "socketPath": "$TEMP/private/no.sock", "request": {}, "maximumResponseBytes": 1024, "timeoutMs": 500 }, "expect": { "ok": false, "code": "not-found" } + }, + { + "name": "generic password read rejects an empty service", + "request": { "op": "generic_password_read", "service": "", "account": "acct" }, + "expect": { "ok": false, "code": "invalid-request" } + }, + { + "name": "generic password read rejects an empty account", + "request": { "op": "generic_password_read", "service": "svc", "account": "" }, + "expect": { "ok": false, "code": "invalid-request" } + }, + { + "name": "generic password read rejects an overlong selector", + "request": { "op": "generic_password_read", "service": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "account": "acct" }, + "expect": { "ok": false, "code": "invalid-request" } + }, + { + "name": "generic password read rejects a control character", + "request": { "op": "generic_password_read", "service": "svc\u0000x", "account": "acct" }, + "expect": { "ok": false, "code": "invalid-request" } + }, + { + "name": "generic password read requires both selectors", + "request": { "op": "generic_password_read", "service": "svc" }, + "expect": { "ok": false, "code": "invalid-request" } } ], "protectedInput": [ diff --git a/src/custody-rust.test.ts b/src/custody-rust.test.ts index f27414e..5dd41af 100644 --- a/src/custody-rust.test.ts +++ b/src/custody-rust.test.ts @@ -1,3 +1,4 @@ +import assert from "node:assert"; import { chmodSync, openSync, realpathSync, writeSync, closeSync } from "node:fs"; import { mkdtemp, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -7,6 +8,7 @@ import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test"; import { CustodyError, loadLocalCustodyRustEngine, + readGenericPassword, sidecarBinaryPath, } from "./custody-rust"; import type { LocalCustodyRustEngine } from "./custody-rust"; @@ -249,3 +251,53 @@ describe("custody-rust loader", () => { } }); }); + +describe("readGenericPassword", () => { + const originalEnv = process.env[SIDECAR_ENV]; + let binary: string; + + beforeAll(async () => { + binary = await ensureSidecarPath(); + }, 120000); + + afterAll(() => { + if (originalEnv === undefined) { + delete process.env[SIDECAR_ENV]; + } else { + process.env[SIDECAR_ENV] = originalEnv; + } + }); + + test("rejects malformed selectors without spawning the sidecar", async () => { + await assert.rejects(readGenericPassword(binary, "", "account"), TypeError); + await assert.rejects(readGenericPassword(binary, "service", ""), TypeError); + await assert.rejects(readGenericPassword(binary, "x".repeat(257), "account"), TypeError); + await assert.rejects(readGenericPassword(binary, "bad\u0000service", "account"), TypeError); + }); + + test("propagates sidecar domain failures as typed custody errors", async () => { + if (process.platform === "darwin") { + // A random service name never exists, so no keychain prompt can appear. + const service = `local-custody-test-${crypto.randomUUID()}`; + const error = await readGenericPassword(binary, service, "no-account").then( + () => null, + (caught: unknown) => caught, + ); + expect(error).toBeInstanceOf(CustodyError); + expect((error as CustodyError).code).toBe("missing"); + } else { + const error = await readGenericPassword(binary, "any-service", "any-account").then( + () => null, + (caught: unknown) => caught, + ); + expect(error).toBeInstanceOf(CustodyError); + expect((error as CustodyError).code).toBe("unsupported"); + } + }); + + test("fails closed when the helper binary is absent", async () => { + await assert.rejects( + readGenericPassword(join(tmpdir(), "local-custody-no-such-helper"), "svc", "acct"), + ); + }); +}); diff --git a/src/custody-rust.ts b/src/custody-rust.ts index 753dd39..a57b02e 100644 --- a/src/custody-rust.ts +++ b/src/custody-rust.ts @@ -499,6 +499,71 @@ async function rustRequestControlSocket( } } +// --------------------------------------------------------------------------- +// Generic-password keychain read (macOS) +// --------------------------------------------------------------------------- + +/** Upper bound for a generic-password selector, in UTF-8 bytes. */ +export const GENERIC_PASSWORD_FIELD_MAX_BYTES = 256; +/** Upper bound for a returned generic-password secret. */ +export const GENERIC_PASSWORD_VALUE_MAX_BYTES = 4096; + +function validateGenericPasswordSelector(value: string, field: string): void { + if ( + typeof value !== "string" + || value.length === 0 + || Buffer.byteLength(value, "utf8") > GENERIC_PASSWORD_FIELD_MAX_BYTES + || [...value].some((character) => { + const code = character.codePointAt(0) ?? 0; + return code < 0x20 || code === 0x7f; + }) + ) { + throw new TypeError(`${field} must be 1-256 UTF-8 bytes without control characters`); + } +} + +/** + * Reads one generic-password item's secret bytes from the current user's + * default keychain through the sidecar binary at `binaryPath`. macOS only. + * + * Run it through a product-signed copy of the binary — a helper assembled + * into the product's own app bundle — so the Keychain prompt names the + * product and the access-list entry binds to the helper's signature instead + * of a system tool's. Domain failures arrive as {@link CustodyError}: + * `missing`, `denied`, `interaction-not-allowed`, `keychain-error`, + * `limit`, or `unsupported` on other platforms. + * + * There is intentionally no TypeScript fallback: the point of this op is + * that the request comes from the caller-chosen signed binary. + */ +export async function readGenericPassword( + binaryPath: string, + service: string, + account: string, +): Promise { + validateGenericPasswordSelector(service, "service"); + validateGenericPasswordSelector(account, "account"); + const parsed = await runRequest( + binaryPath, + { op: "generic_password_read", service, account }, + FIXED_REQUEST_BYTES, + base64Bound(GENERIC_PASSWORD_VALUE_MAX_BYTES) + ENVELOPE_SLACK_BYTES, + ); + const candidate = parsed as Record; + if ( + !isRecord(parsed) + || typeof candidate.contentBase64 !== "string" + || !BASE64_PATTERN.test(candidate.contentBase64) + ) { + throw new CustodySidecarProtocolError(new Error("invalid generic_password_read fields"), JSON.stringify(parsed)); + } + const bytes = Buffer.from(candidate.contentBase64, "base64"); + if (bytes.length > GENERIC_PASSWORD_VALUE_MAX_BYTES) { + throw new CustodySidecarProtocolError(new Error("generic_password_read payload exceeds its bound"), JSON.stringify(parsed)); + } + return bytes; +} + // --------------------------------------------------------------------------- // Engine assembly // ---------------------------------------------------------------------------