§ How-to · Operate

Store secrets in the sealed state

Keep a secret in the sealed state, hand it to a provider with pravo.#Secret, rotate it with pravo.#SecretVersion, and see who set each version.

Time
20 min
Level
intermediate
Needs
A sealed state in a bucket, as in Seal the state to a list of readers, and Keep secrets out of the state for what a run may do with a secret. This page uses the in-memory bucket from pravo's source.
Done when
pravo secret list shows the secret, a provider receives its value, and neither the bucket nor a plan file holds a copy of it.

View as Markdown

On this page

A secret is a named value that the sealed state keeps for its readers: a database password, an API token, a private key. Each time you set it, it gains a version. A run reads it with pravo.#Secret and hands it to a provider, and the value reaches no record of the state, no plan and no log. The bucket holds the value sealed to the same readers as the records, and the transactions of the state say who set each version.

Everyone who can read the state can read every secret in it. A secret for other readers belongs in another state, with another list of readers.

Note

Every command below runs against tools/fakes3, the in-memory S3 server in pravo's source, on port 9000, and the three people are three directories on one machine. The page uses the tls provider, which pravo init downloads.

1. Seal a state with roles

Seal the state to a list of readers explains this step. Here it is in short: three people, each with an identity, listed in .pravo/access/readers.txt with a role. Ada is an admin, Dan a writer and Eve a reader. The roles decide who may set a secret, who may remove one and who may only read.

main.cue
package vault

$settings: state: {
	backend:    "s3"
	bucket:     "northwind-state"
	prefix:     "prod"
	region:     "eu-west-1"
	endpoint:   "http://127.0.0.1:9000"
	path_style: true
}
terminal
$mkdir -p people .pravo/access
$pravo id new --name ada > /dev/null
$for p in dan eve; do PRAVO_CONFIG_DIR=people/$p pravo id new --name $p > /dev/null; done
${
$ pravo id show | tail -n 1 | sed 's/^ *//; s/$/ role:admin/'
$ PRAVO_CONFIG_DIR=people/dan pravo id show | tail -n 1 | sed 's/^ *//; s/$/ role:writer/'
$ PRAVO_CONFIG_DIR=people/eve pravo id show | tail -n 1 | sed 's/^ *//; s/$/ role:reader/'
$} > .pravo/access/readers.txt
$cat .pravo/access/readers.txt
ada ssh-ed25519 <ssh-key> x25519:<key> role:admin
dan ssh-ed25519 <ssh-key> x25519:<key> role:writer
eve ssh-ed25519 <ssh-key> x25519:<key> role:reader

Seal the state, then lock the roles in the state, so that a reader cannot make themselves an admin by editing the file in their checkout.

terminal
$pravo init > /dev/null
$pravo protect init --encrypt --yes | tail -n 7
$pravo protect roles --yes
The state is sealed to ada dan eve.

Commit the readers file. Writers read the list from git:
  .pravo/access/readers.txt
No recovery recipient. If every reader loses their key, the state cannot be read.
Add one with: pravo protect rewrap --recovery 3/5
  ada: admin
  dan: writer
  eve: reader
Stored the roles as version 1.
Added roles-genesis <sha256> to .pravo/access/readers.txt. Commit it: until it is in the repository the roles of the file decide.

Commit .pravo/access/readers.txt with the roles-genesis line. A state that holds secrets is in format 5, which earlier versions of pravo refuse to open. The first pravo secret set raises the format, and a state with no secret stays as it is.

2. Set a secret

A writer sets a secret. The value is never an argument, so that the shell history and the list of processes do not hold it. secret set reads it from standard input, from a file with --file, from an environment variable with --from-env, or on a terminal from a prompt that does not show it.

terminal
$printf %s 'correct-horse-battery' | PRAVO_CONFIG_DIR=people/dan pravo secret set prod/db_password --note "first password"
$pravo secret list
Set prod/db_password, version 1.
NAME              VERSION  SET        BY
prod/db_password        1  <age> ago  dan

A name is words of letters, digits, dots, dashes and underscores, with a slash between parts. list shows no value and needs no key. Setting a name again writes a new version and keeps the old ones.

Eve is a reader. She cannot set a secret:

terminalexit 5
$printf %s 'x' | PRAVO_CONFIG_DIR=people/eve pravo secret set prod/db_password
Error: your identity is a reader in .pravo/access/readers.txt, and a writer is needed to change the state.

3. Read a secret

Every reader can read. get prints the bare value, so that a script can use it.

terminal
$PRAVO_CONFIG_DIR=people/eve pravo secret get prod/db_password
correct-horse-battery

The bucket holds the name, the number of the version and the time in the clear, and the value sealed. Anyone who can list the bucket sees that a secret exists and when it changed. Nobody without a key of a reader sees what it holds.

terminal
$curl -s "$FAKES3_URL/northwind-state?list-type=2&prefix=prod/secret/" | grep -o '<Key>[^<]*' | sed 's/<Key>prod\///'
$for k in $(curl -s "$FAKES3_URL/northwind-state?list-type=2&prefix=prod/" | grep -o '<Key>[^<]*' | sed 's/<Key>//'); do
$ curl -s "$FAKES3_URL/northwind-state/$k"
$done | grep -c correct-horse || true
secret/prod%002Fdb_password@00000001.json
0

