Test age terminal output before restoring an encrypted file

John Burns

A restore command can succeed cryptographically and still put bytes in the wrong place. That is easy to miss with encrypted archives because a command such as age -d writes plaintext to standard output when no output file is named. If that standard output is a terminal, a binary archive, database export, or image is not useful to read and can corrupt the display or escape sequence state of the terminal emulator.

age 1.3.2 adds a useful boundary for that case: it refuses to write non-UTF-8 plaintext to a terminal unless the operator explicitly asks for standard output with -o -. The refusal is not a restore failure to work around casually. It is a prompt to decide whether the destination should be a named file, a controlled pipe, or an intentionally forced terminal stream.

This guide tests the boundary with age 1.3.2 on Linux AMD64 in a disposable directory. A generated key encrypted a five-byte file containing a non-UTF-8 byte and a NUL byte. Decryption to a pseudo-terminal stopped with exit status 1 and age: error: refusing to output binary to the terminal. The same decrypt command with -o - exited 0; the captured stream contained the original bytes. That distinction is the operational result: an omitted destination is protected at an interactive terminal, while -o - records an explicit choice to bypass that protection.

The test uses a newly generated key and synthetic bytes. Do not put an existing backup identity, restore archive, customer export, or production destination into a command transcript. The same destination decision applies to those files, but their decryption must follow the storage and access procedure that owns them.

Treat standard output as a destination

age encrypts or decrypts one input to one output. Its manual page defines standard output as the default when -o is omitted. That is convenient when a command is deliberately part of a pipeline, but it makes the surrounding shell important. A terminal is not the same destination as a file descriptor connected to a reviewed program.

Start a restore with three questions:

  • Is the plaintext meant to become a named file that another process will consume later?
  • Is the plaintext intentionally flowing to a single command, such as a format validator or an archive reader?
  • Is standard output really the terminal, and if so, is the content safe to display?

For ordinary binary restores, name the destination. A restrictive umask avoids creating a broadly readable file before the restore procedure can set its intended ownership and permissions.

umask 077
age -d -i <identity-file> -o <restore-file> <encrypted-file>

-i identifies the private identity file used for decryption. Keep it outside the repository, shell history, shared temporary directories, and CI artifacts. -o is more than a convenience here: it makes the destination visible in a change record and prevents the shell from selecting standard output implicitly. age will overwrite an existing -o destination, so choose a new path for a first restore check or perform the overwrite only after the recovery procedure has authorized it.

A pipe can be appropriate when the next command is part of the restore design. For example, an archive listing can inspect structure without extracting files:

age -d -i <identity-file> <archive>.tar.age | tar -tf -

This does not create an archive on disk, but it does give tar access to its plaintext. Treat every program in the pipe as part of the sensitive-data boundary. Do not replace tar -tf - with an extraction command until paths, ownership behavior, and target directory policy have been reviewed. A pipeline also changes error handling: use a shell with pipefail enabled when a later step must not conceal an age failure.

set -o pipefail
age -d -i <identity-file> <archive>.tar.age | tar -tf -

The successful exit status then depends on both age and the archive reader. That is useful for a validation step, but it is still not proof that a restore belongs on a production host.

Reproduce the terminal guard safely

The following test creates a new, throwaway identity and a tiny binary plaintext. It does not contact a network service or change a system configuration. Run it in an empty directory with a current age installation. The generated identity is test material; remove the directory when the check is complete.

mkdir age-terminal-test
cd age-terminal-test
umask 077

age-keygen -o identity.txt
recipient=$(sed -n 's/^# public key: //p' identity.txt)
printf '\377\000safe\n' > plaintext.bin

age -r "$recipient" -o payload.age plaintext.bin

age-keygen writes the private identity to identity.txt and includes its public recipient as a comment. The sed expression selects that public value without printing the secret key. plaintext.bin begins with 0xff, which is not valid UTF-8, followed by a NUL byte and readable text. The encrypted payload.age is safe to store only to the extent that the temporary identity is protected; keep both within the private test directory.

Run the default decrypt directly from an interactive terminal:

age -d -i identity.txt payload.age

With age 1.3.2, the expected result is a nonzero exit and a message similar to this:

age: error: refusing to output binary to the terminal
age: hint: force anyway with "-o -"

Do not add || true to a real restore wrapper merely because this diagnostic is expected in the lab. The nonzero status is the signal that an unspecified standard-output destination was unsafe for these bytes. In an automated job where standard output is redirected to a file or pipe, the same invocation may not be attached to a terminal and therefore tests a different boundary.

Make a deliberate destination choice

For the normal restore path, use a named output and compare it with a known test input before trusting the procedure:

age -d -i identity.txt -o recovered.bin payload.age
cmp plaintext.bin recovered.bin

Both commands should exit zero. cmp is deliberately simple: it proves the recovered synthetic bytes match the original test bytes exactly. It does not authenticate an arbitrary production file beyond what age already authenticates during decryption, and it does not replace an application-specific integrity check.

The -o - form exists for the smaller number of cases where standard output is genuinely intended. It must be explicit:

age -d -i <identity-file> -o - <encrypted-file> | <approved-consumer>

Avoid using -o - merely to silence the terminal error. It can send binary data to a terminal if the pipe is removed or the command is copied incorrectly. Put the consumer on the same command line, enable pipefail where the result controls a later action, and keep the consumer narrow. A shell redirect to a file can also be valid, but -o <restore-file> is easier for a reviewer to recognize and avoids relying on redirection placement.

The v1.3.2 change is especially useful during incident recovery, when operators may copy a familiar command into an unfamiliar shell. It prevents an accidental terminal dump; it does not decide where sensitive plaintext should be stored, who may read it, or whether the selected identity is authorized for the archive. Those are restore-runbook decisions that should exist before an outage.

Remove the disposable test material after recording the result:

cd ..
rm -rf age-terminal-test

Do not use a recursive cleanup command against a directory that might contain a real identity or restore output. In a runbook, use a uniquely created temporary directory and verify its path before deletion.

age 1.3.2 gives interactive restores a narrow but useful guard. A binary decrypt with no -o stops at a terminal; a named output or a deliberate -o - makes the destination choice visible. Test that distinction before the command is needed for a real encrypted restore.

Sources