FiloSottile / age

A simple, modern and secure encryption tool (and Go library) with small explicit keys, no config options, and UNIX-style composability.
https://age-encryption.org
BSD 3-Clause "New" or "Revised" License
15.79k stars 477 forks source link

Remove Bech32 length limitations #453

Closed FiloSottile closed 1 year ago

FiloSottile commented 1 year ago

Before v1.1.0, drop the Bech32 length limitations to enable plugins with large data bodies.

Discussed in https://github.com/FiloSottile/age/discussions/452

Originally posted by **msparks** September 24, 2022 Hey, I ran into this limitation while working on an age plugin. Currently, age rejects plugin identities that are 'too long', i.e., more than 43 bytes of data. That's *technically correct* per the [draft plugin specification](https://github.com/C2SP/C2SP/pull/5/commits/e3f35d6d60fa9bda361f6f5487e8b70e7c2631b3?short_path=07bf8cc#diff-07bf8cc6707dbc77590da5e60a0e91a84362d6c7d5079af1fd7dbc2bb2cd2f14), but in practice it creates complexity for plugins, so it seems worth a discussion, especially since it may mean changes to the spec. ### Background The Bech32 encoding, as defined in [BIP-0173](https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki#Bech32), produces strings that are **at most 90 characters long**, composed of 1–83 characters for HRP, 1 character for the separator ("1"), and at least 6 characters for the data and checksum. The draft age plugin spec [requires Bech32 encoding for recipients and identities](https://github.com/C2SP/C2SP/pull/5/commits/e3f35d6d60fa9bda361f6f5487e8b70e7c2631b3?short_path=07bf8cc#diff-07bf8cc6707dbc77590da5e60a0e91a84362d6c7d5079af1fd7dbc2bb2cd2f14): > - 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). And although Bech32 implementations vary in terms of HRP and data length enforcement, a strict reading and implementation of the age plugin specification would indeed be extremely limiting: age plugin identities can have *only 43 bytes of data* in the best case. To illustrate: - HRP: `AGE-PLUGIN-X-`: 13 characters (at least) - Separator: `1`: 1 character - Checksum: 6 characters - Data: (90 - 13 - 1 - 6) * (5 bits / 8 bits for encoding overhead) = *43 bytes* for raw data. ### Problem statement The Bech32 encoding is too restrictive for plugin recipients and identities. While some plugins can be implemented within the limits, such as [`age-plugin-yubikey`](https://github.com/str4d/age-plugin-yubikey), others using longer keys and/or storing metadata in identities cannot. My use case is a `wrap` plugin that encrypts and decrypts a native age X25519 identity transparently, using age itself and potentially other plugins. The goal is to get an X25519 recipient, which can be used without plugins, but with an identity that is protected by a hardware device, for example. Thus, plugins are required for decryption, but not encryption. How the identity is wrapped is arbitrary and can include interesting operations like [splitting the identity](https://en.wikipedia.org/wiki/Shamir%27s_Secret_Sharing) or adding a [timelock](https://github.com/drand/tlock). It's possible to do this now with multiple `age` invocations (decrypt the identity first, then decrypt the message with the decrypted identity), but a plugin would make it transparent and therefore a better user experience. Specifically, the recipient is native X25519 `age1...` and the identity is `AGE-PLUGIN-WRAP-1...`. The problem is that a wrapped identity easily can be hundreds of bytes, so implementing this plugin is currently infeasible. Other plugins will likely run into the same problem. One example is Kʏʙᴇʀ, discussed in https://github.com/FiloSottile/age/discussions/231. @FiloSottile experimented with a Kʏʙᴇʀ768+X25519 plugin and tweeted an example recipient, which is *very long*: https://twitter.com/FiloSottile/status/1544803635237998592. ### Existing implementations As mentioned above, implementations vary: - `age` uses a slightly modified version of the [reference Go implementation](https://github.com/sipa/bech32/blob/7a7d7ab158db7078a333384e0e918c90dbc42917/ref/go/src/bech32/bech32.go) of the Bech32 specification. Both implementations are strict and enforce the 90-character limit on [encode](https://github.com/FiloSottile/age/blob/8328d19d3e79846a99d432799b1ae31deef54c12/internal/bech32/bech32.go#L114-L116) and [decode](https://github.com/FiloSottile/age/blob/8328d19d3e79846a99d432799b1ae31deef54c12/internal/bech32/bech32.go#L147-L149). - `rage` [uses](https://github.com/str4d/rage/blob/f2507197ca962850f37a5e82e5c3c6f6ca523e1b/age/Cargo.toml#L43-L44) the [`bech32` crate](https://docs.rs/bech32/latest/bech32/). It enforces the length on [the HRP](https://docs.rs/bech32/0.9.1/src/bech32/lib.rs.html#366) but not [the data](https://docs.rs/bech32/0.9.1/src/bech32/lib.rs.html#405). - Note: The `bech32` crate is not the reference implementation. Interestingly, the reference implementation in Rust has a length limitation [only on decode](https://github.com/sipa/bech32/blob/7a7d7ab158db7078a333384e0e918c90dbc42917/ref/rust/src/bech32.rs#L98-L100). The same is true for the [reference Python implementation](https://github.com/sipa/bech32/blob/7a7d7ab158db7078a333384e0e918c90dbc42917/ref/python/segwit_addr.py#L80-L81) and others (but not Go!). ### Potential solutions The simplest approach here is to specify the maximum acceptable length of Bech32-encoded data, or remove the limitation explicitly, in the age plugin spec. The length limitation in Bech32 exists to make guarantees on the error detection of the checksum (see the end of the [*Checksum design* section in BIP-0173](https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki#Bech32)), but age identities and recipients perhaps have weaker requirements for the checksum strength than the motivating use case for BIP-0173, so allowing longer data lengths is reasonable. Note that this approach would mean that the encoding isn't *strict* Bech32, so plugin authors would be unable to use strict Bech32 libraries like the reference Go implementation. That'll be confusing, but likely minor in practice. Some possible alternatives: - Remove the length limitation for identities only, with the assumption that identities are less prone to errors than recipients. This would be insufficient for the Kʏʙᴇʀ768+X25519 use case. - Use side channels in plugins (e.g., environment variables, files) for longer identities, and keep only a stub or index in the identity file that `age` reads. Mentioned only for completeness; this option goes against age's simplicity philosophy. - Support a different encoding scheme for identities. Mentioned for completeness.