{%hackmd theme-dark %} # Plugin system for age ## Introduction 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: - A recipient that wraps a file key and returns a stanza. - An identity that unwraps a stanza and returns a file key. 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: - A map from recipients and identities to plugin binaries. - State machines for wrapping and unwrapping file keys. 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. ## Mapping recipients and identities to plugin binaries age plugins are identified by an arbitrary case-insensitive string `NAME`. This string is used in three places: - Plugin-compatible recipients are encoded using Bech32 with the HRP `age1name` (lowercase). - Plugin-compatible identities are encoded using Bech32 with the HRP `AGE-PLUGIN-NAME-` (uppercase). - Plugin binaries (to be started by age clients) are named `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. ### Standard age keys 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. ### Agents for age identities 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. ## State machines 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: - MUST propagate the failure to the user if it occurs during file encryption (which would mean that the file could not be encrypted to one of the requested recipients). This may include displaying the contents of the plugin's standard error to the user. - MAY ignore the failure if it occurs during file decryption (and try another identity or plugin). It is expected that the same plugin binary will be used (potentially with other argument flags) for administrative tasks like generating keys. ### IPC protocol The IPC protocol for v1 state machines is built around an age stanza, using the same text format as the age format v1 header: - The tag field is used for command and response types. - The arguments array is used for command-specific metadata. - The body contains data associated with the command, if any. 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. ### Phases 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: - Uni-directional: the controller sends a series of commands, terminated by the `done` command. The other party is expected to store the effects of these commands for use in a subsequent phase. - Bi-directional: the controller sends a command, and synchronously waits for a response to that command. This is repeated until the controller terminates the phase with the `done` 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: - Uni-directional: the other party MUST ignore all commands it does not know about. - Bi-directional: the other party MUST respond with an `unsupported` command with no metadata or data. ### Grease To 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. ### TODO: Errors ... and how to handle unknown error types in a phase. ## Wrapping with `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. ### Phase 1 [client, uni-directional] Three commands are defined for this phase: - `(add-recipient, RECIPIENT)` - specifies a recipient that the client wants to wrap all the file keys to. - `RECIPIENT` is the Bech32 encoding of a recipient. - `(add-identity, IDENTITY)` - specifies an identity that the client wants to wrap all the file keys to. - `IDENTITY` 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 ``` ### Phase 2 [plugin, bi-directional] The following commands and responses are defined for this phase: - `(msg; MESSAGE)` - a message that should be displayed to the user, for example to prompt them to interact with a hardware key. - Response is `(ok)` (if the message can be displayed) or `(fail)` (if, for example, there is no UI for displaying messages). - The response MAY be sent by the client before the message has been displayed to the user. - `(confirm, Base64(YES_STRING) [Base64(NO_STRING)]; MESSAGE)` - a request for confirmation that should be displayed to the user, for example to ask them to either plug in a hardware key or skip it. - `MESSAGE` 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 on buttons or next to selection options in the user's UI. - Response is one of the following: - `(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 example, if there is no UI for displaying messages). - `(request-public; MESSAGE)` - the plugin requires some public string from the user in order to progress. - Response is `(ok; REQUESTED_PUBLIC)` or `(fail)`. - `(request-secret; MESSAGE)` - the plugin requires a secret or PIN from the user in order to progress. - Response is `(ok; REQUESTED_SECRET)` or `(fail)`. - `(recipient-stanza, FILE_INDEX STANZA...)` - a stanza containing a correctly-wrapped file key to one of the recipients. - The stanzas do not need to be sent in any particular order; clients will not be mapping the stanzas to specific recipients, and will cache stanzas for separate files until the state machine completes. - Response is `(ok)`. - An `error` command with three variants: - `(error, recipient RECIPIENT_INDEX; MESSAGE)` - a specific recipient is the cause of an error. - Response is `(ok)`. - `(error, identity IDENTITY_INDEX; MESSAGE)` - a specific identity is the cause of an error. - Response is `(ok)`. - `(error, internal; MESSAGE)` - a general error occurred during wrapping. - Response is `(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 ``` ## Unwrapping with `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. ### Phase 1 [client, uni-directional] Two commands are defined for this phase: - `(add-identity, IDENTITY)` - specifies an identity to be used by the plugin for trial-unwrapping. - `IDENTITY` is the Bech32 encoding of an identity. - `(recipient-stanza, FILE_INDEX STANZA...)` - conveys a stanza from an age format v1 header. - File indices are numeric, ordered, and monotonically increasing. Duplicate file indices indicate stanzas that are from the same file header, and wrap the same file key. The 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 ``` ### Phase 2 [plugin, bi-directional] The following commands and responses are defined for this phase: - `(msg; MESSAGE)` - a message that should be displayed to the user, for example to prompt them to interact with a hardware key. - Response is `(ok)` (if the message can be displayed) or `(fail)` (if, for example, there is no UI for displaying messages). - The response MAY be sent by the client before the message has been displayed to the user. - `(confirm, Base64(YES_STRING) [Base64(NO_STRING)]; MESSAGE)` - a request for confirmation that should be displayed to the user, for example to ask them to either plug in a hardware key or skip it. - `MESSAGE` 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 on buttons or next to selection options in the user's UI. - Response is one of the following: - `(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 example, if there is no UI for displaying messages). - `(request-public; MESSAGE)` - the plugin requires some public string from the user in order to progress. - Response is `(ok; REQUESTED_PUBLIC)` or `(fail)`. - `(request-secret; MESSAGE)` - the plugin requires a secret or PIN from the user in order to progress. - Response is `(ok; REQUESTED_SECRET)` or `(fail)`. - `(file-key, FILE_INDEX; FILE_KEY)` - an unwrapped file key. - Response is `(ok)`. - An `error` command with three variants: - `(error, identity IDENTITY_INDEX; MESSAGE)` - an error occured while using this identity. - Response is `(ok)`. - `(error, stanza FILE_INDEX STANZA_INDEX; MESSAGE)` - an error occured while using a specific stanza. This MUST NOT be used for unknown stanzas, only for stanzas with an expected tag but that are otherwise invalid (indicating an invalid age header). - Response is `(ok)`. - `(error, internal; MESSAGE)` - a general error occurred during unwrapping. - Response is `(ok)`. Having assembled the full list of identities to use, and supported stanzas to trial-unwrap, the plugin enforces structural validity on both: - If there are unknown or invalid identity types, the plugin MUST return errors and MUST NOT attempt to unwrap any file keys with otherwise-valid identities. - If any known stanza is structurally invalid, the plugin MUST return an error for that stanza, and MUST NOT unwrap any stanzas with the same `FILE_INDEX`. The plugin MAY continue to unwrap stanzas from other files. 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: - If a stanza cannot be unwrapped that detectably should be unwrappable (e.g. the stanza specifically identifies the recipient), the plugin sends `error stanza`. - TODO: Should this be a hard error (preventing that file from being unwrapped)? Probably yes (we already assume we don't get 32-bit collisions for SSH tags). - If any recipient stanza with a given `FILE_INDEX` can be unwrapped, it sends `file-key`. - If all known and valid stanzas for a given file cannot be unwrapped, and none are expected to be unwrappable, the plugin does not send anything. That is, file keys that cannot be unwrapped are implicit. 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. - TODO: Should IPC errors (e.g. invalid command structure or argument types) be separated from state machine errors (e.g. invalid identities)? - Probably not; it should be possible to run the IPC protocol over e.g. TCP. - TODO: What about identities that are not unknown or invalid, but for which we get an error while trying to use them? (e.g. YubiKey missing, invalid PIN, cloud KMS rejection) 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 ``` ## Example interactions - `A`: age client - `P`: plugin ### Key wrapping ```text A --> 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 | ``` ### Key unwrapping ```text 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 | ``` ## Rationale The two driving goals behind the design are: - No configuration. - Simplest user experience possible. 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. ### Considered Alternatives - 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.