Skip to content

Placeholder secrets

A workspace never holds a real secret. It holds a placeholder — a sentinel token the operator mints once and pastes into the workspace — and the daemon swaps the sentinel for the real secret on the wire, in flight, only toward the destinations the mint named. The real secret is the operator's own value, supplied at mint: a file or stdin on the CLI, a masked field on the TUI form. It lives in msksd's secret store (the database's per-recipient age-encrypted blobs) and in the daemon's unlock cache for its window. It is never echoed back; the mint reply carries the sentinel alone.

A mint covers workspaces. With no target it is the daemon-wide placeholder: one row, one sentinel, valid on every workspace's tap — an operator running several workspaces against one daemon mints the credential once and distributes the same sentinel everywhere. A --workspace target (one name or a comma list, repeatable) scopes the row to exactly those workspaces: one row, one sentinel, one coverage set. The sentinel's prefix names its reach from the string alone — mskssec1_… scopes to its row's workspaces, mskssec2_… reaches every accepting workspace on the daemon.

This chapter is about the store and the mint/revoke/renew commands. The in-flight swap itself is the egress interceptor — the next section.

The interceptor (the swap on the wire)

A workspace covered by at least one live placeholder is armed. A live placeholder is minted, unrevoked, and unexpired; the coverage is the workspace's own scoped placeholders, or any daemon-wide placeholder while the workspace accepts one. When armed, the daemon redirects that workspace's TCP flows toward ports 80 and 443 into an in-process HTTPS/HTTP proxy, and the swap happens there. The proxy is mitmproxy, embedded in msksd — one process serving every armed workspace, one listener per workspace's tap, so a guest that spoofs another workspace's source address still lands on its own tap's placeholders.

What each request gets:

  • The swap. The sentinel is exchanged for the real secret in every request header and every query-string pair — duplicate keys included — when the destination matches the placeholder's allowlist (exact host or label-anchored suffix, as minted). Request bodies are not scanned: the sentinel rides headers and URLs, and an operator pasting it into a body payload sends it nowhere the placeholder covers. The origin sees the real secret; the workspace never holds it. On HTTPS, the connection's TLS handshake (its SNI) names the destination, and the request's Host header is pinned to that same name. A guest cannot claim one name in the handshake and route by another in the header (domain fronting). On plain HTTP the request is dialed by the Host header's name, for the same binding.
  • The splice. An HTTPS destination no placeholder of this workspace covers is relayed undecrypted: the origin's real certificate reaches the workspace, pinned clients keep working, and the sentinel rides raw. Detection of off-allowlist sightings is limited to decrypted flows — this blind spot is recorded on the decision doc of the time (docs/spikes/194-egress-interceptor.md, since removed).
  • The sighting. A sentinel seen toward a destination its own allowlist misses — while another placeholder decrypts the flow — passes through unrewritten and publishes a secret.sighting event on the events channel. The same is true for a sentinel a workspace holds no covering row for. A daemon-wide sentinel used from a workspace scoped against it, or another workspace's scoped sentinel, reads as an off-allowlist sighting: the cross-workspace leak is announced, never serviced. A revoked or expired sentinel passes through the same way; its placeholder is gone, so there is nothing to swap and nothing to report.
  • QUIC stays down while armed. The workspace's UDP flows toward port 443 are dropped, so browsers fall back to the TCP flow the redirect owns — nothing routes around the interceptor.

While a workspace is armed, the redirect takes its web egress (TCP 80 and 443) before the kernel's consent queue, so the interceptor runs the consent gate for exactly those flows. Every web connection is decided on the name the wire carries: the TLS handshake's SNI on HTTPS, the Host header on plain HTTP. The verdict is bound to the address the naming memory holds for the connection. A name claiming an address it never resolved to keys by the address, so it borrows nothing. A static workspace's allowlist gates its web traffic by that name. An interactive workspace's web connections hold through the same decider endpoints, rows, durations, and session memory every other port uses; msks egress watch and the TUI answer them like any other hold. An allowed web connection takes the splice or swap path above. A denied one is answered locally with an HTTP 403 naming consent: the handshake completes against the daemon's interception CA, the request refuses, and nothing forwards. A client that pins the origin's real certificate fails its handshake instead; it is refused closed either way. See egress consent for the gate's precedence and its per-connection boundaries. The consent modes keep gating every other port at the kernel queue as before. The verdict pins and resolver-learned allows they hold in the kernel carry across the interceptor's arm/disarm table swaps: they are re-pinned with their remaining lifetimes in the same transaction that swaps the table.

Each swap publishes a secret.swap event; mint, revoke, expiry, and unlock publish their own (secret.mint, secret.revoke, secret.expiry, secret.unlock). All six ride the events websocket beside the workspace lifecycle events. Each names the placeholder's row id, its workspace and name, and a timestamp. The row id is the durable handle, because the row itself retires on revoke and expiry. A swap or sighting also names the destination the wire saw; a mint names the allowlist it was minted with; revoke, expiry, and unlock carry the identity alone. The msks tui secrets page's audit view (e from the page) shows every workspace's events newest first, daemon-wide. The newest hundred recorded mints, revokes, expiries, and unlocks replay onto it when the view opens, and swaps and sightings stream in live. The off-allowlist sighting is highlighted as the exfil signal, with a header line stating the detection boundary above. Kind and workspace filters narrow the view — a daemon-wide row's events cover every workspace, a scoped row's its members. Each lifecycle event carries its audit row's id on both the live stream and the replay, so one fact lands once on the screen however it arrives. A replayed row keeps no placeholder row-id suffix: the audit record holds the identity, not the placeholder's id. An empty view says so — the screen never renders the header line alone.

Fail-closed on the swap path. A secret the store cannot serve, or a row the database cannot read, answers the request locally with a 502 instead of forwarding it. A request that still carries the sentinel never leaves the host.

The interceptor presents each connection a leaf certificate signed by the daemon's one interception CA. Every workspace's guest trusts the same certificate its seed staged: the identity seed writes /etc/msks/interceptor-ca.crt at first boot, and the trust-store exports and the NixOS fold register it. A workspace whose guest already trusts that CA sees HTTPS toward allowlisted destinations validate normally from the first placeholder. When the daemon's certificate changes under a workspace that already booted (the move from an earlier per-workspace CA, or a future CA rotation), the next boot renews the seed. The seed is rebuilt carrying the daemon's certificate with an instance-id suffix keyed to it. cloud-init re-provisions (the seed script is idempotent, and the operator's payload re-executes with it), and the fold re-registers the fresh certificate. The splice leg needs none of this: it presents the origin's own certificate. Arming and disarming swap the workspace's firewall table in one nft transaction — the redirect, the widened input rule for the listener, and the QUIC drop appear and disappear together, with no window in between where the table is absent.

The listeners bind one shared port on each armed tap address (interceptor_port, default 8643 — see the key reference). Upstream connections are verified against the CA bundle mitmproxy ships (certifi's Mozilla list) — not the platform's own store; pointing verification at the platform bundle is a setting the certification effort brings, never a code change. The daemon pins no cipher list of its own anywhere: the contexts it builds take mitmproxy's curated default list, and any override arrives as a setting with the certification effort, never as code. One functional limit rides that posture: the client-facing leg negotiates HTTP/1.1 only (no ALPN callback is installed), so guests with HTTP/2 fall back — the swap, the splice, and the detection behave the same over HTTP/1.1.

Two lifecycle facts worth knowing: a workspace whose interceptor cannot arm (its listener cannot bind) fails its boot with the named cause — an armed workspace is the whole point of a placeholder, and half-armed is worse than refused. A connection the guest opened before the first placeholder armed keeps flowing unintercepted until it ends, because the kernel's connection tracking outlives the rule swap. An established redirected flow, symmetrically, breaks when the last placeholder goes: the listener is gone while its NAT entry lingers. Both sit in the same accepted blind-spot class as the splice tier. Guest-to-guest web traffic while armed sits there too: the redirect takes every tap 80/443 flow, including one workspace dialing another's address, and the interceptor dials the destination from the host, where the per-VM forward gates do not apply.

The seeded probe placeholder

A fresh daemon seeds one placeholder itself at first-time startup: the daemon-wide row named probe, allowlisting secretprobe.msks, carrying the fixed credential blob bXNrczptc2tz as its secret. It exists so an operator can verify interception from inside a workspace with zero minting (the probe endpoint). Its sentinel never needs to be treated as a secret: the credential it swaps to is public and gates nothing beyond the probe's own ok page.

Read the seeded sentinel back over the token-gated API — GET /api/v1/probe answers the row id, the sentinel, and the endpoint's recipe — and revoke it like any other placeholder when you do not want it. The seed runs exactly when the placeholder table is empty. A daemon that already holds rows seeds nothing, and a revoked probe row stays gone while the operator's rows hold the table. Mint it back by hand instead: msks secret mint --name probe --dest secretprobe.msks --secret-file - with the fixed credential blob bXNrczptc2tz (the value the probe endpoint itself serves). The seed's mint rides the audit trail like any other.

Under the unlock window, the probe row is armed for the window at every boot and again on every GET /api/v1/probe read. The credential is public, so the daemon re-arms its own row rather than leaving the probe locked after the window expires. That is the same rationale that lets the endpoint serve the credential at all. Between the window's expiry and the next read (or boot, or an operator's unlock with the public value) the probe's swap answers the locked refusal like any other row.

The mint flow

$ op read 'op://Vault/github/credential' \
    | msks secret mint --name github_api \
        --dest api.github.com --secret-file -
minted */github_api for api.github.com
sentinel: mskssec2_9Jm3...kQ
  • The value is the operator's own secret: a file's contents, or stdin for --secret-file - (the example pipes it from a password manager, so it never touches disk). It is whitespace-stripped at both ends (a UTF-8 BOM drops too) and never accepted as a command-line argument, because argv lands in process lists and shell history. It is stored as ciphertext blobs and never echoed; the mint reply carries the sentinel, not the value. The client and the daemon may sit on different machines: the value rides the token-authenticated TLS API. The mint stores ciphertext only: one age-encrypted blob per covering workspace's identity key, and the plaintext rides the daemon's unlock cache until its window ends (below). A covering workspace whose key is outside the store's capable set (ssh-ed25519, ssh-rsa) — or that carries no identity key at all — refuses the mint with the workspace named.
  • The mint above carries no workspace target: it is the daemon-wide mint — one row, one mskssec2_ sentinel, valid on every workspace's tap toward the minted destinations. A workspace created later is covered from its first boot; coverage acts as policy, not as a snapshot of the workspace set at mint time.
  • --workspace scopes the mint: --workspace myws (exactly today's single-workspace placeholder), or a comma list (--workspace ci,deploy) for several — one row, one mskssec1_ sentinel, one coverage set either way. A mint produces a single sentinel whether it targets every workspace or a set of one or several; the same label can live on the daemon-wide row and on scoped rows beside it, one row per label per coverage set.
  • --dest repeats: an exact host (api.github.com) binds the swap to that host; a suffix (.github.com) binds it to every host under that domain. A placeholder carries one coverage set, one allowlist, one secret — per-workspace destination scoping stays available by minting a scoped placeholder instead.
  • --ttl SECONDS gives the placeholder a lifetime; without it the placeholder lives until revoked. msks secret renew extends a lifetime in place — the sentinel never changes and nothing is re-delivered.
  • The CLI prints the sentinel at mint. The listing and the audit omit it (msks secret ls never shows one). The daemon serves the row's sentinel back on demand over the token-authenticated API, through the msks tui secrets page's Enter-on-a-row panel, so a sentinel that scrolled away reads back from the daemon. The operator's value alone is never echoed back.

The msks tui secrets page mints too. c opens the form: name, repeatable destinations, coverage (the daemon-wide row, or a multi-select of the workspaces the tree's own list offers), the lifetime (unbounded by default, an hour to thirty days beside it), and the value. The value is a masked field the operator types or pastes into, so the form takes the secret's bytes directly. The submit checks the store (msks secret check's endpoint) before it mints. A refusal (a store that cannot answer writes, a name collision on the chosen coverage set, a blank value) names itself on the form, with the fields kept for a retry. A successful mint answers with the sentinel's panel. The sentinel opens masked, its prefix naming the reach and its body rendering as bullets. A Show action reveals and hides it again. An OSC 52 clipboard copy works over ssh too, where the terminal honors it, and needs no reveal. The display ends with the panel. Enter (or a click) on a listing row opens the same panel again. The page fetches the row's sentinel from the daemon and shows it, so the sentinel reads back on demand. The panel is one surface for the row, opened at mint and after. Closing the panel clears its text.

msks secret revoke --name github_api retires the daemon-wide row of that label everywhere at once. msks secret revoke --workspace myws --name github_api retires the scoped row whose coverage is exactly that set. The targeting mirrors the mint's, and msks secret renew selects its row the same way. msks secret ls lists every row with its coverage (* for the daemon-wide row, the workspace list scoped).

A workspace can exempt itself from daemon-wide placeholders
msks create --secret-coverage scoped (or msks secret coverage myws scoped later) means only placeholders minted directly at that workspace arm it. A daemon-wide sentinel used from a scoped workspace publishes an off-allowlist sighting and swaps nothing. Every new workspace accepts daemon-wide coverage unless its own setting says scoped, and flipping the setting back to all arms it with any live daemon-wide row.

The unlock window

The daemon holds no plaintext of its own: a mint leaves only the per-recipient ciphertext blobs, and the plaintext returns through an operator's unlock. The client decrypts the blobs with the workspace identity's private half and posts the values back over the token-authenticated API (POST /api/v1/secrets/unlock, after GET /api/v1/secrets/pending serves the ciphertext and opens the session window the unlock requires).

The client's half

The unlock reads the identity key file the client already uses for ssh: identity_file (the client config, ~/.config/msks/msks.yaml) or MSKSC_IDENTITY_FILE. It decrypts only the blobs that key opens. Possession of the private key is the authority for a value, proven by the unwrap itself. An operator without the key receives ciphertext, uploads nothing, and the daemon stays cold for those rows; two operators keyed to different workspaces each unlock their own.

An agent-held key cannot unlock: ssh-agent exposes signing, and the unwrap needs the private key material directly, so the unlock reads the file where it lives (ssh login still resolves through the agent). A passphrase-encrypted key file prompts once per session — the passphrase loads the identity and every row then decrypts through it.

Three surfaces run the flow:

  • The TUI at start. The tree's mount offers the unlock against the daemon's locked rows: a warm cache stays silent, a cold one with the key present unlocks itself, and the passphrase prompt (when the file is encrypted) is the modal the session opens with. The secrets page's u action runs the same flow on demand, so a window that lapsed mid-session heals the moment it is seen. The page's STATE column (locked / open) and msks secret ls's state column carry the lock state row by row.
  • msks secret unlock — the scripting surface: fetch pending, decrypt, upload, and print one line per unlocked row plus the rows the key could not open. Rows whose ciphertext failed to open name the re-mint they need; rows with no stanza for this key are another operator's and stay ciphertext. The command without a configured identity names identity_file and exits nonzero.
  • A cold refusal is a named condition. The daemon's refusals surface verbatim — the locked swap error (423), and on a zero-window daemon the 422 that answers an unlock while no operator websocket stands. The websocket lives on a workspace page or the secrets page's audit view: open either (the TUI holds none at its root list) and the same flow works — never a retry loop.

The window is MSKSD_SECRET_STORE_UNLOCK_TIMEOUT_S (default 86400 seconds — one day), the ssh-add -t pattern. The timer resets on an unlock POST itself — an operator action carrying plaintext — and on nothing else: websocket traffic, keepalives, and scheduled tasks never extend it. Zero holds plaintext only while at least one operator websocket stays connected — the last disconnect drops it, and an unlock over HTTP alone holds nothing past the reply. Expiry flushes the cache, observable through the row fetch and the pending list.

Until an unlock stands, the interceptor's swap path refuses a carried sentinel with a named locked error: HTTP 423, msks: locked: no operator session within the unlock window. The request never reaches the origin, and the sentinel never passes through as a value. An unlock also re-encrypts the row's blobs to its current recipient set, so a coverage edit or a workspace key rotation heals at the next operator session.

Where the real secret lives

A mint stores ciphertext blobs in the daemon's database
one age file per covering workspace identity key, wrapped to that workspace's ssh public half — the same file format the reference age tool writes. The daemon holds no key that opens them: only the workspace identity's private half (kept on the operator's client machine) unwraps a blob, which is the whole design — the client decrypts, the daemon swaps. Values exist in plaintext only in the operator's memory, the token-authenticated request, and the daemon's unlock cache for the window.

The per-recipient blobs are the store. The workspace identity keys decide who can read: back up the client machine's identity files alongside the daemon's database — a database copy alone carries ciphertext nobody can open, and a lost identity file means re-minting the values.

Worked example

# the unlock window: how long unlocked plaintext lives in the
# daemon after the unlock POST that supplied it
secret_store_unlock_timeout_s: 86400.0

The store itself needs no configuration: the blobs live in the daemon's database beside the placeholder rows, and the only secret-bearing setting is the window above.

At-rest encryption

Every minted value is ciphertext at rest — a per-recipient age blob in the database — and no configuration choice changes that posture. The ssh identities, the database, and the token hashes keep their own house postures; the blobs name the secrets' one, and the blob's recipient list is exactly the row's coverage.

Moving a daemon to another host means moving its database (the blobs ride the placeholder rows) while the daemon is stopped.

The audit trail

Mint, revoke, expiry, and unlock append a row to the daemon database: the placeholder's coverage (the daemon-wide row, or the workspace list), name, destination allowlist, and a timestamp (msks secret ls lists placeholders; the audit view is /api/v1/secrets/audit). Expiry fires from the status watcher's periodic sweep, which publishes a secret.expiry event and re-evaluates the covered workspaces' redirects in the same pass. The last placeholder's retirement stands the interception down, and one revoke or expiry retires the whole row everywhere at once. The secret value and the sentinel appear nowhere in the audit or the events. The value rides its mint request alone and is never echoed. The sentinel appears only in the mint reply and the on-demand row fetch, never in the audit or the event stream.