Skip to content

Agent: any same-user process can sign during the unlock window; no per-signature re-authentication #354

Description

@bordumb

Summary

The signing agent in auths-core holds unlocked private-key material and serves signature requests over a
Unix-domain socket. Connection authorization is by peer UID only. This correctly rejects other users,
but it means any process running as the same user can connect during the unlock window and obtain
signatures over arbitrary payloads — and there is no per-signature re-authentication (no biometric /
Secure-Enclave user-presence check, no approval prompt). A single unlock grants silent, blanket signing to
every local process the user runs, for the duration of the window.

Where (code)

Caller authorization — crates/auths-core/src/agent/session.rs:299:

fn peer_is_authorized(peer_uid: u32, owner_uid: u32) -> bool {
    peer_uid == owner_uid
}

This is the only caller gate (it is fed by an SO_PEERCRED / getpeereid check on the connecting socket). It
does not distinguish processes of the same user.

Unlock window — crates/auths-core/src/agent/handle.rs:15 and :19:

pub const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(30 * 60);       // sliding; resets on each sign
pub const DEFAULT_MAX_UNLOCK_TTL: Duration = Duration::from_secs(8 * 60 * 60); // absolute cap

During this window the agent signs any request from a same-UID connection. The signing path
(AgentSession::sign in crates/auths-core/src/agent/session.rs) performs no per-request re-authentication
beyond confirming the agent is unlocked.

Threat / scenario

  1. The user unlocks the agent once (passphrase / initial approval).
  2. Any other process running as that user — a malicious dependency, a compromised build tool, a script —
    connects to the agent's Unix socket.
  3. It sends a sign request with attacker-chosen bytes and receives a valid signature attributable to the
    user's identity.
  4. This succeeds silently for up to the unlock window (default 30 min sliding idle, 8 h absolute), with no
    prompt or biometric.

Already in place (do not regress)

  • Socket directory is 0o700, socket file 0o600, with fail-closed handling of pre-existing loose
    permissions (crates/auths-core/src/api/runtime.rs, functions harden_socket_dir / harden_socket_file).
  • Cross-user connections are rejected (the UID check above).
  • Locking clears in-memory keys; both idle and absolute caps exist and are tested.

The gap is specifically: same-user callers + no per-use confirmation.

Proposed remediation (pick / combine)

  1. Per-signature (or per-new-peer) user approval. Require an explicit user action — OS notification /
    TUI prompt / Touch ID — to approve each signature, or at least the first request from each newly
    connecting process. (ssh-agent's confirm mode and password-manager per-request approval are the model.)
  2. Per-signature biometric / Secure-Enclave user-presence. When the key is SE-backed, require a Touch ID
    / enclave user-presence check per signature (or per N signatures), not only at initial unlock.
  3. Caller pinning / allowlist. Record the first authorized peer (pid + executable path / code-signing
    identity) and require explicit approval when a different same-user process connects.
  4. Tighten defaults. Shorten the 8 h absolute cap and/or add a max-signatures-per-unlock counter.

Full defense against same-UID malware is not achievable without OS-level isolation; the realistic goal is
that obtaining a signature requires user interaction, so one unlock does not equal silent blanket signing.

Acceptance criteria

  • A same-user process connecting mid-window cannot obtain a signature without the chosen approval/biometric
    step.
  • A test under crates/auths-core/tests/cases/ that drives the real socket and asserts a second connection
    cannot sign without that step (mirroring the existing agent_socket.rs socket tests).
  • Existing cross-user rejection and lock-clears-keys tests still pass.

Severity

High. The agent exists to hold signing capability; "one unlock → silent blanket signing for any local
process you run" is the realistic local-compromise path.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions