From 913c7bc61bb0b00bf920af6788ca8eac1837e011 Mon Sep 17 00:00:00 2001 From: 0thernet <894119+0thernet@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:52:02 -0400 Subject: [PATCH 1/2] Add generic_password_read: a macOS keychain read attributed to the caller's signed binary The sidecar gains a product-neutral generic-password op backed by SecItemCopyMatching so a product can ship a signed helper copy inside its own app bundle and get a Keychain prompt and access-list entry in its own name instead of the 'security' tool's. Selectors are exact and bounded, values return as bounded base64, and denials, missing items, locked keychains and other OS failures map to typed codes. There is no TypeScript fallback by design: a native read is the whole point of the op. --- Cargo.lock | 40 ++++++++++++++++++ dist/custody-rust.js | 27 ++++++++++++ rust/Cargo.toml | 3 ++ rust/src/lib.rs | 88 ++++++++++++++++++++++++++++++++++++++++ rust/src/main.rs | 9 ++++ rust/tests/sidecar.rs | 17 ++++++++ spec/custody.md | 26 +++++++++++- spec/vectors.json | 25 ++++++++++++ src/custody-rust.test.ts | 52 ++++++++++++++++++++++++ src/custody-rust.ts | 65 +++++++++++++++++++++++++++++ 10 files changed, 351 insertions(+), 1 deletion(-) 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..6e2f14d 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -2174,6 +2174,94 @@ 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. +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 // --------------------------------------------------------------------------- From 847d535763c7c82c47467dbf1a0f09a4d91c1714 Mon Sep 17 00:00:00 2001 From: 0thernet <894119+0thernet@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:56:56 -0400 Subject: [PATCH 2/2] Gate the generic-password value bound to macOS, where the read runs --- rust/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 6e2f14d..7623fdc 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -2185,7 +2185,9 @@ 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. +/// 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> {