Skip to content

feat!: read a value or a secret at the keyboard, and open an encrypted key with one - #214

Merged
lomigmegard merged 2 commits into
mainfrom
feat/enter-secret
Sep 24, 2026
Merged

lomigmegard merged 2 commits into
mainfrom
feat/enter-secret

Conversation

@lomigmegard

Copy link
Copy Markdown
Contributor

Closes #203.

Two actions read a value at the keyboard. enter_value makes an artifact the transcript carries, such as a serial number read off a device. enter_secret makes a secret artifact it never carries: echo is off, the artifact is wiped from memory when the run ends, and the transcript records that a secret was entered at the step and nothing derived from it. The action name is what decides, so a passphrase step reads as one in the YAML and in the printed script.

unlock_the_escrow_key:
  action: enter_secret
  with:
    message: "Passphrase for the escrow key"
  creates: escrow_passphrase

install_the_escrow_key:
  action: import_key
  backend: openssl
  reads:
    key_material: ${artifact.escrow_key}
    passphrase: ${artifact.escrow_passphrase}
  with:
    algorithm: RSA-4096
  creates: escrow_keypair

Both actions take format: (text, digits, alphanumeric, hex, base64, or { pattern: "..." }) and a length. The rule is stated in the prompt before typing, and a refusal names the rule, never the value. For an encoding the artifact is the decoded bytes, so DE AD BE EF and deadbeef give the same artifact. The PIV PIN prompt uses the same check.

import_key reads an optional passphrase through reads: and opens an encrypted PEM (PKCS#8 or traditional) or an encrypted PKCS#8 DER. Only a secret artifact is accepted as the passphrase. A passphrase given for material that is not encrypted is refused.

Breaking changes:

  • KeyStoreBackend::import_key takes a passphrase: Option<&[u8]>.
  • rite check refuses a reads: key that the action does not read, as it already did for with:.
  • oral_readback no longer accepts sensitive, which had no effect.

The headless driver answers a formatted prompt with a value that fits the format, so a dry run goes through PIN and passphrase steps. The import_key showcase gains a section that installs an escrow key from an encrypted PEM. User documentation is in docs/typed-entry.md.

…d key with one

`enter_value` and `enter_secret` are one implementation under two names, and
the name is the claim. The first makes an artifact the transcript carries: a
serial number read off a device, an address shown on a screen. The second
makes a secret artifact it never does: echo is off, the artifact is wiped from
memory when the run ends, and the transcript records that a secret was entered
at this step and nothing derived from it. Nothing in the block decides which,
so a passphrase step reads as one in the YAML and in the printed script, and a
flag that could be left off is not what stands between a secret and the
transcript.

Both say what kind of value they ask for with one field, `format:`: text,
digits, alphanumeric, hex, base64, or `{ pattern: "..." }`, the same
scalar-or-map shape `retry:` takes. Each format has one canonical
representation and that is the artifact. For a text format it is the text as
typed. For an encoding it is the decoded bytes: the string was transport, so
grouping and case are dropped on the way in and a component typed as
`DE AD BE EF` makes the same artifact as `deadbeef`. What was typed stays on
the prompt fact of an `enter_value` step, which is the evidence. A length
counts the format's own unit, characters for text and bytes for an encoding.

`ValidatorSpec` gains `Format` for the rule, its `Regex` variant is
implemented rather than refused, and `Prompt::Secret` carries a validator as
`Prompt::Text` does. The rule is built in one place, `EntryShape::validator`,
and decoding lives in the model beside the check, so the prompt loop,
`rite check` and the step share one implementation; the copies the check
makes are wiped with the call, since the value may be a secret. The rule is
stated in the label before typing rather than only after a refusal, and a
refusal names the rule and never the value. The PIV PIN prompt takes the same
route with the six to eight characters it asks for, and the step checks the
six to eight bytes SP 800-73 gives a PIN before anything reaches the card, so
a typo costs a retype rather than one of the card's few attempts.

`import_key` reads the secret as `passphrase:` beside the material, which is
how a key that arrives on sealed media is imported in the room where its
passphrase holder stands, with no decrypted copy on any disk. Through `reads:`
and never `with:`, since a `with:` value is copied into unwiped JSON that a
`BackendOperation` fact may record; a `reads:` input is borrowed from the
store. Only a secret artifact is accepted there, a passphrase read from a
material file would be a passphrase that sat on a disk, and a refusal names
the artifact's kind rather than showing it. A reference carrying a property
is refused, since a secret has none. `ReadsContract` gains the optional slot
this needs, and the resolver now refuses a `reads:` key an action never looks
at, as it already refused an unknown `with:` key; `issue_certificate`
declares its optional `issuer_cert` accordingly.

`KeyStoreBackend::import_key` takes the passphrase. The OpenSSL backend opens
an encrypted PEM in either encoding through the callback it already used to
detect one, and encrypted PKCS#8 DER by its own structure. A passphrase given
for material that is not encrypted is refused: the step records that a
passphrase was supplied, and that record has to mean it was used.

The headless driver answers a formatted prompt with the format's own stand-in
at its shortest length, so a dry run walks through a PIN or a passphrase step
as it walks through any other; above 64 KiB, or for a pattern, it declines,
which fails fast rather than answering with something the rule refuses again
without end.

`oral_readback` loses its `sensitive` flag, which nothing read and which had
nothing to hide, since the step records no value in any fact.

The `import_key` showcase gains a section that installs an escrow keypair
from an encrypted PEM, with the media serial read by `enter_value` and the
passphrase by `enter_secret`. The fixture is encrypted under the headless
driver's stand-in, so the example completes in a dry run.
@lomigmegard lomigmegard self-assigned this Sep 22, 2026
Comment thread crates/rite-runtime/src/reporter.rs Fixed
@lomigmegard
lomigmegard merged commit d1fd186 into main Sep 24, 2026
11 checks passed
@lomigmegard
lomigmegard deleted the feat/enter-secret branch September 24, 2026 20:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No way to supply a passphrase from a ceremony

2 participants