niwaniwa0.1.2
saru-id/niwa
niwaniwa

Secrets

A secret is a typed handle, not a value. Where niwa looks for it, why it never prints, and what sealing protects.

A secret is the one value a config must use and must never hold. niwa handles that with a type: niwa.secret returns a handle, and the value behind it appears only inside the apply that writes it.

luau
local token = niwa.secret("github-token")

niwa.file("~/.netrc", {
  content = niwa.render("machine api.github.com login {user} password {token}", {
    user = niwa.machine.owner,
    token = token,
  }),
})

Typed and opaque

The handle carries the secret's name and nothing else. It resolves at apply time, never at plan time, and never into the config.

niwa.render knows which of its values are secret, which is what lets a rendered file be planned without being read. The spec niwa stores holds the template and, per placeholder, the plain value or the secret's name.

Where niwa looks

Resolution is ordered and explicit. niwa.secret(name) searches the macOS keychain first, under the service name niwa, then secrets/<name>.age in the config repo, then an external manager when one is configured.

The order is a default, not a rule. niwa.secret also takes a table, and from in that table names one place and searches nowhere else: niwa.secret { name = "github-token", from = "keychain" }.

A missing secret fails the plan, not the apply, and the error lists the places niwa searched. Before 1.0.0 two of the three answer: the keychain and the repo. No external manager is configured, and the error says where it looked rather than implying a third.

A sealed file that exists and will not open is a different problem, and the message says so: it points at the sealing key, and at niwa seal-key restore.

A secret never prints

One rule, held everywhere the value could otherwise surface.

  • The plan shows a rendered file's shape, never its content. Full file diffs are drawn for files whose bytes are knowable before the run, and a rendered file stays a name.
  • The journal records that the bytes changed, by digest. It never records a value.
  • The archive of a secret-bearing file is sealed, on the apply path and on the undo path alike.
  • niwa pull refuses rendered files by name. They are one way, because reading one back would mean reading the secret out of it.

Files rendered from secrets are private

When a file's content comes from a render that used a secret and the config declares no mode, niwa writes the file 0600. A resolved secret is never left world-readable by omission. A declared mode is obeyed as written, and a mode that later differs reads as drift.

Sealing, and the key

This machine's sealing key is ~/.local/state/niwa/seal.key. niwa generates it on first use, with owner-only permissions set before the file has the name anything else could open it by.

secrets/<name>.age in the repo is sealed to that key, and so is every archive of a secret-bearing file. The repo holds ciphertext; the machine holds the key.

niwa seal-key backup escrows the key at secrets/seal-key.age, passphrase-encrypted in process before anything touches disk, and niwa seal-key restore reads it back on another machine. Losing every machine then costs one passphrase rather than the files.

The gate

niwa check scans the config repo, files/ included, for credentials that should not be committed. Detection is patterns plus entropy, so a credential shape nobody listed is still caught.

Four things are skipped. secrets/ holds ciphertext by design. niwa.lock is full of hashes, which are exactly the kind of string an entropy check exists to catch. .luaurc and .git are machinery. A dot-named file under files/ is scanned like any other source.

The same gate runs on every niwa pull, because the late edit that exported a token has to be caught before it becomes a commit. The offending lines are named and held back, and the rest of the pull proceeds.

niwa doctor resolves every secret the config asked for and names the ones it cannot find. doctor --deep checks the sealed archives decrypt.

Next

View as markdown

↑↓ navigate · enter opens · esc closes