34 Commits

Author SHA1 Message Date
james 5ee131c333 added patch to fix PIV slot error
CI checks / Test MSRV on linux (push) Has been cancelled
CI checks / Test MSRV on macos (push) Has been cancelled
CI checks / Test MSRV on windows (push) Has been cancelled
CI checks / Test latest stable on linux (push) Has been cancelled
CI checks / Test latest stable on macos (push) Has been cancelled
CI checks / Test latest stable on windows (push) Has been cancelled
CI checks / Code coverage (push) Has been cancelled
CI checks / Intra-doc links (push) Has been cancelled
CI checks / Rustfmt (push) Has been cancelled
2026-06-29 02:01:49 +02:00
james c7570bb9c8 added home configuration and claude code
CI checks / Test MSRV on linux (push) Has been cancelled
CI checks / Test MSRV on macos (push) Has been cancelled
CI checks / Test MSRV on windows (push) Has been cancelled
CI checks / Test latest stable on linux (push) Has been cancelled
CI checks / Test latest stable on macos (push) Has been cancelled
CI checks / Test latest stable on windows (push) Has been cancelled
CI checks / Code coverage (push) Has been cancelled
CI checks / Intra-doc links (push) Has been cancelled
CI checks / Rustfmt (push) Has been cancelled
2026-06-29 01:43:17 +02:00
james c421d44e4f added patch to fix PIV slot error
CI checks / Test MSRV on linux (push) Has been cancelled
CI checks / Test MSRV on macos (push) Has been cancelled
CI checks / Test MSRV on windows (push) Has been cancelled
CI checks / Test latest stable on linux (push) Has been cancelled
CI checks / Test latest stable on macos (push) Has been cancelled
CI checks / Test latest stable on windows (push) Has been cancelled
CI checks / Code coverage (push) Has been cancelled
CI checks / Intra-doc links (push) Has been cancelled
CI checks / Rustfmt (push) Has been cancelled
2026-06-29 01:40:17 +02:00
james 3ffefedfe8 added patch to fix PIV slot error
CI checks / Test MSRV on linux (push) Has been cancelled
CI checks / Test MSRV on macos (push) Has been cancelled
CI checks / Test MSRV on windows (push) Has been cancelled
CI checks / Test latest stable on linux (push) Has been cancelled
CI checks / Test latest stable on macos (push) Has been cancelled
CI checks / Test latest stable on windows (push) Has been cancelled
CI checks / Code coverage (push) Has been cancelled
CI checks / Intra-doc links (push) Has been cancelled
CI checks / Rustfmt (push) Has been cancelled
2026-06-29 01:20:33 +02:00
Jack Grigg cafbc75fbd Merge pull request #218 from hnw/feature/trigger-ssh-agent-reconnect
CI checks / Test MSRV on linux (push) Has been cancelled
CI checks / Test MSRV on macos (push) Has been cancelled
CI checks / Test MSRV on windows (push) Has been cancelled
CI checks / Test latest stable on linux (push) Has been cancelled
CI checks / Test latest stable on macos (push) Has been cancelled
CI checks / Test latest stable on windows (push) Has been cancelled
CI checks / Code coverage (push) Has been cancelled
CI checks / Intra-doc links (push) Has been cancelled
CI checks / Rustfmt (push) Has been cancelled
feat(key): Trigger SSH agent reconnection after YubiKey release
2026-04-24 07:05:49 +01:00
Jack Grigg c3c0f474fc Merge pull request #224 from str4d/detect-critical-extensions
Reject identities with unrecognised critical extensions
2026-04-08 05:05:25 +01:00
Jack Grigg c57ae544a6 Merge branch 'main' into detect-critical-extensions 2026-04-08 04:31:34 +01:00
Jack Grigg 9329e31dd3 v0.5.1
Publish release binaries / Publish for macos-arm64 (push) Has been cancelled
Publish release binaries / Publish for macos-x86_64 (push) Has been cancelled
Publish release binaries / Publish for linux (push) Has been cancelled
Publish release binaries / Publish for windows (push) Has been cancelled
Publish release binaries / Debian linux (push) Has been cancelled
2026-04-08 04:27:00 +01:00
Jack Grigg ac22ae1df1 Merge tag 'v0.5.0' into detect-critical-extensions 2026-04-08 04:21:39 +01:00
Jack Grigg 23a1f61e5a v0.4.1
CI checks / Test on linux (push) Has been cancelled
CI checks / Test on macos (push) Has been cancelled
CI checks / Test on windows (push) Has been cancelled
CI checks / Code coverage (push) Has been cancelled
CI checks / Intra-doc links (push) Has been cancelled
CI checks / Rustfmt (push) Has been cancelled
Publish release binaries / Publish for macos-arm64 (push) Has been cancelled
Publish release binaries / Publish for macos-x86_64 (push) Has been cancelled
Publish release binaries / Publish for linux (push) Has been cancelled
Publish release binaries / Publish for windows (push) Has been cancelled
Publish release binaries / Debian linux (push) Has been cancelled
2026-04-08 04:20:54 +01:00
Jack Grigg eb945b2849 Merge tag 'v0.4.0' into detect-critical-extensions 2026-04-08 04:16:00 +01:00
Jack Grigg bf081835c4 Release 0.3.4
CI checks / Test on linux (push) Has been cancelled
CI checks / Test on macos (push) Has been cancelled
CI checks / Test on windows (push) Has been cancelled
CI checks / Clippy (1.56.0) (push) Has been cancelled
CI checks / Clippy (nightly) (push) Has been cancelled
CI checks / Code coverage (push) Has been cancelled
CI checks / Intra-doc links (push) Has been cancelled
CI checks / Rustfmt (push) Has been cancelled
Publish release binaries / Publish for macos-arm64 (push) Has been cancelled
Publish release binaries / Publish for macos-x86_64 (push) Has been cancelled
Publish release binaries / Publish for linux (push) Has been cancelled
Publish release binaries / Publish for windows (push) Has been cancelled
Publish release binaries / Debian linux (push) Has been cancelled
2026-04-08 04:14:54 +01:00
Jack Grigg 9503f406ae Reject identities with unrecognised critical extensions
We don't know how to correctly use these identities. In particular, some
identities store parts of their private key material in certificate
extensions to work around hardware limitations. Not understanding these
extensions could lead to encrypting with the wrong protocol and
violating security assumptions.
2026-04-08 04:12:35 +01:00
Jack Grigg fb8368c29d Merge pull request #213 from str4d/p256tag
Add support for `p256tag` recipient type
2026-04-08 02:10:01 +01:00
Jack Grigg 0068b1f343 Change default recipient type to p256tag
Identities generated with older versions of `age-plugin-yubikey` show
their legacy recipient in comments; newer identities only show the new
recipient.
2026-04-07 23:44:51 +01:00
Jack Grigg 971d63957c Change recipient type for identity encryption to p256tag
Encrypting to an identity requires the plugin binary, and there is a
reasonable expectation that the same (or a later) plugin binary version
will be used to decrypt, so we can assume support for the preferred
recipient type.
2026-04-07 23:44:51 +01:00
Jack Grigg 0057a1825e Add support for p256tag 2026-04-07 23:44:03 +01:00
Jack Grigg 4f13e2fc27 Merge pull request #223 from str4d/update-age
Migrate to latest revision of `age-core` and `age-plugin`
2026-04-07 18:34:39 +01:00
Jack Grigg 2a4d129548 Migrate to latest revision of age-core and age-plugin 2026-04-07 18:05:54 +01:00
Yoshio HANAWA 7308d35e6c feat(key): trigger SSH agent reconnection on disconnect
Added a mechanism to send an SSH Agent Protocol request (Opcode 11) immediately after releasing the YubiKey handle. This triggers agents like yubikey-agent to reclaim the device on-demand, preventing PIN cache loss caused by OS power management during the "idle" period.
2026-01-04 17:28:00 +09:00
Jack Grigg 631f4426e1 Merge pull request #214 from str4d/recipient-refactor
Recipient refactor
2025-12-21 11:21:02 +00:00
Jack Grigg 5b44faec44 Refactors for reusability across supported recipients 2025-12-21 11:15:40 +00:00
Jack Grigg 1f1f257ede Rename crate::format to crate::piv_p256 2025-12-21 10:44:35 +00:00
Jack Grigg 144d3088b6 Refactor piv-p256-specific stanza unwrapping onto RecipientLine 2025-12-21 10:44:35 +00:00
Jack Grigg f3f99a0cbc Bump MSRV to 1.70 2025-12-21 10:44:35 +00:00
Jack Grigg 82e948c4e0 Merge pull request #212 from str4d/age-plugin-0.6
Migrate to `age-plugin 0.6`
2025-12-21 00:30:17 +00:00
Jack Grigg 68e634c04e Migrate to age-plugin 0.6 2025-12-08 00:10:49 +00:00
Jack Grigg 36290c74eb Merge pull request #169 from str4d/dependabot/github_actions/svenstaro/upload-release-action-2.9.0
Bump svenstaro/upload-release-action from 2.6.1 to 2.9.0
2024-11-02 10:57:47 +00:00
dependabot[bot] 10dacf9711 Bump svenstaro/upload-release-action from 2.6.1 to 2.9.0
Bumps [svenstaro/upload-release-action](https://github.com/svenstaro/upload-release-action) from 2.6.1 to 2.9.0.
- [Release notes](https://github.com/svenstaro/upload-release-action/releases)
- [Changelog](https://github.com/svenstaro/upload-release-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/svenstaro/upload-release-action/compare/2.6.1...2.9.0)

---
updated-dependencies:
- dependency-name: svenstaro/upload-release-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-02 10:39:33 +00:00
Jack Grigg 56918e7437 Merge pull request #189 from str4d/update-deps-0.6.0
Update dependencies for 0.6.0
2024-11-02 10:35:33 +00:00
Jack Grigg 2f9895229b cargo update again 2024-11-01 09:51:18 +00:00
Jack Grigg e21c504b33 Migrate to latest age-plugin crate commit 2024-11-01 09:18:58 +00:00
Jack Grigg c015dedcf8 i18n-embed 0.15 2024-09-04 14:39:11 +00:00
Jack Grigg f05a99351f cargo update 2024-09-04 14:39:03 +00:00
17 changed files with 1754 additions and 742 deletions
+2 -2
View File
@@ -86,7 +86,7 @@ jobs:
if: matrix.name == 'windows' if: matrix.name == 'windows'
- name: Upload archive to release - name: Upload archive to release
uses: svenstaro/upload-release-action@2.6.1 uses: svenstaro/upload-release-action@2.9.0
with: with:
file: ${{ matrix.archive_name }} file: ${{ matrix.archive_name }}
asset_name: age-plugin-yubikey-$tag-${{ matrix.asset_suffix }} asset_name: age-plugin-yubikey-$tag-${{ matrix.asset_suffix }}
@@ -144,7 +144,7 @@ jobs:
args: --package age-plugin-yubikey --no-build --target ${{ matrix.target }} args: --package age-plugin-yubikey --no-build --target ${{ matrix.target }}
- name: Upload Debian package to release - name: Upload Debian package to release
uses: svenstaro/upload-release-action@2.6.1 uses: svenstaro/upload-release-action@2.9.0
with: with:
file: target/${{ matrix.target }}/debian/*.deb file: target/${{ matrix.target }}/debian/*.deb
file_glob: true file_glob: true
+21
View File
@@ -8,6 +8,27 @@ to 0.3.0 are beta releases.
## [Unreleased] ## [Unreleased]
### Added
- Support for the native non-hybrid tagged recipient type (`age1tag1..`).
- Encryption requires making the `age-plugin-yubikey` binary available on the
`PATH` as `age-plugin-tag`, or upgrading to a client version that builds in
support for this new native recipient type.
### Changed
- MSRV is now 1.70.0.
- Encryption to an identity now uses the preferred recipient type supported for
that identity.
- `age-plugin-yubikey` now prints `age1tag1..` recipients in its CLI and
identity files instead of `age1yubikey1..` recipients. The latter is now only
shown in comments for identities generated with `age-plugin-yubikey 0.5.0` or
earlier.
## [0.3.4], [0.4.1], [0.5.1] - 2026-04-08
### Fixed
- `age-plugin-yubikey` now completely ignores any identity that has unrecognised
critical extensions in its certificate, to ensure it doesn't misuse a newer
identity type.
## [0.5.0] - 2024-08-04 ## [0.5.0] - 2024-08-04
### Fixed ### Fixed
- `age-plugin-yubikey` can now be compiled with Rust 1.80 and above. - `age-plugin-yubikey` can now be compiled with Rust 1.80 and above.
Generated
+933 -564
View File
File diff suppressed because it is too large Load Diff
+17 -9
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "age-plugin-yubikey" name = "age-plugin-yubikey"
description = "YubiKey plugin for age clients" description = "YubiKey plugin for age clients"
version = "0.5.0" version = "0.5.1"
authors = ["Jack Grigg <thestr4d@gmail.com>"] authors = ["Jack Grigg <thestr4d@gmail.com>"]
repository = "https://github.com/str4d/age-plugin-yubikey" repository = "https://github.com/str4d/age-plugin-yubikey"
readme = "README.md" readme = "README.md"
@@ -9,7 +9,7 @@ keywords = ["age", "cli", "encryption", "yubikey"]
categories = ["command-line-utilities", "cryptography"] categories = ["command-line-utilities", "cryptography"]
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
edition = "2021" edition = "2021"
rust-version = "1.67" # MSRV rust-version = "1.70" # MSRV
[package.metadata.deb] [package.metadata.deb]
extended-description = """\ extended-description = """\
@@ -22,15 +22,18 @@ assets = [
] ]
[dependencies] [dependencies]
age-core = "0.10" age-core = "0.11"
age-plugin = "0.5" age-plugin = "0.6"
base64 = "0.21" base64 = "0.22"
bech32 = "0.9" bech32 = "0.11"
console = { version = "0.15", default-features = false } console = { version = "0.15", default-features = false }
dialoguer = { version = "0.11", default-features = false, features = ["password"] } dialoguer = { version = "0.11", default-features = false, features = ["password"] }
env_logger = "0.10" env_logger = "0.10"
gumdrop = "0.8" gumdrop = "0.8"
hex = "0.4" hex = "0.4"
hkdf = "0.12"
hpke = { version = "0.12", default-features = false, features = ["alloc", "p256"] }
yubikey = { version = "=0.8.0-pre.0", features = ["untested"] }
log = "0.4" log = "0.4"
p256 = { version = "0.13", features = ["ecdh"] } p256 = { version = "0.13", features = ["ecdh"] }
pcsc = "2.4" pcsc = "2.4"
@@ -39,11 +42,11 @@ sha2 = "0.10"
which = "5" which = "5"
x509 = "0.2" x509 = "0.2"
x509-parser = "0.14" x509-parser = "0.14"
yubikey = { version = "=0.8.0-pre.0", features = ["untested"] }
# Translations # Translations
i18n-embed = { version = "0.14", features = ["desktop-requester", "fluent-system"] } i18n-embed = { version = "0.15", features = ["desktop-requester", "fluent-system"] }
i18n-embed-fl = "0.8" i18n-embed-fl = "0.9"
lazy_static = "1" lazy_static = "1"
rust-embed = "8" rust-embed = "8"
@@ -56,3 +59,8 @@ man = "0.3"
tempfile = "3" tempfile = "3"
test-with = "0.11" test-with = "0.11"
which = "5" which = "5"
[patch.crates-io]
age-core = { git = "https://github.com/str4d/rage.git", rev = "e08c450aa5d7b1cc5706094080c0042ddd60aaf7" }
age-plugin = { git = "https://github.com/str4d/rage.git", rev = "e08c450aa5d7b1cc5706094080c0042ddd60aaf7" }
yubikey = { git = "https://git.ts.zusein.com/james/yubikey.rs.git", rev = "8c86cab3724a2c6c60348a13d8a93f3652507920" }
+8 -6
View File
@@ -8,7 +8,7 @@ which enables files to be encrypted to age identities stored on YubiKeys.
| Environment | CLI command | | Environment | CLI command |
|-------------|-------------| |-------------|-------------|
| Cargo (Rust 1.67+) | `cargo install age-plugin-yubikey` | | Cargo (Rust 1.70+) | `cargo install age-plugin-yubikey` |
| Homebrew (macOS or Linux) | `brew install age-plugin-yubikey` | | Homebrew (macOS or Linux) | `brew install age-plugin-yubikey` |
| Arch Linux | `pacman -S age-plugin-yubikey` | | Arch Linux | `pacman -S age-plugin-yubikey` |
| Debian | [Debian package](https://github.com/str4d/age-plugin-yubikey/releases) | | Debian | [Debian package](https://github.com/str4d/age-plugin-yubikey/releases) |
@@ -108,15 +108,17 @@ standard output:
$ age-plugin-yubikey --list $ age-plugin-yubikey --list
``` ```
To encrypt files to these YubiKey recipients, ensure that `age-plugin-yubikey` To encrypt files to these YubiKey recipients, ensure you have a recent version
is accessible in your `PATH`, and then use the recipients with an age client as of an age client, and then use the recipients with it as normal (e.g.
normal (e.g. `rage -r age1yubikey1...`). `rage -r age1tag1...`). If this does not work, make `age-plugin-yubikey`
accessible in your `PATH` with the name `age-plugin-tag` and try again.
The output of the `--list` command can also be used directly to encrypt files to The output of the `--list` command can also be used directly to encrypt files to
all recipients (e.g. `age -R filename.txt`). all recipients (e.g. `age -R filename.txt`).
To decrypt files encrypted to a YubiKey identity, pass the identity file to the To decrypt files encrypted to a YubiKey identity, ensure that
age client as normal (e.g. `rage -d -i yubikey-identity.txt`). `age-plugin-yubikey` is accessible in your `PATH`, and then pass the identity
file to the age client as normal (e.g. `rage -d -i yubikey-identity.txt`).
## Advanced topics ## Advanced topics
+2
View File
@@ -43,6 +43,8 @@ yubikey-metadata =
# Created: {$created} # Created: {$created}
# PIN policy: {$pin_policy} # PIN policy: {$pin_policy}
# Touch policy: {$touch_policy} # Touch policy: {$touch_policy}
yubikey-legacy-recipient =
# Legacy recipient: {$recipient}
yubikey-identity = yubikey-identity =
{$yubikey_metadata} {$yubikey_metadata}
# Recipient: {$recipient} # Recipient: {$recipient}
+1 -1
View File
@@ -1,3 +1,3 @@
[toolchain] [toolchain]
channel = "1.67.0" channel = "1.70.0"
components = ["clippy", "rustfmt"] components = ["clippy", "rustfmt"]
+5 -3
View File
@@ -11,9 +11,9 @@ use crate::{
error::Error, error::Error,
fl, fl,
key::{self, Stub}, key::{self, Stub},
p256::Recipient, native::p256tag,
util::{Metadata, POLICY_EXTENSION_OID}, util::{Metadata, POLICY_EXTENSION_OID},
BINARY_NAME, USABLE_SLOTS, Recipient, BINARY_NAME, USABLE_SLOTS,
}; };
pub(crate) const DEFAULT_PIN_POLICY: PinPolicy = PinPolicy::Once; pub(crate) const DEFAULT_PIN_POLICY: PinPolicy = PinPolicy::Once;
@@ -104,7 +104,9 @@ impl IdentityBuilder {
touch_policy, touch_policy,
)?; )?;
let recipient = Recipient::from_spki(&generated).expect("YubiKey generates a valid pubkey"); let recipient = Recipient::P256Tag(
p256tag::Recipient::from_spki(&generated).expect("YubiKey generates a valid pubkey"),
);
let stub = Stub::new(yubikey.serial(), slot, &recipient); let stub = Stub::new(yubikey.serial(), slot, &recipient);
eprintln!(); eprintln!();
+123 -49
View File
@@ -1,20 +1,20 @@
//! Structs for handling YubiKeys. //! Structs for handling YubiKeys.
use age_core::{ use age_core::primitives::bech32_encode;
format::{FileKey, FILE_KEY_BYTES}, use age_core::secrecy::{ExposeSecret, SecretString};
primitives::{aead_decrypt, hkdf},
secrecy::{ExposeSecret, SecretString},
};
use age_plugin::{identity, Callbacks}; use age_plugin::{identity, Callbacks};
use bech32::{ToBase32, Variant};
use dialoguer::Password; use dialoguer::Password;
use log::{debug, error, warn}; use log::{debug, error, warn};
use std::convert::Infallible; use std::convert::Infallible;
use std::env;
use std::fmt; use std::fmt;
use std::io; use std::io::{self, Read, Write};
use std::iter; use std::iter;
#[cfg(unix)]
use std::os::unix::net::UnixStream;
use std::thread::sleep; use std::thread::sleep;
use std::time::{Duration, Instant, SystemTime}; use std::time::{Duration, Instant, SystemTime};
use x509_parser::der_parser::oid::Oid;
use yubikey::{ use yubikey::{
certificate::Certificate, certificate::Certificate,
piv::{decrypt_data, AlgorithmId, RetiredSlotId, SlotId}, piv::{decrypt_data, AlgorithmId, RetiredSlotId, SlotId},
@@ -25,15 +25,18 @@ use yubikey::{
use crate::{ use crate::{
error::Error, error::Error,
fl, fl,
format::{RecipientLine, STANZA_KEY_LABEL}, native::p256tag,
p256::{Recipient, TAG_BYTES}, recipient::TAG_BYTES,
util::{otp_serial_prefix, Metadata}, util::{otp_serial_prefix, Metadata, POLICY_EXTENSION_OID},
IDENTITY_PREFIX, Recipient, IDENTITY_PREFIX,
}; };
const ONE_SECOND: Duration = Duration::from_secs(1); const ONE_SECOND: Duration = Duration::from_secs(1);
const FIFTEEN_SECONDS: Duration = Duration::from_secs(15); const FIFTEEN_SECONDS: Duration = Duration::from_secs(15);
/// The set of OIDs that we understand and use when parsing YubiKey slot certificates.
const KNOWN_OIDS: &[&[u64]] = &[POLICY_EXTENSION_OID];
pub(crate) fn is_connected(reader: Reader) -> bool { pub(crate) fn is_connected(reader: Reader) -> bool {
filter_connected(&reader) filter_connected(&reader)
} }
@@ -257,6 +260,67 @@ pub(crate) fn open(serial: Option<Serial>) -> Result<YubiKey, Error> {
Ok(yubikey) Ok(yubikey)
} }
/// Sends a request to the SSH agent to trigger reconnection to the YubiKey.
///
/// This function sends an `SSH_AGENTC_REQUEST_IDENTITIES` (opcode 11) message to the
/// SSH agent socket specified by the `SSH_AUTH_SOCK` environment variable. This triggers
/// agents like `yubikey-agent` to reconnect to the YubiKey on-demand, preserving PIN
/// cache state.
///
/// All errors are silently ignored to avoid affecting the main encryption/decryption
/// workflow.
#[cfg(unix)]
fn poke_ssh_agent() {
// Get SSH_AUTH_SOCK; silently return if not set
let socket_path = match env::var("SSH_AUTH_SOCK") {
Ok(path) if !path.is_empty() => path,
_ => return,
};
// Connect to the SSH agent socket
let mut stream = match UnixStream::connect(&socket_path) {
Ok(s) => s,
Err(e) => {
debug!("Failed to connect to SSH agent socket: {}", e);
return;
}
};
// Set a short timeout to avoid blocking
let timeout = Some(Duration::from_secs(1));
let _ = stream.set_read_timeout(timeout);
let _ = stream.set_write_timeout(timeout);
// SSH_AGENTC_REQUEST_IDENTITIES message:
// - 4 bytes: message length (big-endian) = 1
// - 1 byte: message type = 11 (SSH_AGENTC_REQUEST_IDENTITIES)
const SSH_AGENTC_REQUEST_IDENTITIES: [u8; 5] = [0, 0, 0, 1, 11];
if let Err(e) = stream.write_all(&SSH_AGENTC_REQUEST_IDENTITIES) {
debug!("Failed to send request to SSH agent: {}", e);
return;
}
// Read and discard the response (we don't need the identities list)
// Response format: 4-byte length + message body
let mut length_buf = [0u8; 4];
if stream.read_exact(&mut length_buf).is_ok() {
let length = u32::from_be_bytes(length_buf) as usize;
// Limit read to prevent memory issues with malformed responses
if length <= 64 * 1024 {
let mut response = vec![0u8; length];
let _ = stream.read_exact(&mut response);
}
}
debug!("Sent reconnection trigger to SSH agent at {}", socket_path);
}
#[cfg(not(unix))]
fn poke_ssh_agent() {
// SSH agent socket communication is Unix-specific
}
/// Disconnect from the YubiKey without resetting it. /// Disconnect from the YubiKey without resetting it.
/// ///
/// This can be used to preserve the YubiKey's PIN and touch caches. There are two cases /// This can be used to preserve the YubiKey's PIN and touch caches. There are two cases
@@ -270,8 +334,13 @@ pub(crate) fn open(serial: Option<Serial>) -> Result<YubiKey, Error> {
/// YubiKey's state were to potentially cache the PIN and/or touch (depending on the /// YubiKey's state were to potentially cache the PIN and/or touch (depending on the
/// policies of the slot). We want to allow these to persist beyond our execution, for /// policies of the slot). We want to allow these to persist beyond our execution, for
/// usability. /// usability.
///
/// After releasing the YubiKey, this function also sends a request to the SSH agent
/// (if available) to trigger reconnection, allowing agents like `yubikey-agent` to
/// reclaim the device and preserve PIN cache state.
pub(crate) fn disconnect_without_reset(yubikey: YubiKey) { pub(crate) fn disconnect_without_reset(yubikey: YubiKey) {
let _ = yubikey.disconnect(pcsc::Disposition::LeaveCard); let _ = yubikey.disconnect(pcsc::Disposition::LeaveCard);
poke_ssh_agent();
} }
fn request_pin<E, E2>( fn request_pin<E, E2>(
@@ -332,7 +401,7 @@ pub(crate) fn manage(yubikey: &mut YubiKey) -> Result<(), Error> {
.with_prompt(fl!("mgr-choose-new-pin")) .with_prompt(fl!("mgr-choose-new-pin"))
.with_confirmation(fl!("mgr-repeat-new-pin"), fl!("mgr-pin-mismatch")) .with_confirmation(fl!("mgr-repeat-new-pin"), fl!("mgr-pin-mismatch"))
.interact() .interact()
.map(|pin| Result::<_, Infallible>::Ok(SecretString::new(pin))) .map(|pin| Result::<_, Infallible>::Ok(SecretString::from(pin)))
}, },
yubikey.serial(), yubikey.serial(),
)? )?
@@ -388,6 +457,30 @@ pub(crate) fn manage(yubikey: &mut YubiKey) -> Result<(), Error> {
Ok(()) Ok(())
} }
/// Parses the certificate to identify the preferred recipient type it corresponds to.
pub(crate) fn identify_recipient(cert: &Certificate) -> Option<Recipient> {
let known_oids = KNOWN_OIDS
.iter()
.map(|oid| Oid::from(oid).unwrap())
.collect::<Vec<_>>();
// If the certificate contains any unrecognised critical extensions, reject it: we
// don't know how to correctly use the identity. In particular, some identities store
// parts of their private key material in certificate extensions to work around
// hardware limitations. Not understanding these extensions could lead to encrypting
// with the wrong protocol and violating security assumptions.
let (_, c) = x509_parser::parse_x509_certificate(cert.as_ref()).ok()?;
if c.tbs_certificate
.extensions()
.iter()
.any(|ext| ext.critical && !known_oids.contains(&ext.oid))
{
return None;
}
p256tag::Recipient::from_certificate(cert).map(Recipient::P256Tag)
}
/// Returns an iterator of keys that are occupying plugin-compatible slots, along with the /// Returns an iterator of keys that are occupying plugin-compatible slots, along with the
/// corresponding recipient if the key is compatible with this plugin. /// corresponding recipient if the key is compatible with this plugin.
pub(crate) fn list_slots( pub(crate) fn list_slots(
@@ -397,8 +490,7 @@ pub(crate) fn list_slots(
// We only use the retired slots. // We only use the retired slots.
match key.slot() { match key.slot() {
SlotId::Retired(slot) => { SlotId::Retired(slot) => {
// Only P-256 keys are compatible with us. let recipient = identify_recipient(key.certificate());
let recipient = Recipient::from_certificate(key.certificate());
Some((key, slot, recipient)) Some((key, slot, recipient))
} }
_ => None, _ => None,
@@ -426,12 +518,7 @@ pub struct Stub {
impl fmt::Display for Stub { impl fmt::Display for Stub {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str( f.write_str(
bech32::encode( bech32_encode(IDENTITY_PREFIX, &self.to_bytes())
IDENTITY_PREFIX,
self.to_bytes().to_base32(),
Variant::Bech32,
)
.expect("HRP is valid")
.to_uppercase() .to_uppercase()
.as_str(), .as_str(),
) )
@@ -453,7 +540,7 @@ impl Stub {
Stub { Stub {
serial, serial,
slot, slot,
tag: recipient.tag(), tag: recipient.static_tag(),
identity_index: 0, identity_index: 0,
} }
} }
@@ -480,10 +567,6 @@ impl Stub {
bytes bytes
} }
pub(crate) fn matches(&self, line: &RecipientLine) -> bool {
self.tag == line.tag
}
/// Returns: /// Returns:
/// - `Ok(Ok(Some(connection)))` if we successfully connected to this YubiKey. /// - `Ok(Ok(Some(connection)))` if we successfully connected to this YubiKey.
/// - `Ok(Ok(None))` if the user told us to skip this YubiKey. /// - `Ok(Ok(None))` if the user told us to skip this YubiKey.
@@ -605,9 +688,10 @@ impl Stub {
let (cert, pk) = match Certificate::read(&mut yubikey, SlotId::Retired(self.slot)) let (cert, pk) = match Certificate::read(&mut yubikey, SlotId::Retired(self.slot))
.ok() .ok()
.and_then(|cert| { .and_then(|cert| {
Recipient::from_certificate(&cert) // Parse as the preferred recipient for each identity type.
.filter(|pk| pk.tag() == self.tag) identify_recipient(&cert)
.map(|pk| (cert, pk)) .filter(|recipient| recipient.static_tag() == self.tag)
.map(|r| (cert, r))
}) { }) {
Some(pk) => pk, Some(pk) => pk,
None => { None => {
@@ -623,7 +707,6 @@ impl Stub {
cert, cert,
pk, pk,
slot: self.slot, slot: self.slot,
tag: self.tag,
identity_index: self.identity_index, identity_index: self.identity_index,
cached_metadata: None, cached_metadata: None,
last_touch: None, last_touch: None,
@@ -636,17 +719,21 @@ pub(crate) struct Connection {
cert: Certificate, cert: Certificate,
pk: Recipient, pk: Recipient,
slot: RetiredSlotId, slot: RetiredSlotId,
tag: [u8; 4],
identity_index: usize, identity_index: usize,
cached_metadata: Option<Metadata>, cached_metadata: Option<Metadata>,
last_touch: Option<Instant>, last_touch: Option<Instant>,
} }
impl Connection { impl Connection {
/// Returns the preferred recipient for encrypting to this identity.
pub(crate) fn recipient(&self) -> &Recipient { pub(crate) fn recipient(&self) -> &Recipient {
&self.pk &self.pk
} }
pub(crate) fn stub(&self) -> Stub {
Stub::new(self.yubikey.serial(), self.slot, &self.pk)
}
pub(crate) fn request_pin_if_necessary<E>( pub(crate) fn request_pin_if_necessary<E>(
&mut self, &mut self,
callbacks: &mut dyn Callbacks<E>, callbacks: &mut dyn Callbacks<E>,
@@ -705,8 +792,10 @@ impl Connection {
Ok(Ok(())) Ok(Ok(()))
} }
pub(crate) fn unwrap_file_key(&mut self, line: &RecipientLine) -> Result<FileKey, ()> { pub(crate) fn p256_ecdh(&mut self, epk_bytes: &[u8]) -> Result<yubikey::Buffer, ()> {
assert_eq!(self.tag, line.tag); // The YubiKey API for performing scalar multiplication takes the point in its
// uncompressed SEC-1 encoding.
assert_eq!(epk_bytes.len(), 65);
// Check if the touch policy requires a touch. // Check if the touch policy requires a touch.
let needs_touch = match ( let needs_touch = match (
@@ -718,11 +807,9 @@ impl Connection {
_ => false, _ => false,
}; };
// The YubiKey API for performing scalar multiplication takes the point in its
// uncompressed SEC-1 encoding.
let shared_secret = match decrypt_data( let shared_secret = match decrypt_data(
&mut self.yubikey, &mut self.yubikey,
line.epk_bytes.decompress().as_bytes(), epk_bytes,
AlgorithmId::EccP256, AlgorithmId::EccP256,
SlotId::Retired(self.slot), SlotId::Retired(self.slot),
) { ) {
@@ -739,20 +826,7 @@ impl Connection {
} }
} }
let mut salt = vec![]; Ok(shared_secret)
salt.extend_from_slice(line.epk_bytes.as_bytes());
salt.extend_from_slice(self.pk.to_encoded().as_bytes());
let enc_key = hkdf(&salt, STANZA_KEY_LABEL, shared_secret.as_ref());
// A failure to decrypt is fatal, because we assume that we won't
// encounter 32-bit collisions on the key tag embedded in the header.
match aead_decrypt(&enc_key, FILE_KEY_BYTES, &line.encrypted_file_key) {
Ok(pt) => Ok(TryInto::<[u8; FILE_KEY_BYTES]>::try_into(&pt[..])
.unwrap()
.into()),
Err(_) => Err(()),
}
} }
/// Close this connection without resetting the YubiKey. /// Close this connection without resetting the YubiKey.
+27 -15
View File
@@ -16,19 +16,20 @@ use yubikey::{piv::RetiredSlotId, reader::Context, PinPolicy, Serial, TouchPolic
mod builder; mod builder;
mod error; mod error;
mod format;
mod key; mod key;
mod p256; mod native;
mod piv_p256;
mod plugin; mod plugin;
mod util; mod util;
mod recipient;
use recipient::Recipient;
use error::Error; use error::Error;
const PLUGIN_NAME: &str = "yubikey"; const PLUGIN_NAME: &str = "yubikey";
const BINARY_NAME: &str = "age-plugin-yubikey"; const BINARY_NAME: &str = "age-plugin-yubikey";
const RECIPIENT_PREFIX: &str = "age1yubikey"; const IDENTITY_PREFIX: bech32::Hrp = bech32::Hrp::parse_unchecked("AGE-PLUGIN-YUBIKEY-");
const IDENTITY_PREFIX: &str = "age-plugin-yubikey-";
const STANZA_TAG: &str = "piv-p256";
const USABLE_SLOTS: [RetiredSlotId; 20] = [ const USABLE_SLOTS: [RetiredSlotId; 20] = [
RetiredSlotId::R1, RetiredSlotId::R1,
@@ -194,7 +195,7 @@ fn generate(flags: PluginFlags) -> Result<(), Error> {
fn print_single( fn print_single(
serial: Option<Serial>, serial: Option<Serial>,
slot: RetiredSlotId, slot: RetiredSlotId,
printer: impl Fn(key::Stub, p256::Recipient, util::Metadata), printer: impl Fn(key::Stub, Recipient, util::Metadata),
) -> Result<(), Error> { ) -> Result<(), Error> {
let mut yubikey = key::open(serial)?; let mut yubikey = key::open(serial)?;
@@ -216,7 +217,7 @@ fn print_multiple(
kind: &str, kind: &str,
serial: Option<Serial>, serial: Option<Serial>,
all: bool, all: bool,
printer: impl Fn(key::Stub, p256::Recipient, util::Metadata), printer: impl Fn(key::Stub, Recipient, util::Metadata),
) -> Result<(), Error> { ) -> Result<(), Error> {
let mut readers = Context::open()?; let mut readers = Context::open()?;
@@ -256,7 +257,7 @@ fn print_details(
kind: &str, kind: &str,
flags: PluginFlags, flags: PluginFlags,
all: bool, all: bool,
printer: impl Fn(key::Stub, p256::Recipient, util::Metadata), printer: impl Fn(key::Stub, Recipient, util::Metadata),
) -> Result<(), Error> { ) -> Result<(), Error> {
if let Some(slot) = flags.slot { if let Some(slot) = flags.slot {
print_single(flags.serial, slot, printer) print_single(flags.serial, slot, printer)
@@ -297,6 +298,12 @@ fn list(flags: PluginFlags, all: bool) -> Result<(), Error> {
all, all,
|_, recipient, metadata| { |_, recipient, metadata| {
println!("{metadata}"); println!("{metadata}");
if let Some(legacy_recipient) = recipient.legacy_recipient(&metadata) {
println!(
"{}",
fl!("yubikey-legacy-recipient", recipient = legacy_recipient)
);
}
println!("{recipient}"); println!("{recipient}");
}, },
) )
@@ -327,11 +334,7 @@ fn main() -> Result<(), Error> {
} }
if let Some(state_machine) = opts.age_plugin { if let Some(state_machine) = opts.age_plugin {
run_state_machine( run_state_machine(&state_machine, plugin::Handler)?;
&state_machine,
Some(plugin::RecipientPlugin::default),
Some(plugin::IdentityPlugin::default),
)?;
Ok(()) Ok(())
} else if opts.version { } else if opts.version {
println!("age-plugin-yubikey {}", env!("CARGO_PKG_VERSION")); println!("age-plugin-yubikey {}", env!("CARGO_PKG_VERSION"));
@@ -406,7 +409,7 @@ fn main() -> Result<(), Error> {
let (_, cert) = let (_, cert) =
x509_parser::parse_x509_certificate(key.certificate().as_ref()) x509_parser::parse_x509_certificate(key.certificate().as_ref())
.unwrap(); .unwrap();
let (name, _) = util::extract_name(&cert, true).unwrap(); let (name, _) = util::extract_name_and_version(&cert, true).unwrap();
let created = cert let created = cert
.validity() .validity()
.not_before .not_before
@@ -616,6 +619,15 @@ fn main() -> Result<(), Error> {
Err(e) => return Err(e.into()), Err(e) => return Err(e.into()),
}; };
let identity = if let Some(legacy_recipient) = recipient.legacy_recipient(&metadata) {
format!(
"{}\n{stub}",
fl!("yubikey-legacy-recipient", recipient = legacy_recipient),
)
} else {
stub.to_string()
};
writeln!( writeln!(
file, file,
"{}", "{}",
@@ -623,7 +635,7 @@ fn main() -> Result<(), Error> {
"yubikey-identity", "yubikey-identity",
yubikey_metadata = metadata.to_string(), yubikey_metadata = metadata.to_string(),
recipient = recipient.to_string(), recipient = recipient.to_string(),
identity = stub.to_string(), identity = identity,
) )
)?; )?;
file.sync_data()?; file.sync_data()?;
+59
View File
@@ -0,0 +1,59 @@
use std::marker::PhantomData;
use std::rc::Rc;
use std::sync::RwLock;
use hkdf::Hkdf;
use sha2::Sha256;
use crate::key::Connection;
pub(crate) mod p256tag;
/// Derives a tag for the tagged age recipient formats.
fn stanza_tag(ikm: &[u8], salt: &str) -> [u8; 4] {
let (tag, _) = Hkdf::<Sha256>::extract(Some(salt.as_bytes()), ikm);
tag[..4].try_into().expect("correct length")
}
/// Pretend that a YubiKey connection is a KEM private key.
struct YubiKeyKemPrivateKey<'a, Kem> {
conn: Rc<RwLock<&'a mut Connection>>,
_kem: PhantomData<Kem>,
}
impl<'a, Kem> YubiKeyKemPrivateKey<'a, Kem> {
fn new(conn: &'a mut Connection) -> Self {
Self {
conn: Rc::new(RwLock::new(conn)),
_kem: PhantomData::default(),
}
}
}
impl<'a, Kem> Clone for YubiKeyKemPrivateKey<'a, Kem> {
fn clone(&self) -> Self {
Self {
conn: self.conn.clone(),
_kem: PhantomData::default(),
}
}
}
impl<'a, Kem> PartialEq for YubiKeyKemPrivateKey<'a, Kem> {
fn eq(&self, other: &Self) -> bool {
self.conn.read().unwrap().stub() == other.conn.read().unwrap().stub()
}
}
impl<'a, Kem> Eq for YubiKeyKemPrivateKey<'a, Kem> {}
impl<'a, Kem: hpke::Kem> hpke::Serializable for YubiKeyKemPrivateKey<'a, Kem> {
type OutputSize = <Kem::PrivateKey as hpke::Serializable>::OutputSize;
fn write_exact(&self, _: &mut [u8]) {
unreachable!("Never called")
}
}
impl<'a, Kem: hpke::Kem> hpke::Deserializable for YubiKeyKemPrivateKey<'a, Kem> {
fn from_bytes(_: &[u8]) -> Result<Self, hpke::HpkeError> {
unreachable!("Never called")
}
}
+292
View File
@@ -0,0 +1,292 @@
use std::fmt;
use std::marker::PhantomData;
use age_core::{
format::{FileKey, Stanza},
primitives::{bech32_encode_to_fmt, hpke_open, hpke_seal},
secrecy::{zeroize::Zeroize, ExposeSecret},
};
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
use hpke::{Deserializable, Serializable};
use p256::{
elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint},
EncodedPoint,
};
use rand::rngs::OsRng;
use yubikey::{certificate::PublicKeyInfo, Certificate};
use super::{stanza_tag, YubiKeyKemPrivateKey};
use crate::{
key::{self, Connection},
recipient::static_tag,
util::base64_arg,
};
pub(crate) const PLUGIN_NAME: &str = "tag";
const RECIPIENT_PREFIX: bech32::Hrp = bech32::Hrp::parse_unchecked("age1tag");
const P256TAG_RECIPIENT_TAG: &str = "p256tag";
const P256TAG_SALT: &str = "age-encryption.org/p256tag";
const TAG_BYTES: usize = 4;
/// Per [RFC 9180 section 7.1.1]:
/// > For P-256, P-384, and P-521, the `SerializePublicKey()` function of the KEM performs
/// > the uncompressed Elliptic-Curve-Point-to-Octet-String conversion according to [SECG].
///
/// [RFC 9180 section 7.1.1]: https://www.rfc-editor.org/rfc/rfc9180.html#section-7.1.1
/// [SECG]: https://secg.org/sec1-v2.pdf
const ENC_BYTES: usize = 65;
type Kem = hpke::kem::DhP256HkdfSha256;
/// The non-hybrid tagged age recipient type, designed for hardware keys where decryption
/// potentially requires user presence.
///
/// With knowledge of the recipient, it is possible to check if a stanza was addressed to
/// a specific recipient before attempting decryption. This offers less privacy than the
/// untagged recipient types.
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct Recipient {
/// Compressed encoding of the recipient public key.
compressed: EncodedPoint,
/// Cached in-memory representation, for HPKE.
pk_recip: <Kem as hpke::Kem>::PublicKey,
}
impl fmt::Display for Recipient {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
bech32_encode_to_fmt(f, RECIPIENT_PREFIX, self.compressed.as_bytes())
}
}
impl fmt::Debug for Recipient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self)
}
}
impl Recipient {
/// Attempts to parse a valid p256tag recipient from its compressed SEC-1 byte encoding.
pub(crate) fn from_bytes(bytes: &[u8]) -> Option<Self> {
let encoded = p256::EncodedPoint::from_bytes(bytes).ok()?;
if !encoded.is_compressed() {
return None;
}
let point = p256::PublicKey::from_encoded_point(&encoded).into_option()?;
let pk_recip =
<Kem as hpke::Kem>::PublicKey::from_bytes(point.to_encoded_point(false).as_bytes())
.expect("valid");
Some(Self {
compressed: encoded,
pk_recip,
})
}
pub(crate) fn from_certificate(cert: &Certificate) -> Option<Self> {
Self::from_spki(cert.subject_pki())
}
pub(crate) fn from_spki(spki: &PublicKeyInfo) -> Option<Self> {
let encoded = match spki {
PublicKeyInfo::EcP256(pubkey) => Some(pubkey),
_ => None,
}?;
// Check that the certificate encoding is uncompressed.
let pk_recip = <Kem as hpke::Kem>::PublicKey::from_bytes(encoded.as_bytes()).ok()?;
let point = p256::PublicKey::from_encoded_point(encoded).into_option()?;
let compressed = point.to_encoded_point(true);
Some(Self {
compressed,
pk_recip,
})
}
/// Returns the compressed SEC-1 encoding of this recipient.
pub(crate) fn to_compressed(&self) -> p256::EncodedPoint {
self.compressed
}
pub(crate) fn static_tag(&self) -> [u8; TAG_BYTES] {
static_tag(self.compressed.as_bytes())
}
pub(crate) fn wrap_file_key(&self, file_key: &FileKey) -> RecipientLine {
let (enc, ct) = hpke_seal::<Kem, _>(
&self.pk_recip,
P256TAG_SALT.as_bytes(),
file_key.expose_secret(),
&mut OsRng,
);
RecipientLine {
tag: tag(&enc, self.static_tag()),
enc,
ct,
}
}
}
fn tag(enc: &<Kem as hpke::Kem>::EncappedKey, static_tag: [u8; TAG_BYTES]) -> [u8; TAG_BYTES] {
let ikm = enc
.to_bytes()
.into_iter()
.chain(static_tag)
.collect::<Vec<u8>>();
stanza_tag(&ikm, P256TAG_SALT)
}
pub(crate) struct RecipientLine {
tag: [u8; TAG_BYTES],
enc: <Kem as hpke::Kem>::EncappedKey,
ct: Vec<u8>,
}
impl From<RecipientLine> for Stanza {
fn from(r: RecipientLine) -> Self {
Stanza {
tag: P256TAG_RECIPIENT_TAG.to_owned(),
args: vec![
BASE64_STANDARD_NO_PAD.encode(r.tag),
BASE64_STANDARD_NO_PAD.encode(r.enc.to_bytes()),
],
body: r.ct,
}
}
}
impl RecipientLine {
pub(crate) fn from_stanza(s: Stanza) -> Option<Result<Self, ()>> {
if s.tag != P256TAG_RECIPIENT_TAG {
return None;
}
let (tag, enc) = match &s.args[..] {
[encoded_tag, encoded_enc] => (
base64_arg(encoded_tag, [0; TAG_BYTES]),
base64_arg(encoded_enc, [0; ENC_BYTES])
.and_then(|bytes| <Kem as hpke::Kem>::EncappedKey::from_bytes(&bytes[..]).ok()),
),
_ => (None, None),
};
Some(match (tag, enc) {
(Some(tag), Some(epk_bytes)) => Ok(RecipientLine {
tag,
enc: epk_bytes,
ct: s.body,
}),
// Anything else indicates a structurally-invalid stanza.
_ => Err(()),
})
}
pub(crate) fn matches_stub(&self, stub: &key::Stub) -> bool {
self.tag == tag(&self.enc, stub.tag)
}
pub(crate) fn unwrap_file_key(&self, conn: &mut Connection) -> Result<FileKey, ()> {
// > The identity implementation [...] MUST check that the body length is exactly
// > 32 bytes before attempting to decrypt it, to mitigate partitioning oracle
// > attacks.
if self.ct.len() != 32 {
return Err(());
}
let sk_recip = YubiKeyKemPrivateKey::new(conn);
// A failure to decrypt is fatal, because we assume that we won't
// encounter 32-bit collisions on the key tag embedded in the header.
hpke_open::<YubiKeyDhP256HkdfSha256>(
&self.enc,
&sk_recip,
P256TAG_SALT.as_bytes(),
&self.ct,
)
.map_err(|_| ())
.map(|mut pt| {
FileKey::init_with_mut(|file_key| {
file_key.copy_from_slice(&pt);
pt.zeroize();
})
})
}
}
/// A decap-only version of [`Kem`] where the private key is stored on a YubiKey.
struct YubiKeyDhP256HkdfSha256<'a>(PhantomData<&'a ()>);
impl<'a> hpke::Kem for YubiKeyDhP256HkdfSha256<'a> {
type PublicKey = <Kem as hpke::Kem>::PublicKey;
type PrivateKey = YubiKeyKemPrivateKey<'a, Kem>;
fn sk_to_pk(_: &Self::PrivateKey) -> Self::PublicKey {
unreachable!("Never called")
}
type EncappedKey = <Kem as hpke::Kem>::EncappedKey;
type NSecret = <Kem as hpke::Kem>::NSecret;
const KEM_ID: u16 = <Kem as hpke::Kem>::KEM_ID;
fn derive_keypair(_: &[u8]) -> (Self::PrivateKey, Self::PublicKey) {
unreachable!("Never called")
}
fn decap(
sk_recip: &Self::PrivateKey,
pk_sender_id: Option<&Self::PublicKey>,
encapped_key: &Self::EncappedKey,
) -> Result<hpke::kem::SharedSecret<Self>, hpke::HpkeError> {
let mut sk_recip = sk_recip.conn.write().unwrap();
// Put together the binding context used for all KDF operations
let suite_id = b"KEM\x00\x10";
// Compute the shared secret from the ephemeral inputs
let kex_res_eph = sk_recip
.p256_ecdh(&encapped_key.to_bytes())
.map_err(|_| hpke::HpkeError::DecapError)?;
// Compute the sender's pubkey from their privkey
let pk_recip = match sk_recip.recipient() {
crate::recipient::Recipient::P256Tag(recipient) => &recipient.pk_recip,
_ => panic!("should have been filtered out earlier"),
};
assert!(pk_sender_id.is_none());
// kem_context = encapped_key || pk_recip || pk_sender_id
let kem_context = [encapped_key.to_bytes(), pk_recip.to_bytes()]
.into_iter()
.flatten()
.collect::<Vec<_>>();
// The "unauthed shared secret" is derived from just the KEX of the ephemeral
// input with the recipient pubkey. The HKDF-Expand call only errors if the
// output values are 255x the digest size of the hash function. Since these
// values are fixed at compile time, we don't worry about it.
let mut shared_secret = <hpke::kem::SharedSecret<Self> as Default>::default();
hpke::kdf::extract_and_expand::<hpke::kdf::HkdfSha256>(
&kex_res_eph,
suite_id,
&kem_context,
&mut shared_secret.0,
)
.expect("shared secret is way too big");
Ok(shared_secret)
}
fn encap<R: rand::CryptoRng + rand::RngCore>(
_: &Self::PublicKey,
_: Option<(&Self::PrivateKey, &Self::PublicKey)>,
_: &mut R,
) -> Result<(hpke::kem::SharedSecret<Self>, Self::EncappedKey), hpke::HpkeError> {
unreachable!("Never called")
}
}
+55 -22
View File
@@ -1,7 +1,7 @@
use age_core::{ use age_core::{
format::{FileKey, Stanza}, format::{FileKey, Stanza, FILE_KEY_BYTES},
primitives::aead_encrypt, primitives::{aead_decrypt, aead_encrypt, hkdf},
secrecy::ExposeSecret, secrecy::{zeroize::Zeroize, ExposeSecret},
}; };
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine}; use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
use p256::{ use p256::{
@@ -11,11 +11,14 @@ use p256::{
use rand::rngs::OsRng; use rand::rngs::OsRng;
use sha2::Sha256; use sha2::Sha256;
use crate::{p256::Recipient, STANZA_TAG}; use crate::{key::Connection, recipient::TAG_BYTES, util::base64_arg};
mod recipient;
pub(crate) use recipient::Recipient;
const STANZA_TAG: &str = "piv-p256";
pub(crate) const STANZA_KEY_LABEL: &[u8] = b"piv-p256"; pub(crate) const STANZA_KEY_LABEL: &[u8] = b"piv-p256";
const TAG_BYTES: usize = 4;
const EPK_BYTES: usize = 33; const EPK_BYTES: usize = 33;
const ENCRYPTED_FILE_KEY_BYTES: usize = 32; const ENCRYPTED_FILE_KEY_BYTES: usize = 32;
@@ -80,17 +83,6 @@ impl RecipientLine {
return None; return None;
} }
fn base64_arg<A: AsRef<[u8]>, B: AsMut<[u8]>>(arg: &A, mut buf: B) -> Option<B> {
if arg.as_ref().len() != ((4 * buf.as_mut().len()) + 2) / 3 {
return None;
}
BASE64_STANDARD_NO_PAD
.decode_slice_unchecked(arg, buf.as_mut())
.ok()
.and_then(|len| (len == buf.as_mut().len()).then_some(buf))
}
let (tag, epk_bytes) = match &s.args[..] { let (tag, epk_bytes) = match &s.args[..] {
[tag, epk_bytes] => ( [tag, epk_bytes] => (
base64_arg(tag, [0; TAG_BYTES]), base64_arg(tag, [0; TAG_BYTES]),
@@ -109,17 +101,17 @@ impl RecipientLine {
_ => Err(()), _ => Err(()),
}) })
} }
}
pub(crate) fn wrap_file_key(file_key: &FileKey, pk: &Recipient) -> Self { impl Recipient {
pub(crate) fn wrap_file_key(&self, file_key: &FileKey) -> RecipientLine {
let esk = EphemeralSecret::random(&mut OsRng); let esk = EphemeralSecret::random(&mut OsRng);
let epk = esk.public_key(); let epk = esk.public_key();
let epk_bytes = EphemeralKeyBytes::from_public_key(&epk); let epk_bytes = EphemeralKeyBytes::from_public_key(&epk);
let shared_secret = esk.diffie_hellman(pk.public_key()); let shared_secret = esk.diffie_hellman(self.public_key());
let mut salt = vec![]; let salt = salt(&epk_bytes, self.to_encoded());
salt.extend_from_slice(epk_bytes.as_bytes());
salt.extend_from_slice(pk.to_encoded().as_bytes());
let enc_key = { let enc_key = {
let mut okm = [0; 32]; let mut okm = [0; 32];
@@ -137,9 +129,50 @@ impl RecipientLine {
}; };
RecipientLine { RecipientLine {
tag: pk.tag(), tag: self.tag(),
epk_bytes, epk_bytes,
encrypted_file_key, encrypted_file_key,
} }
} }
} }
impl RecipientLine {
pub(crate) fn unwrap_file_key(&self, conn: &mut Connection) -> Result<FileKey, ()> {
let (static_tag, pk) = match conn.recipient() {
crate::recipient::Recipient::PivP256(recipient) => {
(recipient.tag(), recipient.to_encoded())
}
crate::recipient::Recipient::P256Tag(recipient) => {
(recipient.static_tag(), recipient.to_compressed())
}
};
assert_eq!(self.tag, static_tag);
let salt = salt(&self.epk_bytes, pk);
// The YubiKey API for performing scalar multiplication takes the point in its
// uncompressed SEC-1 encoding.
let shared_secret = conn.p256_ecdh(self.epk_bytes.decompress().as_bytes())?;
let enc_key = hkdf(&salt, STANZA_KEY_LABEL, shared_secret.as_ref());
// A failure to decrypt is fatal, because we assume that we won't
// encounter 32-bit collisions on the key tag embedded in the header.
aead_decrypt(&enc_key, FILE_KEY_BYTES, &self.encrypted_file_key)
.map_err(|_| ())
.map(|mut pt| {
FileKey::init_with_mut(|file_key| {
file_key.copy_from_slice(&pt);
pt.zeroize();
})
})
}
}
fn salt(epk_bytes: &EphemeralKeyBytes, pk: p256::EncodedPoint) -> Vec<u8> {
assert!(pk.is_compressed());
let mut salt = vec![];
salt.extend_from_slice(epk_bytes.as_bytes());
salt.extend_from_slice(pk.as_bytes());
salt
}
+5 -27
View File
@@ -1,13 +1,11 @@
use bech32::{ToBase32, Variant}; use age_core::primitives::bech32_encode_to_fmt;
use p256::elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint}; use p256::elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint};
use sha2::{Digest, Sha256};
use yubikey::{certificate::PublicKeyInfo, Certificate};
use std::fmt; use std::fmt;
use crate::RECIPIENT_PREFIX; use crate::recipient::{static_tag, TAG_BYTES};
pub(crate) const TAG_BYTES: usize = 4; const RECIPIENT_PREFIX: bech32::Hrp = bech32::Hrp::parse_unchecked("age1yubikey");
/// Wrapper around a compressed secp256r1 curve point. /// Wrapper around a compressed secp256r1 curve point.
#[derive(Clone)] #[derive(Clone)]
@@ -21,15 +19,7 @@ impl fmt::Debug for Recipient {
impl fmt::Display for Recipient { impl fmt::Display for Recipient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str( bech32_encode_to_fmt(f, RECIPIENT_PREFIX, self.to_encoded().as_bytes())
bech32::encode(
RECIPIENT_PREFIX,
self.to_encoded().as_bytes().to_base32(),
Variant::Bech32,
)
.expect("HRP is valid")
.as_str(),
)
} }
} }
@@ -44,17 +34,6 @@ impl Recipient {
} }
} }
pub(crate) fn from_certificate(cert: &Certificate) -> Option<Self> {
Self::from_spki(cert.subject_pki())
}
pub(crate) fn from_spki(spki: &PublicKeyInfo) -> Option<Self> {
match spki {
PublicKeyInfo::EcP256(pubkey) => Self::from_encoded(pubkey),
_ => None,
}
}
/// Attempts to parse a valid YubiKey recipient from its SEC-1 encoding. /// Attempts to parse a valid YubiKey recipient from its SEC-1 encoding.
/// ///
/// This accepts both compressed (as used by the plugin) and uncompressed (as used in /// This accepts both compressed (as used by the plugin) and uncompressed (as used in
@@ -69,8 +48,7 @@ impl Recipient {
} }
pub(crate) fn tag(&self) -> [u8; TAG_BYTES] { pub(crate) fn tag(&self) -> [u8; TAG_BYTES] {
let tag = Sha256::digest(self.to_encoded().as_bytes()); static_tag(self.to_encoded().as_bytes())
(&tag[0..TAG_BYTES]).try_into().expect("length is correct")
} }
/// Exposes the wrapped public key. /// Exposes the wrapped public key.
+60 -16
View File
@@ -2,12 +2,27 @@ use age_core::format::{FileKey, Stanza};
use age_plugin::{ use age_plugin::{
identity::{self, IdentityPluginV1}, identity::{self, IdentityPluginV1},
recipient::{self, RecipientPluginV1}, recipient::{self, RecipientPluginV1},
Callbacks, Callbacks, PluginHandler,
}; };
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::io; use std::io;
use crate::{fl, format, key, p256::Recipient, PLUGIN_NAME}; use crate::{fl, key, native::p256tag, piv_p256, Recipient, PLUGIN_NAME};
pub(crate) struct Handler;
impl PluginHandler for Handler {
type RecipientV1 = RecipientPlugin;
type IdentityV1 = IdentityPlugin;
fn recipient_v1(self) -> io::Result<Self::RecipientV1> {
Ok(RecipientPlugin::default())
}
fn identity_v1(self) -> io::Result<Self::IdentityV1> {
Ok(IdentityPlugin::default())
}
}
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub(crate) struct RecipientPlugin { pub(crate) struct RecipientPlugin {
@@ -22,11 +37,7 @@ impl RecipientPluginV1 for RecipientPlugin {
plugin_name: &str, plugin_name: &str,
bytes: &[u8], bytes: &[u8],
) -> Result<(), recipient::Error> { ) -> Result<(), recipient::Error> {
if let Some(pk) = if plugin_name == PLUGIN_NAME { if let Some(pk) = Recipient::from_bytes(plugin_name, bytes) {
Recipient::from_bytes(bytes)
} else {
None
} {
self.recipients.push(pk); self.recipients.push(pk);
Ok(()) Ok(())
} else { } else {
@@ -58,6 +69,10 @@ impl RecipientPluginV1 for RecipientPlugin {
} }
} }
fn labels(&mut self) -> HashSet<String> {
HashSet::new()
}
fn wrap_file_keys( fn wrap_file_keys(
&mut self, &mut self,
file_keys: Vec<FileKey>, file_keys: Vec<FileKey>,
@@ -95,7 +110,7 @@ impl RecipientPluginV1 for RecipientPlugin {
self.recipients self.recipients
.iter() .iter()
.chain(yk_recipients.iter()) .chain(yk_recipients.iter())
.map(|pk| format::RecipientLine::wrap_file_key(&file_key, pk).into()) .map(|pk| pk.wrap_file_key(&file_key))
.collect() .collect()
}) })
.collect()) .collect())
@@ -140,16 +155,16 @@ impl IdentityPluginV1 for IdentityPlugin {
let mut file_keys = HashMap::with_capacity(files.len()); let mut file_keys = HashMap::with_capacity(files.len());
// Filter to files / stanzas for which we have matching YubiKeys // Filter to files / stanzas for which we have matching YubiKeys
let mut candidate_stanzas: Vec<(&key::Stub, HashMap<usize, Vec<format::RecipientLine>>)> = let mut candidate_stanzas: Vec<(&key::Stub, HashMap<usize, Vec<SupportedStanza>>)> = self
self.yubikeys .yubikeys
.iter() .iter()
.map(|stub| (stub, HashMap::new())) .map(|stub| (stub, HashMap::new()))
.collect(); .collect();
for (file, stanzas) in files.iter().enumerate() { for (file, stanzas) in files.into_iter().enumerate() {
for (stanza_index, stanza) in stanzas.iter().enumerate() { for (stanza_index, stanza) in stanzas.into_iter().enumerate() {
match ( match (
format::RecipientLine::from_stanza(stanza).map(|res| { SupportedStanza::parse(stanza).map(|res| {
res.map_err(|_| identity::Error::Stanza { res.map_err(|_| identity::Error::Stanza {
file_index: file, file_index: file,
stanza_index, stanza_index,
@@ -163,7 +178,7 @@ impl IdentityPluginV1 for IdentityPlugin {
// A line will match at most one YubiKey. // A line will match at most one YubiKey.
if let Some(files) = if let Some(files) =
candidate_stanzas.iter_mut().find_map(|(stub, files)| { candidate_stanzas.iter_mut().find_map(|(stub, files)| {
if stub.matches(&line) { if line.matches_stub(stub) {
Some(files) Some(files)
} else { } else {
None None
@@ -233,7 +248,7 @@ impl IdentityPluginV1 for IdentityPlugin {
} }
for (stanza_index, line) in stanzas.iter().enumerate() { for (stanza_index, line) in stanzas.iter().enumerate() {
match conn.unwrap_file_key(line) { match line.unwrap_file_key(&mut conn) {
Ok(file_key) => { Ok(file_key) => {
// We've managed to decrypt this file! // We've managed to decrypt this file!
file_keys.entry(file_index).or_insert(Ok(file_key)); file_keys.entry(file_index).or_insert(Ok(file_key));
@@ -255,3 +270,32 @@ impl IdentityPluginV1 for IdentityPlugin {
Ok(file_keys) Ok(file_keys)
} }
} }
enum SupportedStanza {
PivP256(piv_p256::RecipientLine),
P256Tag(p256tag::RecipientLine),
}
impl SupportedStanza {
fn parse(stanza: Stanza) -> Option<Result<Self, ()>> {
piv_p256::RecipientLine::from_stanza(&stanza)
.map(|res| res.map(Self::PivP256))
.or_else(|| {
p256tag::RecipientLine::from_stanza(stanza).map(|res| res.map(Self::P256Tag))
})
}
pub(crate) fn matches_stub(&self, stub: &key::Stub) -> bool {
match self {
SupportedStanza::PivP256(line) => stub.tag == line.tag,
SupportedStanza::P256Tag(line) => line.matches_stub(stub),
}
}
pub(crate) fn unwrap_file_key(&self, conn: &mut key::Connection) -> Result<FileKey, ()> {
match self {
SupportedStanza::PivP256(line) => line.unwrap_file_key(conn),
SupportedStanza::P256Tag(line) => line.unwrap_file_key(conn),
}
}
}
+70
View File
@@ -0,0 +1,70 @@
use std::fmt;
use age_core::format::{FileKey, Stanza};
use sha2::{Digest, Sha256};
use crate::{native::p256tag, piv_p256, util::Metadata, PLUGIN_NAME};
pub(crate) const TAG_BYTES: usize = 4;
#[derive(Clone, Debug)]
pub(crate) enum Recipient {
PivP256(piv_p256::Recipient),
P256Tag(p256tag::Recipient),
}
impl fmt::Display for Recipient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Recipient::PivP256(recipient) => recipient.fmt(f),
Recipient::P256Tag(recipient) => recipient.fmt(f),
}
}
}
impl Recipient {
/// Attempts to parse a supported YubiKey recipient.
pub(crate) fn from_bytes(plugin_name: &str, bytes: &[u8]) -> Option<Self> {
match plugin_name {
PLUGIN_NAME => piv_p256::Recipient::from_bytes(bytes).map(Self::PivP256),
p256tag::PLUGIN_NAME => p256tag::Recipient::from_bytes(bytes).map(Self::P256Tag),
_ => None,
}
}
/// Helper for returning the legacy encoding of this recipient, if any.
pub(crate) fn legacy_recipient(&self, metadata: &Metadata) -> Option<String> {
metadata
.is_pre_p256tag()
.then(|| match self {
Recipient::P256Tag(recipient) => Some(
piv_p256::Recipient::from_bytes(recipient.to_compressed().as_bytes())
.expect("valid")
.to_string(),
),
_ => None,
})
.flatten()
}
/// Returns the static tag for this recipient.
pub(crate) fn static_tag(&self) -> [u8; TAG_BYTES] {
match self {
Recipient::PivP256(recipient) => recipient.tag(),
Recipient::P256Tag(recipient) => recipient.static_tag(),
}
}
pub(crate) fn wrap_file_key(&self, file_key: &FileKey) -> Stanza {
match self {
Recipient::PivP256(recipient) => recipient.wrap_file_key(file_key).into(),
Recipient::P256Tag(recipient) => recipient.wrap_file_key(file_key).into(),
}
}
}
pub(crate) fn static_tag(pk: &[u8]) -> [u8; TAG_BYTES] {
Sha256::digest(pk)[0..TAG_BYTES]
.try_into()
.expect("length is correct")
}
+62 -16
View File
@@ -1,6 +1,7 @@
use std::fmt; use std::fmt;
use std::iter; use std::iter;
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
use x509_parser::{certificate::X509Certificate, der_parser::oid::Oid}; use x509_parser::{certificate::X509Certificate, der_parser::oid::Oid};
use yubikey::{ use yubikey::{
piv::{RetiredSlotId, SlotId}, piv::{RetiredSlotId, SlotId},
@@ -8,7 +9,7 @@ use yubikey::{
}; };
use crate::fl; use crate::fl;
use crate::{error::Error, key::Stub, p256::Recipient, BINARY_NAME, USABLE_SLOTS}; use crate::{error::Error, key::Stub, Recipient, BINARY_NAME, USABLE_SLOTS};
pub(crate) const POLICY_EXTENSION_OID: &[u64] = &[1, 3, 6, 1, 4, 1, 41482, 3, 8]; pub(crate) const POLICY_EXTENSION_OID: &[u64] = &[1, 3, 6, 1, 4, 1, 41482, 3, 8];
@@ -70,7 +71,10 @@ pub(crate) fn otp_serial_prefix(serial: Serial) -> String {
.collect() .collect()
} }
pub(crate) fn extract_name(cert: &X509Certificate, all: bool) -> Option<(String, bool)> { pub(crate) fn extract_name_and_version(
cert: &X509Certificate,
all: bool,
) -> Option<(String, Option<String>)> {
// Look at Subject Organization to determine if we created this. // Look at Subject Organization to determine if we created this.
match cert.subject().iter_organization().next() { match cert.subject().iter_organization().next() {
Some(org) if org.as_str() == Ok(BINARY_NAME) => { Some(org) if org.as_str() == Ok(BINARY_NAME) => {
@@ -83,7 +87,16 @@ pub(crate) fn extract_name(cert: &X509Certificate, all: bool) -> Option<(String,
.map(|s| s.to_owned()) .map(|s| s.to_owned())
.unwrap_or_default(); // TODO: This should always be present. .unwrap_or_default(); // TODO: This should always be present.
Some((name, true)) // We store the binary version as an Organizational Unit attribute.
let version = cert
.subject()
.iter_organizational_unit()
.next()
.and_then(|cn| cn.as_str().ok())
.map(|s| s.to_owned())
.unwrap_or_default(); // TODO: This should always be present.
Some((name, Some(version)))
} }
_ => { _ => {
// Not one of ours, but we've already filtered for compatibility. // Not one of ours, but we've already filtered for compatibility.
@@ -94,7 +107,7 @@ pub(crate) fn extract_name(cert: &X509Certificate, all: bool) -> Option<(String,
// Display the entire subject. // Display the entire subject.
let name = cert.subject().to_string(); let name = cert.subject().to_string();
Some((name, false)) Some((name, None))
} }
} }
} }
@@ -103,6 +116,7 @@ pub(crate) struct Metadata {
serial: Serial, serial: Serial,
slot: RetiredSlotId, slot: RetiredSlotId,
name: String, name: String,
version: Option<String>,
created: String, created: String,
pub(crate) pin_policy: Option<PinPolicy>, pub(crate) pin_policy: Option<PinPolicy>,
pub(crate) touch_policy: Option<TouchPolicy>, pub(crate) touch_policy: Option<TouchPolicy>,
@@ -148,15 +162,13 @@ impl Metadata {
.unwrap_or((None, None)) .unwrap_or((None, None))
}; };
extract_name(&cert, all) extract_name_and_version(&cert, all)
.map(|(name, ours)| { .map(|(name, version)| {
if ours { let (pin_policy, touch_policy) = if version.is_some() {
let (pin_policy, touch_policy) = policies(&cert); policies(&cert)
(name, pin_policy, touch_policy)
} else { } else {
// We can extract the PIN and touch policies via an attestation. This // We can extract the PIN and touch policies via an attestation. This
// is slow, but the user has asked for all compatible keys, so... // is slow, but the user has asked for all compatible keys, so...
let (pin_policy, touch_policy) =
yubikey::piv::attest(yubikey, SlotId::Retired(slot)) yubikey::piv::attest(yubikey, SlotId::Retired(slot))
.ok() .ok()
.and_then(|buf| { .and_then(|buf| {
@@ -164,15 +176,15 @@ impl Metadata {
.map(|(_, c)| policies(&c)) .map(|(_, c)| policies(&c))
.ok() .ok()
}) })
.unwrap_or((None, None)); .unwrap_or((None, None))
};
(name, pin_policy, touch_policy) (name, version, pin_policy, touch_policy)
}
}) })
.map(|(name, pin_policy, touch_policy)| Metadata { .map(|(name, version, pin_policy, touch_policy)| Metadata {
serial: yubikey.serial(), serial: yubikey.serial(),
slot, slot,
name, name,
version,
created: cert created: cert
.validity() .validity()
.not_before .not_before
@@ -182,6 +194,19 @@ impl Metadata {
touch_policy, touch_policy,
}) })
} }
/// Returns `true` if this identity was generated with an `age-plugin-yubikey` version
/// before `p256tag` was added (and became the default).
pub(crate) fn is_pre_p256tag(&self) -> bool {
self.version
.as_ref()
.and_then(|version| version.split_once('.'))
.and_then(|(major, rest)| rest.split_once('.').map(|(minor, _)| (major, minor)))
.is_some_and(|(major, minor)| {
// `p256tag` added in v0.6.0
major == "0" && minor.parse::<u8>().is_ok_and(|minor| minor < 6)
})
}
} }
impl fmt::Display for Metadata { impl fmt::Display for Metadata {
@@ -203,19 +228,40 @@ impl fmt::Display for Metadata {
} }
pub(crate) fn print_identity(stub: Stub, recipient: Recipient, metadata: Metadata) { pub(crate) fn print_identity(stub: Stub, recipient: Recipient, metadata: Metadata) {
let legacy_recipient = recipient.legacy_recipient(&metadata);
let recipient = recipient.to_string(); let recipient = recipient.to_string();
if !console::user_attended() { if !console::user_attended() {
let recipient = recipient.as_str(); let recipient = recipient.as_str();
eprintln!("{}", fl!("print-recipient", recipient = recipient)); eprintln!("{}", fl!("print-recipient", recipient = recipient));
} }
let identity = if let Some(legacy_recipient) = legacy_recipient {
format!(
"{}\n{stub}",
fl!("yubikey-legacy-recipient", recipient = legacy_recipient),
)
} else {
stub.to_string()
};
println!( println!(
"{}", "{}",
fl!( fl!(
"yubikey-identity", "yubikey-identity",
yubikey_metadata = metadata.to_string(), yubikey_metadata = metadata.to_string(),
recipient = recipient, recipient = recipient,
identity = stub.to_string(), identity = identity,
) )
); );
} }
pub(crate) fn base64_arg<A: AsRef<[u8]>, B: AsMut<[u8]>>(arg: &A, mut buf: B) -> Option<B> {
if arg.as_ref().len() != ((4 * buf.as_mut().len()) + 2) / 3 {
return None;
}
BASE64_STANDARD_NO_PAD
.decode_slice_unchecked(arg, buf.as_mut())
.ok()
.and_then(|len| (len == buf.as_mut().len()).then_some(buf))
}