4. Hand a secret to a provider

A run reads a secret as pravo.#Secret. It is an ephemeral value, like pravo.#SecretEnv in Keep secrets out of the state: it may go into the configuration of a provider, into a write-only attribute and into another ephemeral resource, and nowhere that is recorded.

The tls provider signs a certificate with a private key that it receives in private_key_pem_wo, a write-only attribute. The provider never stores it, so it cannot see that the key changed. Its private_key_pem_wo_version is the number that tells it. pravo.#SecretVersion is that number: the version of the secret that the run reads, as an ordinary value.

secrets.cue
package vault

import (
	"pravo.dev/pravo"
	tlsp "pravo.dev/providers/hashicorp/tls"
)

$tls: tlsp.#Provider

signing_key: pravo.#Secret & {name: "prod/signing_key"}
signing_rev: pravo.#SecretVersion & {name: "prod/signing_key"}

cert: $tls.#Resource.tls_self_signed_cert & {
	private_key_pem_wo:         signing_key.value
	private_key_pem_wo_version: signing_rev.version
	subject: [{common_name: "example.test"}]
	validity_period_hours: 24
	allowed_uses: ["digital_signature"]
}

The secret does not exist yet. A run stops before any provider starts, and says what to run.

terminalexit 1
$pravo init > /dev/null
$PRAVO_CONFIG_DIR=people/dan pravo plan
Error: signing_key: the state has no secret named prod/signing_key. Set it with: pravo secret set prod/signing_key

Dan sets the key. Any PEM private key works; this block makes one with openssl and passes it on standard input, so no file holds it.

terminal
$openssl ecparam -name prime256v1 -genkey -noout | PRAVO_CONFIG_DIR=people/dan pravo secret set prod/signing_key --note "issued for the example"
$PRAVO_CONFIG_DIR=people/dan pravo plan -out change.plan | grep -E '^(Plan|Saved)'
$PRAVO_CONFIG_DIR=people/dan pravo apply change.plan | grep '^Apply complete'
Set prod/signing_key, version 1.
Plan: 1 to add, 0 to change, 0 to destroy.
Saved the plan to change.plan. To apply exactly these actions, run:
Apply complete! Resources: 1 added, 0 changed, 0 destroyed. [txn <txn>]

Look for the key in the state, in the plan file and in the bucket:

terminal
$PRAVO_CONFIG_DIR=people/dan pravo state show cert | grep private_key_pem_wo
$grep -c 'PRIVATE KEY' change.plan || true
$for k in $(curl -s "$FAKES3_URL/northwind-state?list-type=2&prefix=prod/" | grep -o '<Key>[^<]*' | sed 's/<Key>//'); do
$ curl -s "$FAKES3_URL/northwind-state/$k"
$done | grep -c 'PRIVATE KEY' || true
	private_key_pem_wo:         null
	private_key_pem_wo_version: 1
0
0

The record holds null for the write-only attribute and the number of the version. The plan file is sealed like the state, and the bucket holds the certificate and no key.

5. Break it: a secret in a recorded place

An output is recorded in the state, so a secret may not be an output.

leak.cuerejected
package vault

import "pravo.dev/pravo"

shown: pravo.#Output & {value: signing_key.value}
terminalexit 1
$PRAVO_CONFIG_DIR=people/dan pravo plan
Error: output shown references signing_key; outputs are recorded in state, so ephemeral values cannot be used in them

An ordinary attribute is refused the same way, and the check runs before any provider does. pravo.#SecretVersion may go anywhere, since a number is not secret.

terminal
$rm leak.cue

6. Rotate a secret

Setting the name again is the rotation. The new version is the one that the next run reads, and the number of the version changes the attribute that tells the provider to take the new value.

terminal
$openssl ecparam -name prime256v1 -genkey -noout | PRAVO_CONFIG_DIR=people/dan pravo secret set prod/signing_key --note "rotated after the audit"
$PRAVO_CONFIG_DIR=people/dan pravo plan | grep -E 'private_key_pem_wo_version|^Plan'
Set prod/signing_key, version 2.
  ~     private_key_pem_wo_version: 1 -> 2 # forces replacement
Plan: 1 to add, 0 to change, 1 to destroy.

The certificate is replaced, and nothing else changes in the plan. Apply it.

terminal
$PRAVO_CONFIG_DIR=people/dan pravo apply -auto-approve | grep '^Apply complete'
Apply complete! Resources: 1 added, 0 changed, 1 destroyed. [txn <txn>]

A pravo.#Secret reads the latest version at every plan and apply. To hold a node at one version, write version: 1 in it. The nodes of a run that read the same secret without a version read the same one, so a version that is set while a run goes changes the next run and not this one.

A plan file and the apply of it are two runs, and the apply reads the secret again. When a new version was set in between and the specification reads its number, the provider's plan differs from the saved one: the apply stops with exit code 3, as for any stale plan, and you make the plan again.

