Skip to content

Privilege separation stays here; the gate to upstream is secretspec.provider/1 (PR #362) #2

Open

Description

@djbclark

The division of labour, settled 2026-08-18

This repo keeps privilege separation only: vault files owned by a dedicated
service user, unreadable and unwritable by the calling user, mediated through
sudo. The sqlite provider — including its opt-in history feature — goes
upstream via djbclark/secretspec-sqlite, not here.

Privilege separation is not a provider concern, and this repo's own code
already says so:

Confidentiality comes entirely from filesystem permissions on the database
file — the schema and connection carry no credential of their own. That is
what lets identical code serve both an ordinary user-owned store and a
privilege-boundary vault file guarded by a 0600 service-user-owned path:
the privilege model stays external to the provider.
— secretspec/src/provider/sqlite.rs:7-11

The Provider trait is get/set/delete/reflect/preflight. It has no notion of
caller UID or daemon lifecycle, and adding one would force every provider to
implement a security model it does not need.

The gate between the two halves: upstream PR cachix#362

This was already worked out on 2026-08-16 and then went untracked — hence this
issue.
See docs/design/upstream-ipc-v1-and-the-fork.md.

cachix/secretspec PR #362
("SecretSpec IPC v1 for 0.20+", authored by the maintainer) defines two
protocols over one framed JSON-RPC layer:

boundary protocol
app / SDK → secretspec broker secretspec.client/1
secretspec → external provider endpoint secretspec.provider/1

secretspec.provider/1 is the gate: this fork becomes an out-of-tree
privileged provider endpoint, with no patching of upstream provider
internals.

Load-bearing distinction — upstream's "broker" is not a privilege boundary.
It is a private child process of the caller, in the caller's own trust domain.
Forwarding secret authority is a stated upstream non-goal. Upstream built the
plumbing and explicitly fenced off this fork's territory, so cachix#362 grants the
out-of-tree mechanism cachix#345 asked for.

Already engaged: a supportive comment was posted to cachix#362 on 2026-08-17
(cachix#362 (comment), text at
docs/design/pr362-comment.md), announcing intent to ship the first out-of-tree
privileged endpoint, and offering three verified gaps in upstream's
external.rs trust checks as review feedback — POSIX-mode checks blind to macOS
ACLs, std::fs::metadata following symlinks, and trust stopping at the
immediate parent. This fork's drift.rs check_ancestor_chain walks the resolved
chain to / and rejects all three.

Work items

  • Prototype the privileged secretspec.provider/1 endpoint against
    feat/ipc-v1
    in a scratch worktree. Small shim; it proves the "no
    upstream patching" claim instead of asserting it. DONE 2026-08-18 —
    ss-ipc-proto at a204898, provider.lifecycle passes, git diff in
    the upstream checkout is empty. See the comment below for the four
    findings.
  • Wire the endpoint's --client path to the live vault end to end. It
    is implemented, cancellation kills the privileged child, but it has never
    been run against the live boundary. This is the next action.
  • Offer the macOS sigtimedwait portability fix upstream on feat: add versioned client and provider IPC cachix/secretspec#362.
    libsecretspec-ipc/src/process_posix.c:237 makes the conformance suite
    unbuildable on Darwin; a portable sigpending + sigwait fix is ready as
    a patch file in the prototype crate.
  • Watch for a maintainer reply on feat: add versioned client and provider IPC cachix/secretspec#362.
  • Plan the 0.20 rebase: adopt secretspec-ipc types for this fork's own
    client↔broker hop, keeping sudo as the authority mechanism.
  • Position the README against the trust-domain distinction — it currently
    does not address it at all. Risk: "SecretSpec has a broker, with audit and
    reasons" will sound to the ecosystem like it already covers the local-AI-
    agent threat model.
  • Convergence watch: if upstream later specifies the deferred socket
    transport with peer credentials, it genuinely enters this fork's
    space. Counter is to be the incumbent reference privileged deployment
    shaping that spec — which argues for engaging now, not after 0.20 ships.

Superseded by the above

  • The two-database file merge (secrets.db | broker.sqlite3) — the
    "cut by owner" rationale that broke a 2–2 reviewer tie assumed the provider
    and the broker are permanently separate owners. Under the plan above the
    provider goes upstream and this repo keeps only privilege separation, so that
    boundary is not the one to design around. Do not build it without revisiting.
  • Its transactions FK follow-up, for the same reason. Note if it is ever
    revisited: SQLite cannot add a FK to an existing table and FKs cannot span
    files (both verified), so splitting the FK from the merge means rewriting the
    audit trail twice.

Still live, independent of all of the above

  • drift/doctor never verifies the manifest chain at rest.
    drift.rs:1036 verifies only audit::verify_read_only; there is no history
    equivalent, and every history::verify caller is test-only (broker.rs:1805,
    1832, 1855, all after #[cfg(test)] at broker.rs:1560). The chain is
    verified on every capture (history.rs:387), so this is a detection-at-rest
    gap, not silent rot. Worth closing: a deliberate integrity check should not be
    asymmetric between two ledgers of equal standing.

Retracted, do not re-report

"No busy_timeout anywhere in the CLI crate" is FALSE. rusqlite 0.31.0
calls sqlite3_busy_timeout(db, 5000) unconditionally on every
Connection::open (rusqlite-0.31.0/src/inner_connection.rs:121). Two
independent reviewers reported it as a P0; a fix was written, mutation-tested,
found to be a no-op, and reverted. grep finding no explicit call is not
evidence the behavior is absent when a library sets a default. See handoff
7a1c.

Related

Companion issue in the upstreaming fork: djbclark#1 — djbclark/secretspec-sqlite (upstreaming the provider)

Activity

  1. djbclark commented on Aug 19, 2026

    @djbclark
    CollaboratorAuthor

    Prototype started — feasibility confirmed, and one doc correction

    Worktree: /Users/djbclark/src/ss-ipc-proto, branch proto/privileged-endpoint,
    from upstream/feat/ipc-v1 at a393a27. (Fetched via the upstream remote on
    djbclark/secretspec-sqlite.)

    ⚠️ docs/design/upstream-ipc-v1-and-the-fork.md is now stale on one name

    The northbound protocol was renamed secretspec.client/1 → secretspec.resolver/1
    — that is literally the branch tip commit, a393a27 "feat(ipc): name the
    northbound protocol after the resolver, add a blocking client"
    . Evidence:
    secretspec-ipc/src/blocking.rs:1 and lifecycle.rs:249 say
    secretspec.resolver/1.

    secretspec.provider/1 is unchanged (lifecycle.rs:95) — the gate this
    fork depends on is intact. Only the northbound name moved.

    The endpoint-author API exists and the "small shim" claim holds up

    secretspec-ipc is a real workspace member (Cargo.toml members list). The
    authoring surface is two items in secretspec-ipc/src/provider.rs:

    • pub trait ProviderHandler: Send + Sync + 'static (line 43)
    • pub async fn serve_provider<R, W, H>(…) (line 357)

    Only resolve_address is mandatory (plus capabilities and initialize).
    Every other method — get, set, delete, exists, get_many,
    set_expiring, clear, check_writable, check_deletable,
    describe_write_target — has a default returning
    RpcError::new(ErrorKind::CapabilityRequired). So a first endpoint implements
    three methods and opts into more as it goes.

    Values are already handled carefully upstream: SecretValue wraps
    Zeroizing<String> (provider.rs:20-27).

    The shape of the privileged endpoint

    Upstream's reference endpoint
    (conformance/ipc/runner/src/bin/ipc-provider-endpoint-rust.rs, 494 lines)
    serves over stdio:

    serve_provider(tokio::io::stdin(), tokio::io::stdout(), handler, config)

    That is exactly the shape privilege separation needs: the endpoint is a
    stdio-speaking child process, so sudo launching it as the service user is
    the whole privilege mechanism. The calling user never opens the vault files;
    secretspec talks JSON-RPC to a process that can.

    Upstream also ships a conformance suite to test against —
    conformance/ipc/cases/provider-{lifecycle,operations,errors,reconnect,session-isolation}.json.
    Passing those is a much stronger correctness bar than anything hand-rolled.

    Next steps

    • Write the minimal ProviderHandler (capabilities / initialize /
      resolve_address / get) and get it compiling against secretspec-ipc.
    • Run it against conformance/ipc/cases/provider-lifecycle.json.
    • Only then wire it to the real vault path and sudo.
    • Update docs/design/upstream-ipc-v1-and-the-fork.md for the
      secretspec.resolver/1 rename.
  2. djbclark commented on Aug 19, 2026

    @djbclark
    CollaboratorAuthor

    Prototype done — the claim is demonstrated, not asserted (2026-08-18)

    First work item is complete. Worktree /Users/djbclark/src/ss-ipc-proto, branch
    proto/privileged-endpoint, commit a204898, crate privileged-endpoint-proto/.
    Built against feat/ipc-v1 at a393a27.

    No upstream patching, shown mechanically. The crate declares its own
    [workspace] table, so it is not a member of the upstream workspace and depends
    on secretspec-ipc by path through public API only. After a full build, test,
    and conformance run, git diff in the upstream checkout is empty and
    git status shows exactly one untracked directory.

    provider.lifecycle passes, driven by upstream's own checked-in case through
    ipc-provider-conformance-driver:

    {"case":"provider.lifecycle","events":[{"kind":"initialized"},{"kind":"cancelled"},{"kind":"deadline_exceeded"},{"kind":"terminal"},{"kind":"closed"}]}

    Verified by mutation rather than trusted: replacing the __BLOCK__ branch in
    get with if false fails the case with "provider cancellation did not produce
    one cancelled terminal"
    . The pass depends on the behaviour it claims to test.

    "A first endpoint is three methods" holds — capabilities, initialize,
    resolve_address, plus get. Framing, version negotiation, capability gating,
    deadlines, cancellation and shutdown are all serve_provider's.

    Four findings

    1. Upstream's conformance suite cannot be built on macOS at all.
      libsecretspec-ipc/src/process_posix.c:237 calls sigtimedwait under a plain
      #ifndef _WIN32. Darwin has no such function — absent from every SDK header
      and from libSystem, so it is a link failure as well as a compile error,
      not a strict-mode warning. A portable sigpending + sigwait fix is kept as
      a patch file rather than applied, so the no-patching claim stays literally
      true. Worth offering on feat: add versioned client and provider IPC cachix/secretspec#362 — the same lane as fix(cli): restore the default SIGPIPE disposition cachix/secretspec#377.
    2. Our client→broker text channel cannot distinguish "missing" from "denied".
      The broker exits 1 for has no value, is not resolved, and a resolve error
      alike (broker.rs:960-978), and a sudo -n denial also surfaces as 1. The
      endpoint collapses every non-zero exit to one opaque OperationFailed:
      mapping 1 to Missing would tell a caller a secret does not exist when it was
      actually denied. Accepted consequence: the endpoint currently can never
      report a genuinely absent secret.
    3. The same channel loses a trailing newline — the broker prints with
      println! (broker.rs:962), so exactly one \n is framing.
    4. Upstream bounds an address key at 4096 bytes and nothing else
      (Address::validate, protocol.rs:737), so a caller may send --help or
      -n as a key. Nothing reaches a shell, but our own clap would parse it. An
      endpoint author bridging to a CLI needs their own name guard; ours is
      is_safe_name.

    Findings 2 and 3 are the sharpest argument yet for the 0.20 rebase item: on
    secretspec.provider/1 the failure is a structured ErrorKind and the value is
    a byte-exact JSON string, so both defects disappear rather than being decoded.

    One mistake worth recording

    The first version used the installed client when given no arguments. Upstream's
    driver spawns an endpoint with no arguments, so the first conformance run
    crossed the live sudo boundary twice, and the case passed for the wrong reason
    — the subprocess was merely slow enough to look like a blocked request. No
    arguments now means no privileged access, and the privileged path is opt-in.

    Still open

    • Wire --client to the live vault end to end. Implemented, including killing
      the privileged child when a request is cancelled (kill_on_drop), but not yet
      run against the live boundary.
    • Offer the macOS sigtimedwait fix upstream.
  3. djbclark commented on Aug 19, 2026

    @djbclark
    CollaboratorAuthor

    Blocked on #3, and the fork is now on upstream HEAD (2026-08-18)

    Two updates.

    The fork moved to bleeding edge. sudo-main is at 9138c96, a merge of
    upstream/main (cf48a7a), and is now 0 commits behind upstream. The real
    merge base turned out to be dfa4b10 — 87 commits past v0.19.1, not v0.19.1
    itself — so this was 5 commits with exactly one conflict (CHANGELOG.md,
    purely additive, both sides kept). cargo test -p sudo-secretspec-cli is
    229 passed / 0 failed, unchanged from baseline, and cargo check -p secretspec --all-targets is clean. The stale main mirror was fast-forwarded
    to cf48a7a, and upstream's push URL is now DISABLED in this checkout (it was
    not).

    Merging upstream/feat/ipc-v1 on top is the next integration step and is not
    done. It has 6 conflicts — .github/workflows/test.yml, CHANGELOG.md,
    Cargo.lock, Cargo.toml, secretspec/src/config.rs, secretspec/src/lib.rs
    — so it wants a session with room to do it properly, on a topic branch rather
    than on sudo-main (cachix#362 is still open and will be rebased).

    The --client work item is blocked on #3. Wiring the endpoint to a live
    vault is the remaining piece, and it should not be wired to the operational
    boundary — this session already crossed it twice by accident. #3 records the
    decision to build an isolated test boundary instead (compile-time test prefix
    plus ephemeral macOS CI; a VM was considered and rejected on licence and disk
    grounds).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions