Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 27 additions & 0 deletions dist/custody-rust.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down Expand Up @@ -470,7 +494,10 @@ async function loadLocalCustodyRustEngine() {
}
export {
sidecarBinaryPath,
readGenericPassword,
loadLocalCustodyRustEngine,
GENERIC_PASSWORD_VALUE_MAX_BYTES,
GENERIC_PASSWORD_FIELD_MAX_BYTES,
CustodySidecarTimeoutError,
CustodySidecarProtocolError,
CustodySidecarNotFoundError,
Expand Down
3 changes: 3 additions & 0 deletions rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }

Expand Down
90 changes: 90 additions & 0 deletions rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2174,6 +2174,96 @@ pub fn request_control_socket<P: AsRef<Path>>(
))
}

// ---------------------------------------------------------------------------
// 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<Vec<u8>, 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<Vec<u8>, 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::*;
Expand Down
9 changes: 9 additions & 0 deletions rust/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<u32, String> {
Expand Down Expand Up @@ -288,6 +292,11 @@ fn dispatch(req: Request) -> Result<serde_json::Value, (String, String)> {
.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) }))
}
}
}

Expand Down
17 changes: 17 additions & 0 deletions rust/tests/sidecar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
26 changes: 25 additions & 1 deletion spec/custody.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
25 changes: 25 additions & 0 deletions spec/vectors.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
52 changes: 52 additions & 0 deletions src/custody-rust.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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"),
);
});
});
Loading
Loading