7. See who set which version

secret history lists the versions of a secret, newest first, with when each was set, who set it and the note, which is sealed with the value and so is shown to readers only.

terminal
$pravo secret history prod/signing_key
prod/signing_key
  v2    <time>  dan          value
        rotated after the audit
  v1    <time>  dan          value
        issued for the example

Every secret command is a transaction of the state, signed by the identity that ran it, as apply is. state log shows them next to the applies, and protect status counts the versions that a signed transaction of a writer accounts for.

terminal
$pravo state log | grep -B1 -A3 'pravo secret set prod/signing_key'
$pravo protect status | grep -E '^(Roles|Secrets)'
<txn>  secret   applied   <time>
    pravo secret set prod/signing_key
    signed by dan (verified)
    set prod/signing_key, version 2
    set      secret:prod/signing_key  v1 -> v2
--
<txn>  secret   applied   <time>
    pravo secret set prod/signing_key
    signed by dan (verified)
    set prod/signing_key, version 1
    set      secret:prod/signing_key  v1
Roles        on · 1 admin · 1 writer · 1 reader · pinned, version 1 in the state · you are an admin
Secrets      2 secrets · 3 versions · 3 bound to a transaction of a writer

A version that no signed transaction of a writer accounts for is refused when a command reads it, with exit code 7. That is what stops a reader, who holds the data key, from putting a value in the bucket that a writer never set.

8. Use a secret in a command

pravo secret exec runs a command with secrets in its environment. The values are not written to a file, only the command sees them, and the exit code is that of the command. Everything after -- belongs to the command.

terminal
$PRAVO_CONFIG_DIR=people/eve pravo secret exec --env PGPASSWORD=prod/db_password -- sh -c 'echo "PGPASSWORD has ${#PGPASSWORD} characters"'
PGPASSWORD has 21 characters

9. Remove a secret and forget its old values

A writer sets, and an admin removes. Dan cannot remove the password, and Ada can.

terminalexit 5
$PRAVO_CONFIG_DIR=people/dan pravo secret rm prod/db_password --yes
Error: your identity is a writer in .pravo/access/readers.txt, and an admin is needed to remove a secret.
terminal
$pravo secret rm prod/db_password --yes
$pravo secret list
Removed prod/db_password, version 2. Its values stay in the state until pravo secret purge prod/db_password --keep 0.
NAME              VERSION  SET        BY
prod/db_password        2  <age> ago  ada  removed
prod/signing_key        2  <age> ago  dan

A removal is a version of its own. The values stay in the state until an admin forgets them, so that the history shows what was there. A run that reads the removed secret stops before it changes anything.

uses_db.cue
package vault

import "pravo.dev/pravo"

db: pravo.#Secret & {name: "prod/db_password"}
terminalexit 1
$PRAVO_CONFIG_DIR=people/dan pravo plan
Error: db: the secret prod/db_password was removed. Set it again with: pravo secret set prod/db_password
terminal
$rm uses_db.cue

secret purge forgets the old values of a secret and keeps the newest ones. The versions stay in the history, without their values.

terminal
$pravo secret purge prod/signing_key --yes
$pravo secret history prod/signing_key
$PRAVO_CONFIG_DIR=people/eve pravo secret get prod/signing_key --version 1; echo "exit=$?"
Forgot 1 value of prod/signing_key.
prod/signing_key
  v2    <time>  dan          value
        rotated after the audit
  v1    <time>  dan          purged

Error: prod/signing_key version 1: the value of this version was purged
exit=1

A purge removes the values from the state. A bucket that keeps earlier versions of its objects keeps these too, and everyone who could read the secret has read it. A value that leaked needs a new one.

10. When a reader leaves

Removing a reader from the file stops future versions from being sealed to them. It cannot take back what they read, and every secret they could open is known to them. Step 10 of sealing the state shows the removal. For the secrets, four things follow it.

  1. Remove the line, commit the file and run pravo protect rewrap. Versions written from now on are sealed to the readers who are left.
  2. Replace each secret the person could read at its source, such as the database or the provider of the token, and set the new value with pravo secret set NAME.
  3. Forget the old values with pravo secret purge NAME.
  4. Run pravo protect rewrap --all and pravo protect gc. The first seals what is left under the new key, and the second drops the group the person was in.

pravo secret history NAME shows the versions that existed while they were a reader.

11. See the secrets in the viewer

pravo serve has a Secrets section, opened with e. It lists each secret with its latest version, who set it and when, and which nodes of the specification read it. It shows no value: the server holds no key for the values, and it reads the part of the objects that is in the clear and the transactions that wrote them.

It also says what is wrong. A secret that the specification reads and the state does not hold, a version that no signed transaction of a writer accounts for and a state that is not sealed are flagged, worst first, each with the command that mends it to copy. A state with nothing to flag shows one line, all clear. The tab carries a number only when something needs action. The Secrets section lists the parts.

The nodes that read a secret are in the graph, in the first column with the environment variables. A node's inspector says that its value is read from the sealed state and is never recorded.

What next