The age file encryption format follows the "one well-oiled joint" design
philosophy. The mechanism for extensibility (within a particular format version)
is the recipient stanzas within the age header: file keys can be wrapped in any
number of ways, and age clients are required to ignore stanzas that they do not
understand. The core APIs that exercise this mechanism are:
Custom age clients can bundle support for the exact recipient or identity types
required for a particular environment. However, a general plugin system will
enable an ecosystem of common third-party recipient types to grow.
The plugin system specified in this document provides a mechanism for exposing
the core APIs across process boundaries. It has two main components:
With this composable design, developers can implement a recipient or identity
that they would use directly with an age library, and then also deploy it as a
plugin binary.
age plugins are identified by an arbitrary case-insensitive string NAME
. This
string is used in three places:
age1name
AGE-PLUGIN-NAME-
(uppercase).age-plugin-name
.Users interact with age clients by providing either recipients for file
encryption, or identities for file decryption. When a plugin recipient or
identity is provided, the age client searches the PATH
for a binary with the
corresponding plugin name.
Recipient stanza types are not required to be correlated to specific plugin
names. When decrypting, age clients will pass all recipient stanzas to every
connected plugin. Plugins MUST ignore stanzas that they do not know about.
A plugin binary may handle multiple recipient or identity types by being present
in the PATH
under multiple names. This can be implemented with symlinks or
aliases to the canonical binary.
Multiple plugin binaries can support the same recipient and identity types; the
first binary found in the PATH
will be used by age clients. Some Unix OSs
support "alternatives", which plugin binaries should leverage if they provide
support for a common recipient or identity type.
Note that the identity specified by a user doesn't need to point to a specific
decryption key, or indeed contain any key material at all. It only needs to
contain sufficient information for the plugin to locate the necessary key
material.
A plugin MAY support decrypting files encrypted to native age recipients, by
including support for the x25519
recipient stanza. Such plugins will pick
their own name, and users will use identity files containing identities that
specify that plugin name.
One use-case for plugins is for implementing agents for age identities. The
expected design is a short-lifetime plugin binary age-plugin-NAME
, which
implements the plugin protocol, and in turn connects to (or starts, if not
already running) a long-lifetime agent daemon. Specification of agents is
out of scope for this document.
A plugin operates using one of several state machines. The age client chooses
which state machine to use when it starts the plugin, by passing an argument
flag --age-plugin=STATE_MACHINE
which specifies the type and version of the
state machine. This document defines the following state machines:
recipient-v1
- for wrapping file keys during file encryption.identity-v1
- for unwrapping file keys during file decryption.Plugins MUST NOT make any assumptions about the working directory that they are
run inside.
A plugin MUST refuse to start if it does not know about the requested state
machine; in this situation, or if a plugin otherwise terminates early with an
error, the age client:
It is expected that the same plugin binary will be used (potentially with other
argument flags) for administrative tasks like generating keys.
The IPC protocol for v1 state machines is built around an age stanza, using the
same text format as the age format v1 header:
In the rest of this document, stanzas will be specified with the following
notations (optional fields indicated with [brackets]
):
(COMMAND[, METADATA][; DATA])
(COMMAND, [METADATA] STANZA...)
- STANZA
is a complete stanza.(COMMAND)
- a command with no metadata or data.Stanzas are serialized exactly as in age headers, using the explicit encoding
of stanza bodies. For example, if three commands are sent, and the second
command has no associated data:
-> command-1 metadata
Base64(data)
-> command-2 more metadata
-> command-3
Base64(lots of)
Base64(data)
Note that because the first line of an age stanza consists of SP-separated
arbitary strings, the tag field is always a valid argument. We leverage this in
order to send stanzas directly between the age client and plugin, by prepending
the stanza's first line with an appropriate command and/or additional metadata.
Communication between the client and plugin happens over the plugin's standard
input and standard output. This inherently makes the IPC uni-directionally
synchronous: stanzas have a well-defined order within a specific direction (e.g.
client to plugin). The IPC protocol does not track or enforce a bi-directional
ordering of stanzas; this is handled within the state machine.
The state machines for wrapping and unwrapping are composed of several phases.
Each phase is separated by an explicit done
command with no metadata or data.
A phase is controlled by either the age client or the plugin; the controller
initiates all communication during the phase. There are two kinds of phases:
done
command. The other party is expected to store the effects of thesedone
command.State machine versions enable future specification of backwards-incompatible
state machines (that would likely be associated with new age format versions).
However, backwards-compatible changes may be made to existing state machines.
To ensure backwards-compatibility, other partys MUST handle receiving
unsupported commands. The other party's behaviour depends on the type of phase:
unsupported
commandTo ensure that the above joint does not rust (and similarly to the age format
header), each phase supports the addition of "grease": age stanzas with random
commands (that MUST NOT collide with existing defined commands for that phase),
and random (potentially-empty) metadata and data.
During a phase, the controller MAY send one or more grease stanzas at any point
where they might otherwise send a command. Note that grease cannot replace the
done
command, but a phase may otherwise have multiple commands sent even if it
is defined as a single-command phase.
… and how to handle unknown error types in a phase.
recipient-v1
This state machine wraps a single file key (for a single age-encrypted file) to
multiple recipients and/or identities. It enables amortization of identity-specific
costs (such as requesting a PIN or passphrase from the user) across multiple file
encryptions.
Three commands are defined for this phase:
(add-recipient, RECIPIENT)
- specifies a recipient that the client wants toRECIPIENT
is the Bech32 encoding of a recipient.(add-identity, IDENTITY)
- specifies an identity that the client wants toIDENTITY
is the Bech32 encoding of an identity.(wrap-file-key; FILE_KEY)
- a file key to be wrapped.The plugin indexes recipients, identities, and file keys in the order received
(starting from 0). The two may be interleaved by the client, with no semantic
implications. The plugin caches each recipient, identity, and file key internally.
Example phase diagram:
-> add-recipient foo
-> add-identity bar
-> wrap-file-key
Base64(FILE_KEY)
-> add-recipient baz
-> wrap-file-key
Base64(FILE_KEY)
-> done
The following commands and responses are defined for this phase:
(msg; MESSAGE)
- a message that should be displayed to the user, for(ok)
(if the message can be displayed) or (fail)
(if, for(confirm, Base64(YES_STRING) [Base64(NO_STRING)]; MESSAGE)
- a request forMESSAGE
is the request or call-to-action to be displayed to the user.YES_STRING
and (optionally) NO_STRING
are strings that will be displayed(ok, yes)
if the user selected the option marked with YES_STRING
.(ok, no)
if the user selected the option marked with NO_STRING
.(fail)
if the confirmation request could not be given to the user (for(request-public; MESSAGE)
- the plugin requires some public string from the(ok; REQUESTED_PUBLIC)
or (fail)
.(request-secret; MESSAGE)
- the plugin requires a secret or PIN from the(ok; REQUESTED_SECRET)
or (fail)
.(recipient-stanza, FILE_INDEX STANZA...)
- a stanza containing a correctly-wrapped(ok)
.error
command with three variants:
(error, recipient RECIPIENT_INDEX; MESSAGE)
- a specific recipient is the(ok)
.(error, identity IDENTITY_INDEX; MESSAGE)
- a specific identity is the(ok)
.(error, internal; MESSAGE)
- a general error occurred during wrapping.
(ok)
.Having assembled the full set of recipients and identities that the client wishes
to wrap to, the plugin determines whether it can successfully wrap to all
recipients and identities. The plugin MUST generate an error if one or more
recipients or identities cannot be wrapped to.
The plugin then proceeds to wrap the given file keys to the recipients and
identities. While doing so, the plugin may send commands to request additional
help from the client / user.
The plugin is the controller of this phase; clients should not close the
connection if, for example, a user fails to respond in the way the plugin wants
for a particular request. Instead, the client returns (fail)
to indicate this;
the plugin then decides whether this response is fatal.
If any errors occur, the plugin MUST NOT send any stanzas to the client.
Once all file keys have been successfully wrapped to all recipients and identities,
the plugin sends the resulting stanzas to the client. The plugin MUST NOT return
more stanzas per file than the number of recipients and identities.
Example phase diagram:
-> request-secret ...
< ok\nSECRET
-> request-secret ...
< fail
-> msg ...
< ok
-> recipient-stanza 0 ...
< ok
-> recipient-stanza 1 ...
< ok
-> recipient-stanza 0 ...
< ok
-> recipient-stanza 1 ...
< ok
-> done
identity-v1
This state machine unwraps multiple file keys from multiple age-encrypted files.
It enables amortization of identity-specific costs (such as requesting a PIN or
passphrase from the user) across multiple file decryptions.
Two commands are defined for this phase:
(add-identity, IDENTITY)
- specifies an identity to be used by the pluginIDENTITY
is the Bech32 encoding of an identity.(recipient-stanza, FILE_INDEX STANZA...)
- conveys a stanza from an age formatThe plugin indexes identities and stanzas in the order received (starting from
0). The two may be interleaved by the client, but the recipient-stanza
file
indices must remain ordered and monotonically increasing.
Unknown stanza types MUST be ignored by the plugin.
Example phase diagram:
-> add-identity foo
-> recipient-stanza 0 ...
-> add-identity bar
-> recipient-stanza 0 ...
-> recipient-stanza 0 ...
-> recipient-stanza 1 ...
-> recipient-stanza 1 ...
-> add-identity baz
-> recipient-stanza 1 ...
-> recipient-stanza 2 ...
-> done
The following commands and responses are defined for this phase:
(msg; MESSAGE)
- a message that should be displayed to the user, for(ok)
(if the message can be displayed) or (fail)
(if, for(confirm, Base64(YES_STRING) [Base64(NO_STRING)]; MESSAGE)
- a request forMESSAGE
is the request or call-to-action to be displayed to the user.YES_STRING
and (optionally) NO_STRING
are strings that will be displayed(ok, yes)
if the user selected the option marked with YES_STRING
.(ok, no)
if the user selected the option marked with NO_STRING
.(fail)
if the confirmation request could not be given to the user (for(request-public; MESSAGE)
- the plugin requires some public string from the(ok; REQUESTED_PUBLIC)
or (fail)
.(request-secret; MESSAGE)
- the plugin requires a secret or PIN from the(ok; REQUESTED_SECRET)
or (fail)
.(file-key, FILE_INDEX; FILE_KEY)
- an unwrapped file key.
(ok)
.error
command with three variants:
(error, identity IDENTITY_INDEX; MESSAGE)
- an error occured while using(ok)
.(error, stanza FILE_INDEX STANZA_INDEX; MESSAGE)
- an error occured while(ok)
.(error, internal; MESSAGE)
- a general error occurred during unwrapping.
(ok)
.Having assembled the full list of identities to use, and supported stanzas to
trial-unwrap, the plugin enforces structural validity on both:
FILE_INDEX
.The plugin then chooses internally an order of identities to try, and sends
commands to request additional help from the client / user, or store unwrapped
file keys.
The plugin is the controller of this phase; clients should not close the
connection if, for example, a user fails to respond in the way the plugin wants
for a particular request. Instead, the client returns (fail)
to indicate this;
the plugin is expected to try another recipient stanza or identity.
When the plugin is able to determine whether a given file key can be unwrapped
or not, it takes one of three actions:
error stanza
.
FILE_INDEX
can be unwrapped, it sendsfile-key
.These may be interleaved with other requests, and the client must cache the
unwrapped file keys until this phase is complete. The plugin sends no more than
one file key per file index in the original set of stanzas.
Example phase diagram:
-> request-secret ...
< ok\nSECRET
-> request-secret ...
< ok\nSECRET
-> file-key FILE_INDEX ...
< ok
-> file-key FILE_INDEX ...
< ok
-> request-secret ...
< fail
-> msg ...
< ok
-> error ...
< ok
-> file-key FILE_INDEX ...
< ok
-> file-key FILE_INDEX ...
< ok
-> msg ...
< fail
-> error ...
< ok
-> file-key FILE_INDEX ...
< ok
-> done
A
: age clientP
: pluginA --> P | -> add-recipient RECIPIENT_1
|
A --> P | -> add-recipient RECIPIENT_2
|
A --> P | -> wrap-file-key
| Base64(FILE_KEY)
A --> P | -> done
|
A <-- P | -> recipient-stanza 0 some-tag CJM36AHmTbdHSuOQL+NESqyVQE75f2e610iRdLPEN20
| C3ZAeY64NXS4QFrksLm3EGz+uPRyI0eQsWw7LWbbYig
A <-- P | -> recipient-stanza 0 another-tag 42 ytazqsbmUnPwVWMVx0c1X9iUtGdY4yAB08UQTY2hNCI
| N3pgrXkbIn/RrVt0T0G3sQr1wGWuclqKxTSWHSqGdkc
A <-- P | -> done
|
A --> P | -> add-identity YUBIKEY_ID_PIN_REQUIRED
|
A --> P | -> add-identity YUBIKEY_ID_NO_PIN
|
A --> P | -> recipient-stanza 0 yubikey BjH7FA RO+wV4kbbl4NtSmp56lQcfRdRp3dEFpdQmWkaoiw6lY
| 51eEu5Oo2JYAG7OU4oamH03FDRP18/GnzeCrY7Z+sa8
A --> P | -> recipient-stanza 1 yubikey mhir0Q ZV/AhotwSGqaPCU43cepl4WYUouAa17a3xpu4G2yi5k
| fgMiVLJHMlg9fW7CVG/hPS5EAU4Zeg19LyCP7SoH5nA
A --> P | -> done
|
A <-- P | -> msg
| Base64("Please insert YubiKey with serial 65227134")
A --> P | -> ok
|
A <-- P | -> file-key 0
| Base64(FILE_KEY)
A --> P | -> ok
|
A <-- P | -> request-secret
| Base64("Please enter PIN for YubiKey with serial 65227134")
A --> P | -> ok
| Base64(123456)
A <-- P | -> file-key 1
| Base64(FILE_KEY)
A --> P | -> ok
|
A <-- P | -> done
|
The two driving goals behind the design are:
In order to have no configuration, age clients need to be able to detect, for
example, which plugins support which recipient types. The simplest way to do
this is to have a 1:1 relationship between plugins and recipient types.
An age plugin could be queried for supported recipient types. This was
discounted because it requires starting every installed plugin when only a
subset of them might actually be able to encrypt or decrypt a given message.
An age plugin could, at install time, store a file containing the recipient
types that it supports. This was discounted because it requires significantly
more complex configuration support; instead of only needing one per-user
folder, we would also need to handle system configuration folders across
various platforms, as well as be safe across OS upgrades.