216 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
Jack Grigg ca1cd587ff Merge pull request #184 from str4d/release-0.5.0
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
Release 0.5.0
2024-08-04 04:29:40 +01:00
Jack Grigg 0699991f2b v0.5.0 2024-08-04 03:17:15 +00:00
Jack Grigg 76143524ef cargo update 2024-08-04 03:16:48 +00:00
Jack Grigg fec9dcc994 Merge pull request #183 from str4d/update-deps-0.5.0
Update dependencies for 0.5.0
2024-08-04 02:58:41 +01:00
Jack Grigg 8aeb9f3da7 CI: Test on both MSRV and latest stable 2024-07-30 05:54:36 +00:00
Jack Grigg 34011088a0 Fix 1.67 clippy lints 2024-07-30 05:42:54 +00:00
Jack Grigg 24e6e6ffa6 which 5, test-with 0.11 2024-07-30 05:36:19 +00:00
Jack Grigg a44859d6b2 sysinfo 0.29 2024-07-30 05:33:42 +00:00
Jack Grigg 4921cbf6ed i18n-embed 0.14 2024-07-30 05:31:19 +00:00
Jack Grigg d6729e99ba dialoguer 0.11 2024-07-30 05:27:06 +00:00
Jack Grigg 6452fa0540 age-plugin 0.5 2024-07-30 05:19:27 +00:00
Jack Grigg 71eb71d9c6 cargo update 2024-07-30 05:19:23 +00:00
Jack Grigg f0d82245ea time 0.3.36
Closes str4d/age-plugin-yubikey#182.
2024-07-30 05:19:23 +00:00
Jack Grigg c7e6fdf58a Bump MSRV to 1.67
`time 0.3.35` has MSRV 1.67, and is a required update in order to fix
breakage on Rust 1.80 and above.
2024-07-30 05:05:09 +00:00
Jack Grigg 4a44fb9025 CI: Migrate to codecov-action@4.5.0 2024-07-30 04:59:00 +00:00
Jack Grigg c4256dddba CI: Migrate to cargo-tarpaulin container for code coverage 2024-07-30 04:56:43 +00:00
str4d 5159577135 Merge pull request #163 from str4d/dependabot/github_actions/codecov/codecov-action-3.1.5
Bump codecov/codecov-action from 3.1.1 to 3.1.5
2024-01-28 06:38:11 +00:00
dependabot[bot] e0b944a524 Bump codecov/codecov-action from 3.1.1 to 3.1.5
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 3.1.1 to 3.1.5.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v3.1.1...v3.1.5)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-28 05:07:06 +00:00
str4d eadebfa2f6 Merge pull request #162 from str4d/ci-updates
CI updates
2024-01-28 05:06:21 +00:00
Jack Grigg 137df751fe CI: Fix test job 2024-01-28 04:21:51 +00:00
Jack Grigg a24de089e0 CI: Remove pointless timeouts 2024-01-28 04:21:44 +00:00
Jack Grigg 982b9d2f95 CI: actions/checkout@v4 2024-01-28 04:21:44 +00:00
str4d 0ebbc31890 Merge pull request #161 from str4d/packages
Add entries for existing packages to README
2024-01-28 03:58:20 +00:00
Jack Grigg d373d531e5 Add entries for existing packages to README 2024-01-28 03:45:04 +00:00
str4d 72da2b46c7 Merge pull request #149 from str4d/dependabot/github_actions/svenstaro/upload-release-action-2.6.1
Bump svenstaro/upload-release-action from 2.5.0 to 2.6.1
2024-01-28 03:32:14 +00:00
str4d d31c60f733 Merge pull request #158 from robinp/patch-1
Add Arch linux notes
2024-01-22 20:02:51 +00:00
str4d d7d47e861d Merge pull request #151 from pierreprinetti/readme_fedora
Add installation instructions for Fedora Linux
2024-01-22 20:02:20 +00:00
Robin Palotai 9bb39a7b95 Add Arch linux notes
I had `pcscd` already installed, but age-plugin-yubikey didn't work initially.

I installed `pcsc-tools` so I could do `pcsc_scan`, which also found nothing. Then I installed `yubikey-manager` so I could `ykman info`, which worked, but after that magically pcsc_scan and age-plugin-yubikey were working too.

Not sure if installing `yubikey-manager` had to do with it, but anyway.
2023-12-21 14:54:08 +01:00
Pierre Prinetti 7c512d283c Add installation instructions for Fedora Linux
Add entries in the README to get the PC/SC dependency ready under
Fedora.
2023-06-21 16:25:36 +02:00
dependabot[bot] 351d40e0c7 Bump svenstaro/upload-release-action from 2.5.0 to 2.6.1
Bumps [svenstaro/upload-release-action](https://github.com/svenstaro/upload-release-action) from 2.5.0 to 2.6.1.
- [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.5.0...2.6.1)

---
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>
2023-06-01 09:01:22 +00:00
str4d acc783d0d3 Merge pull request #142 from str4d/update-release-workflow
Update release workflow
2023-04-09 10:34:52 +01:00
Jack Grigg 379adcb909 Update release workflow
- `ubuntu-18.04` runner has been deprecated and removed by GitHub. We
  now use `ubuntu-20.04`.
- `actions-rs/toolchain` is replaced by `dtolnay/rust-toolchain`.
2023-04-09 09:16:42 +00:00
str4d e77f00ff48 Merge pull request #141 from str4d/release-0.4.0
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
Release 0.4.0
2023-04-09 09:20:57 +01:00
Jack Grigg 3d8a5e9076 v0.4.0 2023-04-09 08:08:39 +00:00
Jack Grigg 00db2d1948 cargo update 2023-04-09 08:08:25 +00:00
str4d d7111594a7 Merge pull request #140 from str4d/improve-age-identity-docs
Clarify significance of the age identity file
2023-04-09 09:03:16 +01:00
Jack Grigg 20b84b9d4e Clarify significance of the age identity file
Replaces str4d/age-plugin-yubikey#122.
2023-04-09 07:38:23 +00:00
str4d 1182f472dd Merge pull request #139 from str4d/135-better-mgmt-key-auth-error
Provide a better error message when management key authentication fails
2023-04-09 08:13:12 +01:00
Jack Grigg 62f237f859 Provide a better error message when management key authentication fails
We now indicate to the user that AES management key algorithms are not
yet supported, and tell them how to change their management key to use
TDES.

Closes str4d/age-plugin-yubikey#135.
2023-04-09 06:53:25 +00:00
str4d 22fd00bd22 Merge pull request #138 from str4d/refactor-installation-docs
Refactor installation docs
2023-03-23 09:19:31 +00:00
Jack Grigg b6030ec4bd Note libpcsclite-dev dependency for Cargo builds in readme 2023-03-23 17:43:26 +09:00
Jack Grigg 9730e3da74 Clean up "Installation" readme section with tables 2023-03-23 17:38:17 +09:00
str4d 95657fd844 Merge pull request #130 from VlkrS/VlkrS-bsd-ccid
Explicitly mention CCID packages for the two BSDs
2023-03-23 08:30:07 +00:00
str4d a7bfeb9c3f Merge pull request #129 from str4d/78-ignore-readers-without-piv-applet
Ignore smart cards that don't have a PIV applet
2023-03-23 06:40:39 +00:00
Jack Grigg ac04615219 Ignore smart cards that don't have a PIV applet
Closes str4d/age-plugin-yubikey#78.
2023-03-23 14:33:13 +09:00
str4d cddb00ab95 Merge pull request #127 from str4d/update-deps
Update dependencies
2023-03-23 05:31:44 +00:00
Jack Grigg 7c532639d4 Fix clippy lints 2023-03-23 14:20:20 +09:00
Jack Grigg d2a856dd48 sysinfo 0.28 2023-03-23 14:13:21 +09:00
Jack Grigg d5395ea4e5 yubikey 0.8.0-pre.0 2023-03-23 14:07:13 +09:00
Jack Grigg 0d0f64ff1b base64 0.21 2023-03-23 14:07:13 +09:00
Jack Grigg edf4c5a45d cargo update 2023-03-23 13:42:51 +09:00
Jack Grigg ae39e136de Bump MSRV to 1.65.0
`yubikey 0.8.0` will have this MSRV, and it includes changes we need.
2023-03-23 13:33:06 +09:00
str4d defc1c1131 Merge pull request #133 from str4d/dependabot/github_actions/svenstaro/upload-release-action-2.5.0
Bump svenstaro/upload-release-action from 2.4.0 to 2.5.0
2023-03-23 04:09:30 +00:00
str4d af71a17f51 Merge pull request #137 from str4d/ci-updates
Update CI
2023-03-21 07:38:56 +00:00
Jack Grigg 02a81adfeb Update CI 2023-03-21 15:14:15 +08:00
dependabot[bot] 956d3bf28a Bump svenstaro/upload-release-action from 2.4.0 to 2.5.0
Bumps [svenstaro/upload-release-action](https://github.com/svenstaro/upload-release-action) from 2.4.0 to 2.5.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.4.0...2.5.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>
2023-02-22 08:18:32 +00:00
str4d d9c5abaa15 Merge pull request #128 from str4d/115-yubikey-4-no-pin-cache
Document and warn that PIN policy "Once" doesn't work for YubiKey 4
2023-02-16 18:34:11 +00:00
str4d c5ca61a37c Merge pull request #131 from str4d/improve-puk-errors
Intercept PIN errors and replace with PUK errors as necessary
2023-02-16 18:33:05 +00:00
Jack Grigg e86cd8113c Intercept PIN errors and replace with PUK errors as necessary
Once iqlusioninc/yubikey.rs#479 is part of a `yubikey` release we
can migrate to, this will mean that users get correctly notified
of incorrect PUK entry, instead of being told it is an incorrect
PIN issue.
2023-02-12 20:47:58 +00:00
VlkrS e037c1c883 Mention ccid packages for {Open,Free}BSD
Addresses #112
2023-02-12 16:10:43 +01:00
VlkrS 051cace1fd Mention ccid packages for {Open,Free}BSD
Addresses #112
2023-02-12 16:08:47 +01:00
Jack Grigg 4e053b5efc TUI: Warn YubiKey 4 users of issue with PIN policy "Once" 2023-02-11 22:04:14 +00:00
Jack Grigg 762adfe098 Document that PIN cache preservation doesn't work for YubiKey 4 2023-02-11 21:26:44 +00:00
str4d 70c109aa1d Merge pull request #126 from str4d/integration-tests
Add some integration tests
2023-02-11 18:52:41 +00:00
Jack Grigg 355ce1cfde Add integration tests that require a live YubiKey slot 2023-02-11 18:40:09 +00:00
Jack Grigg 3408998283 Add encryption test with rage 2023-02-11 18:40:09 +00:00
str4d bf437663af Merge pull request #116 from str4d/avoid-resetting-unused-yubikeys
Avoid resetting unused YubiKeys
2023-02-11 04:57:23 +00:00
Jack Grigg 665aedbbba Merge branch 'version-0.3.3' back into main 2023-02-11 04:51:58 +00:00
str4d 307f5396a8 Merge pull request #124 from str4d/release-0.3.3
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
Release 0.3.3
2023-02-11 04:37:09 +00:00
Jack Grigg cd03e7bda3 Release 0.3.3 2023-02-11 04:28:16 +00:00
str4d 54ad666c73 Merge pull request #123 from str4d/120-prevent-default-pin
Prevent changing the default PIN to itself
2023-02-11 03:00:31 +00:00
Jack Grigg d2132b4ac2 Prevent changing the default PIN to itself
Closes str4d/age-plugin-yubikey#120.
2023-02-11 02:47:55 +00:00
str4d 80e8072624 Merge pull request #117 from str4d/more-smartcard-errors
Treat `pcsc::Error::NoSmartcard` as a "YubiKey disconnected" error
2023-02-11 02:18:34 +00:00
Jack Grigg ff3e8e37c9 Treat pcsc::Error::NoSmartcard as a "YubiKey disconnected" error
Some SmartCard readers report this error when no SmartCard is inserted,
so we need to check for it when filtering for connected YubiKeys (along
with `pcsc::Error::RemovedCard` which some _other_ SmartCard readers
report instead).

Closes str4d/age-plugin-yubikey#81.
2023-01-30 00:39:08 +00:00
str4d a5178bb16e Merge pull request #118 from str4d/correctly-handle-short-pins
Enforce correct PIN lengths during YubiKey setup
2023-01-30 00:37:44 +00:00
Jack Grigg b1710e8d69 Enforce correct PIN lengths during YubiKey setup
The behaviour of `age-plugin-yubikey` during setup now matches its
behaviour during plugin usage.
2023-01-29 23:00:46 +00:00
str4d 8483010393 Merge pull request #108 from str4d/dependabot/github_actions/svenstaro/upload-release-action-2.4.0
Bump svenstaro/upload-release-action from 2.3.0 to 2.4.0
2023-01-29 15:38:18 +00:00
Jack Grigg 55bfa5dafb Avoid resetting YubiKeys that don't match the desired serial
If multiple YubiKeys were connected, and the one we needed for plugin
encryption or decryption was not first in the list of readers, any
YubiKeys before it were being reset upon drop. We now explicitly
disconnect without resetting, since we only access these YubiKeys as a
side-effect of finding the one we need.
2023-01-29 14:16:35 +00:00
Jack Grigg 90b61682bd Don't reset tested YubiKeys in key::filter_connected
This method only connects to YubiKeys in order to confirm it can do so
(i.e. as a side-effect). We therefore want to explicitly disconnect
without resetting the YubiKeys, to avoid clearing PIN caches.
2023-01-29 14:16:35 +00:00
dependabot[bot] 97ddfc3bea Bump svenstaro/upload-release-action from 2.3.0 to 2.4.0
Bumps [svenstaro/upload-release-action](https://github.com/svenstaro/upload-release-action) from 2.3.0 to 2.4.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.3.0...2.4.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>
2023-01-09 08:13:17 +00:00
str4d f34c534e84 Merge pull request #102 from tekumara/patch-1
docs: add brew install instructions
2023-01-03 02:31:27 +00:00
str4d 681cd06087 Merge pull request #103 from str4d/101-fix-gen-pin-always
Request PIN before certificate generation if PIN policy is "always"
2023-01-03 02:25:21 +00:00
Jack Grigg 45f6580be4 Request PIN before certificate generation if PIN policy is "always"
We also correctly ask for a PIN touch after the key is generated (which
does not need it) but before certificate generation (which does if the
touch policy is not "none").

Closes str4d/age-plugin-yubikey#101.
2023-01-03 02:20:51 +00:00
Oliver Mannion f5c8f7a559 docs: add brew install instructions 2023-01-03 10:10:45 +11:00
str4d e26ed3a163 Merge pull request #100 from str4d/pin-caching
Enable PIN caching
2023-01-02 19:12:11 +00:00
Jack Grigg 04c0418c02 Update README and CHANGELOG for PIN cache changes 2023-01-02 19:06:37 +00:00
Jack Grigg 00ab2c756e Don't re-request a cached PIN for identities with PIN policy "once" 2023-01-02 19:06:37 +00:00
Jack Grigg 9418921dab Disconnect without resetting YubiKeys if it is safe to do so
This enables the PIN caches to be preserved across age-plugin-yubikey
processes, allowing PIN policies of "once" to become meaningful.
2023-01-02 19:06:37 +00:00
str4d 87541510ad Merge pull request #99 from VlkrS/main
Add pcscd recommentation for {Open,Free}BSD
2023-01-01 14:43:41 +00:00
vlkrs 9ac72cd66f Add recommendations for {Open,Free}BSD when pcscd isn't running. 2023-01-01 15:30:40 +01:00
VlkrS 093a35733f Add pcscd instructions for FreeBSD and OpenBSD 2023-01-01 15:16:42 +01:00
str4d 082a4f976c Merge pull request #88 from str4d/msrv-1.60
Bump MSRV to 1.60
2023-01-01 14:12:31 +00:00
Jack Grigg 3e1f3b45f5 sysinfo 0.27 2023-01-01 14:07:23 +00:00
Jack Grigg a6a5ad109e Fix clippy lints 2023-01-01 14:07:23 +00:00
Jack Grigg 25fcd353f3 dialoguer 0.10 2023-01-01 14:07:22 +00:00
Jack Grigg d8ab6e373e base64 0.20 2023-01-01 14:06:35 +00:00
Jack Grigg 0490dd3529 env_logger 0.10 2023-01-01 14:06:35 +00:00
Jack Grigg f45ff653b8 Bump cryptographic dependencies
- age-plugin 0.4
- bech32 0.9
- p256 0.11
- sha2 0.10
- x509-parser 0.14
- yubikey 0.7
2023-01-01 14:06:34 +00:00
Jack Grigg e78871d6f3 cargo update 2023-01-01 14:04:28 +00:00
Jack Grigg ec83a8c9cc Bump MSRV to 1.60.0 2023-01-01 14:04:27 +00:00
str4d fc2081c216 Merge pull request #98 from str4d/release-0.3.2
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
Release 0.3.2
2023-01-01 13:53:55 +00:00
Jack Grigg 367a081eea Release 0.3.2 2023-01-01 13:45:21 +00:00
str4d cfb1e5e3d5 Merge pull request #97 from str4d/more-cleanups
More cleanups
2023-01-01 13:44:15 +00:00
Jack Grigg 1dfadc7e27 Clean up key::filter_connected 2023-01-01 13:29:30 +00:00
Jack Grigg fc66d9f6fd Add helper methods for filtering available keys 2023-01-01 13:27:10 +00:00
Jack Grigg d8eb198e97 Move certificate parsing into Metadata::extract 2023-01-01 13:27:10 +00:00
str4d c8f9df1b45 Merge pull request #95 from str4d/94-yubikey-agent-sighup
Extend "sharing violation" logic to send SIGHUP to `yubikey-agent` processes
2023-01-01 13:24:57 +00:00
Jack Grigg 3597d96332 Correctly hunt agents in plugin mode 2023-01-01 13:18:41 +00:00
Jack Grigg 1913838f8e Hunt for yubikey-agent 2023-01-01 12:52:17 +00:00
Jack Grigg 6e47448560 Generalise code for hunting agents that may be holding YubiKeys 2023-01-01 12:52:17 +00:00
str4d 4d4d8cc183 Merge pull request #96 from str4d/refactors-and-cleanups
Refactors and cleanups
2022-12-31 16:41:21 +00:00
Jack Grigg ac7b04a61d Add keyword argument support to fl! and wlnfl! macros 2022-12-31 14:31:25 +00:00
Jack Grigg 493479344c De-duplicate parsing recipients from SubjectPublicKeyInfo 2022-12-31 12:49:44 +00:00
str4d d4f8993988 Merge pull request #93 from str4d/release-0.3.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 / 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
Release 0.3.1
2022-12-30 12:04:50 +00:00
Jack Grigg 876afecc5c Release 0.3.1 2022-12-30 11:57:17 +00:00
str4d 539111c30c Merge pull request #91 from str4d/tdes-mgmkey-advice
Clarify that non-TDES management keys are unsupported
2022-12-30 10:51:52 +00:00
Jack Grigg 647a620a9c Clarify that non-TDES management keys are unsupported
Supporting them is blocked on iqlusioninc/yubikey.rs#330.
2022-12-30 10:39:34 +00:00
str4d 0eee944c64 Merge pull request #90 from str4d/21-mgmkey-advice
Give guidance on reconfiguring YubiKeys with unprotected management keys
2022-12-30 10:23:51 +00:00
Jack Grigg e4ef700263 Give guidance on reconfiguring YubiKeys with unprotected management keys
Closes str4d/age-plugin-yubikey#21.
2022-12-30 10:18:17 +00:00
str4d 492612fc8b Merge pull request #89 from str4d/82-stop-scdaemon
Stop scdaemon if it is holding exclusive access to a YubiKey
2022-12-30 09:36:47 +00:00
Jack Grigg 15c53e42df Stop scdaemon if it is holding exclusive access to a YubiKey
Closes str4d/age-plugin-yubikey#82.
2022-12-30 09:28:24 +00:00
str4d 5d6b618d5f Merge pull request #87 from str4d/71-m1-binaries
CI: Build release binaries for Apple ARM64
2022-12-30 03:44:24 +00:00
Jack Grigg 0ee618cdfd CI: Build release binaries for Apple ARM64
Closes str4d/age-plugin-yubikey#71.
2022-12-30 03:38:40 +00:00
str4d ed6273d781 Merge pull request #86 from str4d/83-improve-missing-service-error
Inform users when `pcscd` is required
2022-12-29 10:10:42 +00:00
Jack Grigg d38743a2fc Inform users when pcscd is required
Closes str4d/age-plugin-yubikey#83.
2022-12-29 05:09:47 +00:00
str4d aaa445c4ac Merge pull request #74 from str4d/dependabot/github_actions/svenstaro/upload-release-action-2.3.0
Bump svenstaro/upload-release-action from 2.2.1 to 2.3.0
2022-12-28 14:01:29 +00:00
Jack Grigg e415ce4ae9 CI: Remove upload-release-action fields that match defaults 2022-12-28 13:53:56 +00:00
str4d 145237003e Merge pull request #79 from str4d/dependabot/github_actions/codecov/codecov-action-3.1.1
Bump codecov/codecov-action from 3.1.0 to 3.1.1
2022-12-28 13:48:15 +00:00
dependabot[bot] 9338b320a4 Bump codecov/codecov-action from 3.1.0 to 3.1.1
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 3.1.0 to 3.1.1.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v3.1.0...v3.1.1)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-09-20 08:29:52 +00:00
dependabot[bot] aae48c4f6c Bump svenstaro/upload-release-action from 2.2.1 to 2.3.0
Bumps [svenstaro/upload-release-action](https://github.com/svenstaro/upload-release-action) from 2.2.1 to 2.3.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.2.1...2.3.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>
2022-06-06 08:56:16 +00:00
Jack Grigg 08ba0aa1ec Update Cargo.lock
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 (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
2022-05-02 01:35:10 +00:00
Jack Grigg 71bd3b1afb v0.3.0 2022-05-02 01:24:53 +00:00
Jack Grigg 8199a7bcfc age-plugin 0.3 2022-05-02 01:21:52 +00:00
str4d b825956bf8 Merge pull request #67 from str4d/48-fix-panic-with-default
Check the length of the bytes passed to `Stub::from_bytes`
2022-05-01 21:28:14 +01:00
Jack Grigg fb5a1060bd Check the length of the bytes passed to Stub::from_bytes
This will be zero-length when the client uses `-j yubikey`.

Closes str4d/age-plugin-yubikey#48.
2022-05-01 17:59:24 +00:00
str4d b9842213da Merge pull request #63 from str4d/skippable-yubikeys
Enable users to skip YubiKeys at plugging-in time
2022-05-01 16:20:32 +01:00
Jack Grigg f8314c5d6d Enable users to skip YubiKeys at plugging-in time
This requires the `confirm` plugin command to be supported by the age
client; otherwise we fall back to the previous message-plus-timer
method.
2022-05-01 15:14:36 +00:00
str4d 3b0da8bd25 Merge pull request #64 from str4d/remove-touch-request-message
Don't print message if YubiKey is waiting for touch
2022-05-01 15:54:42 +01:00
Jack Grigg 345c155bb4 Don't print message if YubiKey is waiting for touch
The user call-to-action will instead be implemented on the client side,
where it can be done in a more forgiving way (allowing the user some
time to react before prompting them that it is waiting on the plugin).
2022-05-01 14:47:55 +00:00
str4d 1ad79cb577 Merge pull request #66 from str4d/57-translations
Add translation support via Fluent
2022-05-01 15:46:26 +01:00
Jack Grigg a92a843e14 Tag all strings for translation 2022-05-01 14:40:05 +00:00
Jack Grigg c4fe3f6b1a Add support for translations 2022-05-01 11:49:06 +00:00
str4d 9db9c65bf7 Merge pull request #65 from str4d/42-list-supported-yubikeys
List supported YubiKey variants
2022-05-01 01:36:26 +01:00
Jack Grigg 1b5db6f871 List supported YubiKey variants
Closes str4d/age-plugin-yubikey#42.
2022-05-01 00:35:11 +00:00
str4d 5f07cb318d Merge pull request #53 from str4d/dependabot/github_actions/svenstaro/upload-release-action-2.2.1
Bump svenstaro/upload-release-action from 2.2.0 to 2.2.1
2022-04-30 21:07:08 +01:00
str4d 71227314f4 Merge pull request #61 from str4d/dependabot/github_actions/codecov/codecov-action-3.1.0
Bump codecov/codecov-action from 1.0.3 to 3.1.0
2022-04-30 21:06:56 +01:00
dependabot[bot] f2f0b40af8 Bump codecov/codecov-action from 1.0.3 to 3.1.0
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 1.0.3 to 3.1.0.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v1.0.3...v3.1.0)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-22 08:33:17 +00:00
str4d fe5bab1277 Merge pull request #56 from str4d/edition-2021
Migrate to 2021 edition
2022-03-20 16:58:41 +00:00
Jack Grigg 5afec288c9 Forbid unsafe code 2022-03-20 16:53:39 +00:00
Jack Grigg 7d2e3a6829 Remove trait imports that are in the 2021 edition prelude 2022-03-20 16:53:39 +00:00
Jack Grigg 74b76d3f5e Migrate to 2021 edition
Closes str4d/age-plugin-yubikey#52.
2022-03-20 16:53:39 +00:00
str4d 6fd6a205ca Merge pull request #55 from str4d/msrv-1.56
Bump MSRV to 1.56.0
2022-03-20 16:43:00 +00:00
Jack Grigg 0b930efc49 cargo update 2022-03-20 16:24:29 +00:00
Jack Grigg 2fd5af8538 Bump MSRV to 1.56.0
Closes str4d/age-plugin-yubikey#51.
2022-03-20 16:23:52 +00:00
dependabot[bot] 5814f5f1a0 Bump svenstaro/upload-release-action from 2.2.0 to 2.2.1
Bumps [svenstaro/upload-release-action](https://github.com/svenstaro/upload-release-action) from 2.2.0 to 2.2.1.
- [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.2.0...2.2.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-20 15:25:25 +00:00
str4d 37c813e0a7 Merge pull request #50 from str4d/ci-updates
CI updates
2022-03-20 15:25:06 +00:00
Jack Grigg 60b36e39c8 CI: Use ubuntu-18.04 for Linux release builder
GitHub removed `ubuntu-16.04` in September 2021, which causes the
release workflow to hang waiting on a runner that will never be made
available.
2022-03-20 15:18:19 +00:00
Jack Grigg da9212c135 CI: Add Dependabot config to keep GitHub Actions up-to-date 2022-03-20 15:18:19 +00:00
Jack Grigg dcdf306d3a CI: Refactor rustfmt CI job 2022-03-20 15:18:19 +00:00
Jack Grigg f0ba5724bf CI: Migrate to actions/checkout@v3 2022-03-20 15:18:19 +00:00
str4d f1bb46d274 Merge pull request #46 from str4d/37-ignore-otp-string
Detect invalid PIN lengths and ask the user again
2022-03-20 14:57:48 +00:00
str4d b3590f4ce1 Merge pull request #45 from str4d/44-request-touch
Print message if YubiKey is waiting for touch
2021-12-20 03:23:49 +00:00
Jack Grigg 2fc2dcd1c3 Unify the touch request messages
This is also a good test for sending emojis through the age plugin IPC
protocol.
2021-12-20 03:22:25 +00:00
Jack Grigg 68ac19017e Detect invalid PIN lengths and ask the user again
We also detect the specific case where the PIN returned by the user is
likely a YubiKey OTP, generated by the user touching it early.

Closes str4d/age-plugin-yubikey#37.
2021-12-19 01:31:54 +00:00
Jack Grigg e5bdffa5cc Print message if YubiKey is waiting for touch
Closes str4d/age-plugin-yubikey#44.
2021-12-19 00:28:27 +00:00
str4d 51910edfab Merge pull request #41 from str4d/release-0.2.0
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.51.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 (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
Release 0.2.0
2021-11-22 02:40:05 +00:00
Jack Grigg 4f30e2e6f6 v0.2.0 2021-11-22 02:27:14 +00:00
str4d 37f1a07b60 Merge pull request #38 from str4d/update-deps
Update dependencies
2021-11-21 17:38:06 +00:00
Jack Grigg 822a10f8f6 yubikey 0.5 2021-11-21 15:51:54 +00:00
Jack Grigg b486276421 cargo update 2021-11-21 11:38:18 +00:00
Jack Grigg c7ad7a671b Add rust-toolchain file with MSRV 2021-11-21 10:54:07 +00:00
Jack Grigg f2237ed2a7 yubikey 0.4 2021-10-18 21:11:42 +01:00
Jack Grigg 399f0b4c11 Rename crate::yubikey to crate::key
So that it doesn't conflict with the renamed `yubikey` crate.
2021-10-18 21:07:23 +01:00
Jack Grigg 22dfc3ee89 env_logger 0.9 2021-10-18 20:42:10 +01:00
Jack Grigg 72d5682454 console 0.15, dialoguer 0.9 2021-10-18 20:40:11 +01:00
Jack Grigg 77bd7aa3a3 age-plugin 0.2 2021-10-18 20:37:28 +01:00
Jack Grigg 5c8a7cced8 cargo update 2021-10-18 20:33:33 +01:00
str4d 6042d5266f Merge pull request #35 from str4d/ux-improvements
UX improvements
2021-08-20 16:30:46 +01:00
Jack Grigg f5f140d172 Fix various clippy lints 2021-08-20 16:22:22 +01:00
Jack Grigg 2c90195f99 Check PIN policy before requesting PIN
Closes str4d/age-plugin-yubikey#34.
2021-08-20 15:11:39 +01:00
Jack Grigg 30f4d00902 Move verify_pin after Stub::connect
If all we want is to determine the recipient, we don't need to verify
the PIN.

Closes str4d/age-plugin-yubikey#30.
2021-08-20 15:08:14 +01:00
Jack Grigg 7f43d15942 Use CLI error type to render errors from yubikey.verify_pin()
This ensures that the attempts-before-blocked counter is displayed to
users during the plugin protocol.
2021-08-20 13:18:11 +01:00
27 changed files with 5526 additions and 1677 deletions
+12
View File
@@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: daily
timezone: Etc/UTC
open-pull-requests-limit: 10
reviewers:
- str4d
assignees:
- str4d
+66 -99
View File
@@ -1,10 +1,13 @@
name: CI checks
on: [push, pull_request]
on:
pull_request:
push:
branches: main
jobs:
test:
name: Test on ${{ matrix.name }}
test-msrv:
name: Test MSRV on ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
@@ -22,130 +25,94 @@ jobs:
os: macos-latest
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
toolchain: 1.51.0
override: true
- uses: actions/checkout@v4
- name: Install build dependencies
run: sudo apt install ${{ matrix.build_deps }}
if: matrix.build_deps != ''
- name: cargo fetch
uses: actions-rs/cargo@v1
with:
command: fetch
- name: Build tests
uses: actions-rs/cargo@v1
with:
command: build
args: --verbose --tests
- uses: dtolnay/rust-toolchain@stable
id: stable-toolchain
- name: Install test dependencies using latest stable Rust
run: cargo +${{steps.stable-toolchain.outputs.name}} install rage
- name: Run tests
uses: actions-rs/cargo@v1
with:
command: test
args: --verbose
run: cargo test
- name: Verify working directory is clean
run: git diff --exit-code
clippy:
name: Clippy (1.51.0)
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
toolchain: 1.51.0
components: clippy
override: true
- name: Install build dependencies
run: sudo apt install libpcsclite-dev
- name: Run clippy
uses: actions-rs/clippy-check@v1
with:
name: Clippy (1.51.0)
token: ${{ secrets.GITHUB_TOKEN }}
args: --all-features --all-targets -- -D warnings
test-latest:
name: Test latest stable on ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
name: [linux, windows, macos]
include:
- name: linux
os: ubuntu-latest
build_deps: >
libpcsclite-dev
- name: windows
os: windows-latest
- name: macos
os: macos-latest
clippy-nightly:
name: Clippy (nightly)
timeout-minutes: 30
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
toolchain: nightly
components: clippy
override: true
- uses: actions/checkout@v4
- name: Install build dependencies
run: sudo apt install libpcsclite-dev
- name: Run Clippy (nightly)
uses: actions-rs/clippy-check@v1
continue-on-error: true
with:
name: Clippy (nightly)
token: ${{ secrets.GITHUB_TOKEN }}
args: --all-features --all-targets
run: sudo apt install ${{ matrix.build_deps }}
if: matrix.build_deps != ''
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@stable
id: toolchain
- run: rustup override set ${{steps.toolchain.outputs.name}}
- name: Install test dependencies
run: cargo install rage
- name: Remove lockfile to build with latest dependencies
run: rm Cargo.lock
- name: Run tests
run: cargo test
- name: Verify working directory is clean (excluding lockfile)
run: git diff --exit-code ':!Cargo.lock'
codecov:
name: Code coverage
runs-on: ubuntu-latest
container:
image: xd009642/tarpaulin:develop-nightly
options: --security-opt seccomp=unconfined
steps:
- uses: actions/checkout@v2
# Use stable for this to ensure that cargo-tarpaulin can be built.
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- uses: actions/checkout@v4
- name: Install build dependencies
run: sudo apt install libpcsclite-dev
run: apt update && apt install -y libpcsclite-dev
- name: Generate coverage report
uses: actions-rs/tarpaulin@v0.1
with:
args: --release --timeout 180 --out Xml
run: >
cargo tarpaulin
--engine llvm
--timeout 180
--out xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v1.0.3
uses: codecov/codecov-action@v4.5.0
with:
token: ${{secrets.CODECOV_TOKEN}}
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
doc-links:
name: Intra-doc links
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
toolchain: 1.51.0
override: true
- uses: actions/checkout@v4
- name: Install build dependencies
run: sudo apt install libpcsclite-dev
- name: cargo fetch
uses: actions-rs/cargo@v1
with:
command: fetch
# Ensure intra-documentation links all resolve correctly
# Requires #![deny(intra_doc_link_resolution_failure)] in crates.
- run: cargo fetch
# Requires #![deny(rustdoc::broken_intra_doc_links)] in crates.
- name: Check intra-doc links
uses: actions-rs/cargo@v1
with:
command: doc
args: --document-private-items
run: cargo doc --document-private-items
fmt:
name: Rustfmt
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
toolchain: 1.51.0
override: true
- run: rustup component add rustfmt
- uses: actions-rs/cargo@v1
with:
command: fmt
args: -- --check
- uses: actions/checkout@v4
- name: Check formatting
run: cargo fmt -- --check
+22
View File
@@ -0,0 +1,22 @@
name: Beta lints
# We only run these lints on trial-merges of PRs to reduce noise.
on: pull_request
jobs:
clippy-beta:
name: Clippy (beta)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@beta
id: toolchain
- run: rustup override set ${{steps.toolchain.outputs.name}}
- name: Install build dependencies
run: sudo apt install libpcsclite-dev
- name: Clippy (beta)
uses: actions-rs/clippy-check@v1
with:
name: Clippy (beta)
token: ${{ secrets.GITHUB_TOKEN }}
args: --all-features --all-targets -- -W clippy::all
+19
View File
@@ -0,0 +1,19 @@
name: Stable lints
# We only run these lints on trial-merges of PRs to reduce noise.
on: pull_request
jobs:
clippy:
name: Clippy (MSRV)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install build dependencies
run: sudo apt install libpcsclite-dev
- name: Run clippy
uses: actions-rs/clippy-check@v1
with:
name: Clippy (MSRV)
token: ${{ secrets.GITHUB_TOKEN }}
args: --all-features --all-targets -- -D warnings
+25 -20
View File
@@ -17,10 +17,14 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
name: [linux, windows, macos]
name:
- linux
- macos-arm64
- macos-x86_64
- windows
include:
- name: linux
os: ubuntu-16.04
os: ubuntu-20.04
build_deps: >
libpcsclite-dev
archive_name: age-plugin-yubikey.tar.gz
@@ -31,17 +35,23 @@ jobs:
archive_name: age-plugin-yubikey.zip
asset_suffix: x86_64-windows.zip
- name: macos
- name: macos-arm64
os: macos-latest
target: aarch64-apple-darwin
build_flags: --target aarch64-apple-darwin
archive_name: age-plugin-yubikey.tar.gz
asset_suffix: arm64-darwin.tar.gz
- name: macos-x86_64
os: macos-latest
archive_name: age-plugin-yubikey.tar.gz
asset_suffix: x86_64-darwin.tar.gz
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
id: toolchain
- run: rustup override set ${{steps.toolchain.outputs.name}}
- name: Add target
run: rustup target add ${{ matrix.target }}
if: matrix.target != ''
@@ -76,18 +86,16 @@ jobs:
if: matrix.name == 'windows'
- name: Upload archive to release
uses: svenstaro/upload-release-action@2.2.0
uses: svenstaro/upload-release-action@2.9.0
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ matrix.archive_name }}
asset_name: age-plugin-yubikey-$tag-${{ matrix.asset_suffix }}
tag: ${{ github.ref }}
prerelease: true
if: github.event.inputs.test != 'true'
deb:
name: Debian ${{ matrix.name }}
runs-on: ubuntu-18.04
runs-on: ubuntu-20.04
strategy:
matrix:
name: [linux]
@@ -98,11 +106,10 @@ jobs:
libpcsclite-dev
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
id: toolchain
- run: rustup override set ${{steps.toolchain.outputs.name}}
- name: Add target
run: rustup target add ${{ matrix.target }}
- name: cargo install cargo-deb
@@ -137,11 +144,9 @@ jobs:
args: --package age-plugin-yubikey --no-build --target ${{ matrix.target }}
- name: Upload Debian package to release
uses: svenstaro/upload-release-action@2.2.0
uses: svenstaro/upload-release-action@2.9.0
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: target/${{ matrix.target }}/debian/*.deb
tag: ${{ github.ref }}
file_glob: true
prerelease: true
if: github.event.inputs.test != 'true'
+114
View File
@@ -0,0 +1,114 @@
# Changelog
All notable changes to this crate will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to Rust's notion of
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). All versions prior
to 0.3.0 are beta releases.
## [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
### Fixed
- `age-plugin-yubikey` can now be compiled with Rust 1.80 and above.
### Changed
- MSRV is now 1.67.0.
## [0.4.0] - 2023-04-09
### Changed
- MSRV is now 1.65.0.
- The YubiKey PIV PIN and touch caches are now preserved across processes in
most cases. See [README.md](README.md#agent-support) for exceptions. This has
several usability effects (not applicable to YubiKey 4 series):
- If a YubiKey's PIN is cached by an agent like `yubikey-agent`, and then
`age-plugin-yubikey` is run (either directly or as a plugin), the agent
won't request a PIN entry on its next use.
- If a YubiKey's PIN was requested by either a previous invocation of
`age-plugin-yubikey` or an agent like `yubikey-agent`, subsequent calls to
`age-plugin-yubikey` won't request a PIN entry to decrypt a file with an
identity that has a PIN policy of `once`.
### Fixed
- Identities can now be generated with a PIN policy of "always" (in previous
versions of `age-plugin-yubikey` this would cause an error).
## [0.3.3] - 2023-02-11
### Fixed
- When `age-plugin-yubikey` assists the user in changing their PIN from the
default PIN, it no longer tells the user that PINs shorter than 6 characters
are allowed, and instead loops until the user enters a PIN of valid length.
It also now prevents the user from setting their PIN to the default PIN, to
avoid creating a cycle.
- More kinds of SmartCard readers are ignored when they have no SmartCard
inserted.
## [0.3.2] - 2023-01-01
### Changed
- The "sharing violation" logic now also sends SIGHUP to any `yubikey-agent`
that is running, to have them release any YubiKey locks they are holding.
### Fixed
- The "sharing violation" logic now runs during plugin mode as intended. In the
previous release it only ran during direct `age-plugin-yubikey` usage.
## [0.3.1] - 2022-12-30
### Changed
- If a "sharing violation" error is encountered while opening a connection to a
YubiKey, and `scdaemon` is running (which can hold exclusive access to a
YubiKey indefinitely), `age-plugin-yubikey` now attempts to stop `scdaemon` by
interrupting it (or killing it on Windows), and then tries again to open the
connection.
- Several error messages were enhanced with guidance on how to resolve their
respective issue.
## [0.3.0] - 2022-05-02
First non-beta release!
### Changed
- MSRV is now 1.56.0.
- During decryption, when asked to insert a YubiKey, you can now choose to skip
it, allowing the client to move on to the next identity instead of returning
an error.
- Certain kinds of PIN invalidity will now cause the plugin to re-request the
PIN instead of aborting: if the PIN is too short or too long, or if the user
touched the YubiKey early and "typed" an OTP.
### Fixed
- The "default" identity (provided by clients that invoke `age-plugin-yubikey`
using `-j yubikey`) previously caused a panic. It is now correctly treated as
an invalid identity (because this plugin does not support default identities).
## [0.2.0] - 2021-11-22
### Fixed
- Attempts-before-blocked counter is now returned as part of the invalid PIN
error string.
- PIN is no longer requested when fetching the recipient for a slot, or when
decrypting with a slot that has a PIN policy of Never.
- Migrated to `yubikey 0.5` to fix `cargo install age-plugin-yubikey` error
(caused by the `yubikey-piv` crate being yanked after it was renamed).
## [0.1.0] - 2021-05-02
Initial beta release.
Generated
+2656 -583
View File
File diff suppressed because it is too large Load Diff
+38 -19
View File
@@ -1,19 +1,19 @@
[package]
name = "age-plugin-yubikey"
description = "[BETA] YubiKey plugin for age clients"
version = "0.1.0"
description = "YubiKey plugin for age clients"
version = "0.5.1"
authors = ["Jack Grigg <thestr4d@gmail.com>"]
repository = "https://github.com/str4d/age-plugin-yubikey"
readme = "README.md"
keywords = ["age", "cli", "encryption", "yubikey"]
categories = ["command-line-utilities", "cryptography"]
license = "MIT OR Apache-2.0"
edition = "2018"
edition = "2021"
rust-version = "1.70" # MSRV
[package.metadata.deb]
extended-description = """\
An age plugin adding support for YubiKeys and other PIV hardware tokens. \
Currently in BETA; we strongly recommend using this with a new YubiKey."""
An age plugin adding support for YubiKeys and other PIV hardware tokens."""
section = "utils"
assets = [
["target/release/age-plugin-yubikey", "usr/bin/", "755"],
@@ -22,26 +22,45 @@ assets = [
]
[dependencies]
age-core = "0.6"
age-plugin = "0.1"
base64 = "0.13"
bech32 = "0.8"
console = "0.14"
dialoguer = "0.8"
env_logger = "0.8"
age-core = "0.11"
age-plugin = "0.6"
base64 = "0.22"
bech32 = "0.11"
console = { version = "0.15", default-features = false }
dialoguer = { version = "0.11", default-features = false, features = ["password"] }
env_logger = "0.10"
gumdrop = "0.8"
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"
p256 = { version = "0.7", features = ["ecdh"] }
p256 = { version = "0.13", features = ["ecdh"] }
pcsc = "2.4"
rand = "0.7"
secrecy = "0.7"
sha2 = "0.9"
which = "4.1"
rand = "0.8"
sha2 = "0.10"
which = "5"
x509 = "0.2"
x509-parser = "0.9"
yubikey-piv = { version = "0.3", features = ["untested"] }
x509-parser = "0.14"
# Translations
i18n-embed = { version = "0.15", features = ["desktop-requester", "fluent-system"] }
i18n-embed-fl = "0.9"
lazy_static = "1"
rust-embed = "8"
# GnuPG coexistence
sysinfo = "0.29"
[dev-dependencies]
flate2 = "1"
man = "0.3"
tempfile = "3"
test-with = "0.11"
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" }
+82 -24
View File
@@ -4,23 +4,45 @@
like [`age`](https://age-encryption.org) and [`rage`](https://str4d.xyz/rage),
which enables files to be encrypted to age identities stored on YubiKeys.
This plugin is in **BETA**; we strongly recommend using this with a new YubiKey,
or one that you do not care about.
## Installation
| Environment | CLI command |
|-------------|-------------|
| Cargo (Rust 1.70+) | `cargo install age-plugin-yubikey` |
| Homebrew (macOS or Linux) | `brew install age-plugin-yubikey` |
| Arch Linux | `pacman -S age-plugin-yubikey` |
| Debian | [Debian package](https://github.com/str4d/age-plugin-yubikey/releases) |
| NixOS | Add to config:<br>`environment.systemPackages = [`<br>` pkgs.age-plugin-yubikey`<br>`];`<br>Or run `nix-env -i age-plugin-yubikey` |
| Ubuntu 20.04+ | [Debian package](https://github.com/str4d/age-plugin-yubikey/releases) |
| OpenBSD | `pkg_add age-plugin-yubikey` (security/age-plugin-yubikey) |
On Windows, Linux, and macOS, you can use the
[pre-built binaries](https://github.com/str4d/age-plugin-yubikey/releases).
If your system has Rust 1.51+ installed (either via `rustup` or a system
package), you can build directly from source:
```
cargo install age-plugin-yubikey
```
Help from new packagers is very welcome.
### Linux, BSD, etc.
On non-Windows, non-macOS systems, you need to ensure that the `pcscd` service
is installed and running.
| Environment | CLI command |
|-------------|-------------|
| Debian or Ubuntu | `sudo apt-get install pcscd` |
| Fedora | `sudo dnf install pcsc-lite` |
| OpenBSD | As ```root``` do:<br>`pkg_add pcsc-lite ccid`<br>`rcctl enable pcscd`<br>`rcctl start pcscd` |
| FreeBSD | As ```root``` do:<br>`pkg install pcsc-lite libccid`<br>`service pcscd enable`<br>`service pcscd start` |
| Arch | `sudo pacman -S pcsclite pcsc-tools yubikey-manager`<br>`sudo systemctl enable pcscd`<br>`sudo systemctl start pcscd`|
When installing via Cargo, you also need to ensure that the development headers
for the `pcsc-lite` library are available, so that the `pcsc-sys` crate can be
compiled.
| Environment | CLI command |
|-------------|-------------|
| Debian or Ubuntu | `sudo apt-get install libpcsclite-dev` |
| Fedora | `sudo dnf install pcsc-lite-devel` |
### Windows Subsystem for Linux (WSL)
WSL does not currently provide native support for USB devices. However, Windows
@@ -38,7 +60,12 @@ YubiKey:
## Configuration
There are two ways to configure a YubiKey as an `age` identity. You can run the
`age-plugin-yubikey` identities have two parts:
- The secret key material, which is stored inside a YubiKey.
- An age identity file, which contains information that an age client can use to
figure out which YubiKey secret key should be used.
There are two ways to configure a YubiKey as an age identity. You can run the
plugin binary directly to use a simple text interface, which will create an age
identity file:
@@ -64,6 +91,14 @@ Once an identity has been created, you can regenerate it later:
$ age-plugin-yubikey --identity [--serial SERIAL] --slot SLOT
```
To use the identity with an age client, it needs to be stored in a file. When
using the above programmatic flags, you can do this by redirecting standard
output to a file. On a Unix system like macOS or Ubuntu:
```
$ age-plugin-yubikey --identity --slot SLOT > yubikey-identity.txt
```
## Usage
The age recipients contained in all connected YubiKeys can be printed on
@@ -73,33 +108,56 @@ standard output:
$ age-plugin-yubikey --list
```
To encrypt files to these YubiKey recipients, ensure that `age-plugin-yubikey`
is accessible in your `PATH`, and then use the recipients with an age client as
normal (e.g. `rage -r age1yubikey1...`).
To encrypt files to these YubiKey recipients, ensure you have a recent version
of an age client, and then use the recipients with it as normal (e.g.
`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
all recipients (e.g. `age -R filename.txt`).
To decrypt files encrypted to a YubiKey identity, pass the identity file to the
age client as normal (e.g. `rage -d -i yubikey-identity.txt`).
To decrypt files encrypted to a YubiKey identity, ensure that
`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
### Agent support
`age-plugin-yubikey` does not provide or interact with an agent for decryption.
As age plugin binaries have short lifetimes (they only run while the age client
is running), this means that YubiKey identities configured with a PIN policy of
`once` will actually prompt for the PIN on every decryption.
It does however attempt to preserve the PIN cache by not soft-resetting the
YubiKey after a decryption or read-only operation, which enables YubiKey
identities configured with a PIN policy of `once` to not prompt for the PIN on
every decryption. **This does not work for YubiKey 4 series.**
A decryption agent will most likely be implemented as a separate age plugin that
interacts with [`yubikey-agent`](https://github.com/FiloSottile/yubikey-agent),
enabling YubiKeys to be used simultaneously with age and SSH.
The session that corresponds to the `once` policy can be ended in several ways,
not all of which are necessarily intuitive:
- Unplugging the YubiKey (the obvious way).
- Using a different applet (e.g. FIDO2). This causes the PIV applet to be closed
which clears its state.
- This is why the YubiKey 4 series does not support PIN cache preservation:
their serial can only be obtained by switching to the OTP applet.
- Generating a new age identity via `age-plugin-yubikey --generate` or the CLI
interface. This is to avoid leaving the YubiKey authenticated with the
management key.
If the current PIN UX proves to be insufficient, a decryption agent will most
likely be implemented as a separate age plugin that interacts with
[`yubikey-agent`](https://github.com/FiloSottile/yubikey-agent), enabling
YubiKeys to be used simultaneously with age and SSH.
### Manual setup and technical details
`age-plugin-yubikey` only officially supports YubiKeys set up either via the
text interface or the `--generate` flag.
`age-plugin-yubikey` only officially supports the following YubiKey variants,
set up either via the text interface or the `--generate` flag:
- YubiKey 4 series
- YubiKey 5 series
NOTE: Nano and USB-C variants of the above are also supported. The pre-YK4
YubiKey NEO series is **NOT** supported. The blue "Security Key by Yubico" will
also not work (as it doesn't support PIV).
In practice, any PIV token with an ECDSA P-256 key and certificate in one of the
20 "retired" slots should work. You can list all age-compatible keys with:
+1 -1
View File
@@ -6,7 +6,7 @@ use std::io::prelude::*;
const MANPAGES_DIR: &str = "./target/manpages";
fn generate_manpage(page: String, name: &str) {
let file = File::create(format!("{}/{}.1.gz", MANPAGES_DIR, name))
let file = File::create(format!("{MANPAGES_DIR}/{name}.1.gz"))
.expect("Should be able to open file in target directory");
let mut encoder = GzEncoder::new(file, Compression::best());
encoder
+4
View File
@@ -0,0 +1,4 @@
fallback_language = "en-US"
[fluent]
assets_dir = "i18n"
+254
View File
@@ -0,0 +1,254 @@
# Copyright 2022 Jack Grigg
#
# Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
# http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
# <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
# option. This file may not be copied, modified, or distributed
# except according to those terms.
### Localization for strings in age-plugin-yubikey
-age = age
-yubikey = YubiKey
-yubikeys = YubiKeys
-age-plugin-yubikey = age-plugin-yubikey
-pcscd = pcscd
## CLI commands and flags
-cmd-generate = --generate
-cmd-identity = --identity
-cmd-list = --list
-cmd-list-all = --list-all
-flag-force = --force
-flag-serial = --serial
-flag-slot = --slot
## YubiKey metadata
pin-policy-always = Always (A PIN is required for every decryption, if set)
pin-policy-once = Once (A PIN is required once per session, if set)
pin-policy-never = Never (A PIN is NOT required to decrypt)
touch-policy-always = Always (A physical touch is required for every decryption)
touch-policy-cached = Cached (A physical touch is required for decryption, and is cached for 15 seconds)
touch-policy-never = Never (A physical touch is NOT required to decrypt)
unknown-policy = Unknown
yubikey-metadata =
# Serial: {$serial}, Slot: {$slot}
# Name: {$name}
# Created: {$created}
# PIN policy: {$pin_policy}
# Touch policy: {$touch_policy}
yubikey-legacy-recipient =
# Legacy recipient: {$recipient}
yubikey-identity =
{$yubikey_metadata}
# Recipient: {$recipient}
{$identity}
## CLI setup via text interface
cli-setup-intro =
✨ Let's get your {-yubikey} set up for {-age}! ✨
This tool can create a new {-age} identity in a free slot of your {-yubikey}.
It will generate an identity file that you can use with an {-age} client,
along with the corresponding recipient. You can also do this directly
with:
{" "}{$generate_usage}
If you are already using a {-yubikey} with {-age}, you can select an existing
slot to recreate its corresponding identity file and recipient.
When asked below to select an option, use the up/down arrow keys to
make your choice, or press [Esc] or [q] to quit.
cli-setup-insert-yk = ⏳ Please insert the {-yubikey} you want to set up.
cli-setup-yk-name = {$yubikey_name} (Serial: {$yubikey_serial})
cli-setup-select-yk = 🔑 Select a {-yubikey}
cli-setup-slot-usable = Slot {$slot_index} ({$slot_name})
cli-setup-slot-unusable = Slot {$slot_index} (Unusable)
cli-setup-slot-empty = Slot {$slot_index} (Empty)
cli-setup-select-slot = 🕳️ Select a slot for your {-age} identity
cli-setup-name-identity = 📛 Name this identity
cli-setup-select-pin-policy = 🔤 Select a PIN policy
cli-setup-select-touch-policy = 👆 Select a touch policy
cli-setup-yk4-pin-policy =
⚠️ Your {-yubikey} is a {-yubikey} 4 series. With ephemeral applications like
{-age-plugin-yubikey}, a PIN policy of "Once" behaves like a PIN policy of
"Always", and your PIN will be requested for every decryption. However, you
might still benefit from a PIN policy of "Once" in long-running applications
like agents.
cli-setup-yk4-pin-policy-confirm = Use PIN policy of "Once" with {-yubikey} 4?
cli-setup-generate-new = Generate new identity in slot {$slot_index}?
cli-setup-use-existing = Use existing identity in slot {$slot_index}?
cli-setup-identity-file-name = 📝 File name to write this identity to
cli-setup-identity-file-exists = File exists. Overwrite it?
cli-setup-finished =
✅ Done! This {-yubikey} identity is ready to go.
🔑 { $is_new ->
[true] Here's your shiny new {-yubikey} recipient:
*[false] Here's the corresponding {-yubikey} recipient:
}
{" "}{$recipient}
Here are some example things you can do with it:
- Encrypt a file to this identity:
{" "}{$encrypt_usage}
- Decrypt a file with this identity:
{" "}{$decrypt_usage}
- Recreate the identity file:
{" "}{$identity_usage}
- Recreate the recipient:
{" "}{$recipient_usage}
💭 Remember: everything breaks, have a backup plan for when this {-yubikey} does.
## Programmatic usage
open-yk-with-serial = ⏳ Please insert the {-yubikey} with serial {$yubikey_serial}.
open-yk-without-serial = ⏳ Please insert the {-yubikey}.
warn-yk-not-connected = Ignoring {$yubikey_name}: not connected
warn-yk-missing-applet = Ignoring {$yubikey_name}: Missing {$applet_name} applet
print-recipient = Recipient: {$recipient}
printed-kind-identities = identities
printed-kind-recipients = recipients
printed-multiple = Generated {$kind} for {$count} slots. If you intended to select a slot, use {-flag-slot}.
## YubiKey management
mgr-enter-pin = Enter PIN for {-yubikey} with serial {$yubikey_serial} (default is {$default_pin})
mgr-change-default-pin =
✨ Your {-yubikey} is using the default PIN. Let's change it!
✨ We'll also set the PUK equal to the PIN.
🔐 The PIN can be numbers, letters, or symbols. Not just numbers!
📏 The PIN must be at least 6 and at most 8 characters in length.
❌ Your keys will be lost if the PIN and PUK are locked after 3 incorrect tries.
mgr-enter-current-puk = Enter current PUK (default is {$default_puk})
mgr-choose-new-pin = Choose a new PIN/PUK
mgr-repeat-new-pin = Repeat the PIN/PUK
mgr-pin-mismatch = PINs don't match
mgr-nope-default-pin = You entered the default PIN again. You need to change it.
mgr-changing-mgmt-key =
✨ Your {-yubikey} is using the default management key.
✨ We'll migrate it to a PIN-protected management key.
mgr-changing-mgmt-key-error =
An error occurred while setting the new management key.
⚠️ SAVE THIS MANAGEMENT KEY - YOU MAY NEED IT TO MANAGE YOUR {-yubikey}! ⚠️
{" "}{$management_key}
mgr-changing-mgmt-key-success = Success!
## YubiKey keygen
builder-gen-key = 🎲 Generating key...
builder-gen-cert = 🔏 Generating certificate...
builder-touch-yk = 👆 Please touch the {-yubikey}
## Plugin usage
plugin-err-invalid-recipient = Invalid recipient
plugin-err-invalid-identity = Invalid {-yubikey} stub
plugin-err-invalid-stanza = Invalid {-yubikey} stanza
plugin-err-decryption-failed = Failed to decrypt {-yubikey} stanza
plugin-insert-yk = Please insert {-yubikey} with serial {$yubikey_serial}
plugin-yk-is-plugged-in = {-yubikey} is plugged in
plugin-skip-this-yk = Skip this {-yubikey}
plugin-insert-yk-retry = Could not open {-yubikey}. Please insert {-yubikey} with serial {$yubikey_serial}
plugin-err-yk-not-found = Could not find {-yubikey} with serial {$yubikey_serial}
plugin-err-yk-opening = Could not open {-yubikey} with serial {$yubikey_serial}
plugin-err-yk-timed-out = Timed out while waiting for {-yubikey} with serial {$yubikey_serial} to be inserted
plugin-err-yk-stub-mismatch = A {-yubikey} stub did not match the {-yubikey}
plugin-err-yk-invalid-pin-policy = Certificate for {-yubikey} identity contains an invalid PIN policy
plugin-enter-pin = Enter PIN for {-yubikey} with serial {$yubikey_serial}
plugin-err-accidental-touch = Did you touch the {-yubikey} by accident?
plugin-err-pin-too-short = PIN was too short.
plugin-err-pin-too-long = PIN was too long.
plugin-err-pin-required = A PIN is required for {-yubikey} with serial {$yubikey_serial}
## Errors
err-mgmt-key-auth = Failed to authenticate with the PIN-protected management key.
rec-mgmt-key-auth =
Check whether your management key is using the TDES algorithm.
AES is not supported yet: {$aes_url}
err-custom-mgmt-key = Custom unprotected non-TDES management keys are not supported.
rec-change-mgmt-key =
You can use the {-yubikey} Manager CLI to change to a protected management key:
{" "}{$cmd}
See here for more information about {-yubikey} Manager:
{" "}{$url}
err-invalid-flag-command = Flag '{$flag}' cannot be used with '{$command}'.
err-invalid-flag-tui = Flag '{$flag}' cannot be used with the interactive interface.
err-invalid-pin-policy = Invalid PIN policy '{$policy}' (expected [{$expected}]).
err-invalid-slot = Invalid slot '{$slot}' (expected number between 1 and 20).
err-invalid-touch-policy = Invalid touch policy '{$policy}' (expected [{$expected}]).
err-io-user = Failed to get input from user: {$err}
err-io = Failed to set up {-yubikey}: {$err}
err-multiple-commands = Only one of {-cmd-generate}, {-cmd-identity}, {-cmd-list}, {-cmd-list-all} can be specified.
err-multiple-yubikeys = Multiple {-yubikeys} are plugged in. Use {-flag-serial} to select a single {-yubikey}.
err-no-empty-slots = {-yubikey} with serial {$serial} has no empty slots.
err-no-matching-serial = Could not find {-yubikey} with serial {$serial}.
err-slot-has-no-identity = Slot {$slot} does not contain an {-age} identity or compatible key.
err-slot-is-not-empty = Slot {$slot} is not empty. Use {-flag-force} to overwrite the slot.
err-timed-out = Timed out while waiting for a {-yubikey} to be inserted.
err-use-list-for-single = Use {-cmd-list} to print the recipient for a single slot.
err-yk-no-service-macos = The Crypto Token Kit service is not running.
rec-yk-no-service-macos =
You may need to restart it. See this Stack Exchange answer for more help:
{" "}{$url}
err-yk-no-service-pcscd = {-pcscd} is not running.
rec-yk-no-service-pcscd =
If you are on Debian or Ubuntu, you can install it with:
{" "}{$apt}
rec-yk-no-service-pcscd-bsd =
You can install and run it as root with:
{" "}{$pkg}
{" "}{$service_enable}
{" "}{$service_start}
err-yk-no-service-win = The Smart Cards for Windows service is not running.
rec-yk-no-service-win =
See this troubleshooting guide for more help:
{" "}{$url}
err-yk-not-found = Please insert the {-yubikey} you want to set up
err-yk-general = Error while communicating with {-yubikey}: {$err}
err-yk-general-cause = Cause: {$inner_err}
err-yk-wrong-pin = Invalid {$pin_kind} ({$tries ->
[one] {$tries} try remaining
*[other] {$tries} tries remaining
} before it is blocked)
err-yk-pin-locked = {$pin_kind} locked
err-ux-A = Did this not do what you expected? Could an error be more useful?
err-ux-B = Tell us
# Put (len(A) - len(B) - 46) spaces here.
err-ux-C = {" "}
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "1.70.0"
components = ["clippy", "rustfmt"]
+35 -22
View File
@@ -1,18 +1,19 @@
use dialoguer::Password;
use rand::{rngs::OsRng, RngCore};
use x509::RelativeDistinguishedName;
use yubikey_piv::{
certificate::{Certificate, PublicKeyInfo},
key::{generate as yubikey_generate, AlgorithmId, RetiredSlotId, SlotId},
policy::{PinPolicy, TouchPolicy},
Key, YubiKey,
use yubikey::{
certificate::Certificate,
piv::{generate as yubikey_generate, AlgorithmId, RetiredSlotId, SlotId},
Key, PinPolicy, TouchPolicy, YubiKey,
};
use crate::{
error::Error,
p256::Recipient,
fl,
key::{self, Stub},
native::p256tag,
util::{Metadata, POLICY_EXTENSION_OID},
yubikey::{self, Stub},
BINARY_NAME, USABLE_SLOTS,
Recipient, BINARY_NAME, USABLE_SLOTS,
};
pub(crate) const DEFAULT_PIN_POLICY: PinPolicy = PinPolicy::Once;
@@ -87,16 +88,12 @@ impl IdentityBuilder {
let pin_policy = self.pin_policy.unwrap_or(DEFAULT_PIN_POLICY);
let touch_policy = self.touch_policy.unwrap_or(DEFAULT_TOUCH_POLICY);
eprintln!("{}", fl!("builder-gen-key"));
// No need to ask for users to enter their PIN if the PIN policy requires it,
// because here we _always_ require them to enter their PIN in order to access the
// protected management key (which is necessary in order to generate identities).
yubikey::manage(yubikey)?;
if let TouchPolicy::Never = touch_policy {
// No need to touch YubiKey
} else {
eprintln!("👆 Please touch the YubiKey");
}
key::manage(yubikey)?;
// Generate a new key in the selected slot.
let generated = yubikey_generate(
@@ -107,14 +104,14 @@ impl IdentityBuilder {
touch_policy,
)?;
let recipient = match &generated {
PublicKeyInfo::EcP256(pubkey) => {
Recipient::from_encoded(pubkey).expect("YubiKey generates a valid pubkey")
}
_ => unreachable!(),
};
let recipient = Recipient::P256Tag(
p256tag::Recipient::from_spki(&generated).expect("YubiKey generates a valid pubkey"),
);
let stub = Stub::new(yubikey.serial(), slot, &recipient);
eprintln!();
eprintln!("{}", fl!("builder-gen-cert"));
// Pick a random serial for the new self-signed certificate.
let mut serial = [0; 20];
OsRng.fill_bytes(&mut serial);
@@ -123,6 +120,23 @@ impl IdentityBuilder {
.name
.unwrap_or(format!("age identity {}", hex::encode(stub.tag)));
if let PinPolicy::Always = pin_policy {
// We need to enter the PIN again.
let pin = Password::new()
.with_prompt(fl!(
"plugin-enter-pin",
yubikey_serial = yubikey.serial().to_string(),
))
.report(true)
.interact()?;
yubikey.verify_pin(pin.as_bytes())?;
}
if let TouchPolicy::Never = touch_policy {
// No need to touch YubiKey
} else {
eprintln!("{}", fl!("builder-touch-yk"));
}
let cert = Certificate::generate_self_signed(
yubikey,
SlotId::Retired(slot),
@@ -140,7 +154,6 @@ impl IdentityBuilder {
)],
)?;
let (_, cert) = x509_parser::parse_x509_certificate(cert.as_ref()).unwrap();
let metadata = Metadata::extract(yubikey, slot, &cert, false).unwrap();
Ok((
+131 -68
View File
@@ -1,27 +1,45 @@
use std::fmt;
use std::io;
use yubikey_piv::{key::RetiredSlotId, Serial};
use yubikey::{piv::RetiredSlotId, Serial};
use crate::util::slot_to_ui;
macro_rules! wlnfl {
($f:ident, $message_id:literal) => {
writeln!($f, "{}", $crate::fl!($message_id))
};
($f:ident, $message_id:literal, $($kwarg:expr),* $(,)*) => {{
writeln!($f, "{}", $crate::fl!($message_id, $($kwarg,)*))
}};
}
pub enum Error {
CustomManagementKey,
Dialog(dialoguer::Error),
InvalidFlagCommand(String, String),
InvalidFlagTui(String),
InvalidPinLength,
InvalidPinPolicy(String),
InvalidSlot(u8),
InvalidTouchPolicy(String),
Io(io::Error),
ManagementKeyAuth,
MultipleCommands,
MultipleYubiKeys,
NoEmptySlots(Serial),
NoMatchingSerial(Serial),
PukLocked,
SlotHasNoIdentity(RetiredSlotId),
SlotIsNotEmpty(RetiredSlotId),
TimedOut,
UseListForSingleSlot,
YubiKey(yubikey_piv::Error),
WrongPuk(u8),
YubiKey(yubikey::Error),
}
impl From<dialoguer::Error> for Error {
fn from(e: dialoguer::Error) -> Self {
Error::Dialog(e)
}
}
impl From<io::Error> for Error {
@@ -30,8 +48,8 @@ impl From<io::Error> for Error {
}
}
impl From<yubikey_piv::error::Error> for Error {
fn from(e: yubikey_piv::error::Error) -> Self {
impl From<yubikey::Error> for Error {
fn from(e: yubikey::Error) -> Self {
Error::YubiKey(e)
}
}
@@ -40,91 +58,136 @@ impl From<yubikey_piv::error::Error> for Error {
// manually to provide the error output we want.
impl fmt::Debug for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
const CHANGE_MGMT_KEY_CMD: &str =
"ykman piv access change-management-key -a TDES --protect";
const CHANGE_MGMT_KEY_URL: &str = "https://developers.yubico.com/yubikey-manager/";
match self {
Error::CustomManagementKey => {
writeln!(f, "Custom unprotected management keys are not supported.")?
wlnfl!(f, "err-custom-mgmt-key")?;
wlnfl!(
f,
"rec-change-mgmt-key",
cmd = CHANGE_MGMT_KEY_CMD,
url = CHANGE_MGMT_KEY_URL
)?;
}
Error::InvalidFlagCommand(flag, command) => {
writeln!(f, "Flag '{}' cannot be used with '{}'.", flag, command)?
Error::Dialog(e) => wlnfl!(f, "err-io-user", err = e.to_string())?,
Error::InvalidFlagCommand(flag, command) => wlnfl!(
f,
"err-invalid-flag-command",
flag = flag.as_str(),
command = command.as_str(),
)?,
Error::InvalidFlagTui(flag) => wlnfl!(f, "err-invalid-flag-tui", flag = flag.as_str())?,
Error::InvalidPinPolicy(s) => wlnfl!(
f,
"err-invalid-pin-policy",
policy = s.as_str(),
expected = "always, once, never",
)?,
Error::InvalidSlot(slot) => wlnfl!(f, "err-invalid-slot", slot = slot)?,
Error::InvalidTouchPolicy(s) => wlnfl!(
f,
"err-invalid-touch-policy",
policy = s.as_str(),
expected = "always, cached, never",
)?,
Error::Io(e) => wlnfl!(f, "err-io", err = e.to_string())?,
Error::ManagementKeyAuth => {
let aes_url = "https://github.com/str4d/age-plugin-yubikey/issues/92";
wlnfl!(f, "err-mgmt-key-auth")?;
wlnfl!(f, "rec-mgmt-key-auth", aes_url = aes_url)?;
wlnfl!(
f,
"rec-change-mgmt-key",
cmd = CHANGE_MGMT_KEY_CMD,
url = CHANGE_MGMT_KEY_URL
)?;
}
Error::InvalidFlagTui(flag) => writeln!(
f,
"Flag '{}' cannot be used with the interactive interface.",
flag
)?,
Error::InvalidPinLength => writeln!(f, "The PIN needs to be 1-8 characters.")?,
Error::InvalidPinPolicy(s) => writeln!(
f,
"Invalid PIN policy '{}' (expected [always, once, never]).",
s
)?,
Error::InvalidSlot(slot) => writeln!(
f,
"Invalid slot '{}' (expected number between 1 and 20).",
slot
)?,
Error::InvalidTouchPolicy(s) => writeln!(
f,
"Invalid touch policy '{}' (expected [always, cached, never]).",
s
)?,
Error::Io(e) => writeln!(f, "Failed to set up YubiKey: {}", e)?,
Error::MultipleCommands => writeln!(
f,
"Only one of --generate, --identity, --list, --list-all can be specified."
)?,
Error::MultipleYubiKeys => writeln!(
f,
"Multiple YubiKeys are plugged in. Use --serial to select a single YubiKey."
)?,
Error::MultipleCommands => wlnfl!(f, "err-multiple-commands")?,
Error::MultipleYubiKeys => wlnfl!(f, "err-multiple-yubikeys")?,
Error::NoEmptySlots(serial) => {
writeln!(f, "YubiKey with serial {} has no empty slots.", serial)?
wlnfl!(f, "err-no-empty-slots", serial = serial.to_string())?
}
Error::NoMatchingSerial(serial) => {
writeln!(f, "Could not find YubiKey with serial {}.", serial)?
wlnfl!(f, "err-no-matching-serial", serial = serial.to_string())?
}
Error::SlotHasNoIdentity(slot) => writeln!(
f,
"Slot {} does not contain an age identity or compatible key.",
slot_to_ui(slot)
)?,
Error::SlotIsNotEmpty(slot) => writeln!(
f,
"Slot {} is not empty. Use --force to overwrite the slot.",
slot_to_ui(slot)
)?,
Error::TimedOut => {
writeln!(f, "Timed out while waiting for a YubiKey to be inserted.")?
Error::PukLocked => wlnfl!(f, "err-yk-pin-locked", pin_kind = "PUK")?,
Error::SlotHasNoIdentity(slot) => {
wlnfl!(f, "err-slot-has-no-identity", slot = slot_to_ui(slot))?
}
Error::UseListForSingleSlot => {
writeln!(f, "Use --list to print the recipient for a single slot.")?
Error::SlotIsNotEmpty(slot) => {
wlnfl!(f, "err-slot-is-not-empty", slot = slot_to_ui(slot))?
}
Error::TimedOut => wlnfl!(f, "err-timed-out")?,
Error::UseListForSingleSlot => wlnfl!(f, "err-use-list-for-single")?,
Error::WrongPuk(tries) => {
wlnfl!(f, "err-yk-wrong-pin", pin_kind = "PUK", tries = tries)?
}
Error::YubiKey(e) => match e {
yubikey_piv::error::Error::NotFound => {
writeln!(f, "Please insert the YubiKey you want to set up")?
yubikey::Error::NotFound => wlnfl!(f, "err-yk-not-found")?,
yubikey::Error::PcscError {
inner: Some(pcsc::Error::NoService),
} => {
if cfg!(windows) {
wlnfl!(f, "err-yk-no-service-win")?;
let url = "https://learn.microsoft.com/en-us/windows/security/identity-protection/smart-cards/smart-card-debugging-information#smart-card-service";
wlnfl!(f, "rec-yk-no-service-win", url = url)?;
} else if cfg!(target_os = "macos") {
wlnfl!(f, "err-yk-no-service-macos")?;
let url = "https://apple.stackexchange.com/a/438198";
wlnfl!(f, "rec-yk-no-service-macos", url = url)?;
} else if cfg!(target_os = "openbsd") {
wlnfl!(f, "err-yk-no-service-pcscd")?;
let pkg = "pkg_add pcsc-lite ccid";
let service_enable = "rcctl enable pcscd";
let service_start = "rcctl start pcscd";
wlnfl!(
f,
"rec-yk-no-service-pcscd-bsd",
pkg = pkg,
service_enable = service_enable,
service_start = service_start
)?;
} else if cfg!(target_os = "freebsd") {
wlnfl!(f, "err-yk-no-service-pcscd")?;
let pkg = "pkg install pcsc-lite libccid";
let service_enable = "service pcscd enable";
let service_start = "service pcscd start";
wlnfl!(
f,
"rec-yk-no-service-pcscd-bsd",
pkg = pkg,
service_enable = service_enable,
service_start = service_start
)?;
} else {
wlnfl!(f, "err-yk-no-service-pcscd")?;
let apt = "sudo apt-get install pcscd";
wlnfl!(f, "rec-yk-no-service-pcscd", apt = apt)?;
}
}
yubikey::Error::PinLocked => wlnfl!(f, "err-yk-pin-locked", pin_kind = "PIN")?,
yubikey::Error::WrongPin { tries } => {
wlnfl!(f, "err-yk-wrong-pin", pin_kind = "PIN", tries = tries)?
}
yubikey_piv::error::Error::WrongPin { tries } => writeln!(
f,
"Invalid PIN ({} tries remaining before it is blocked)",
tries
)?,
e => {
writeln!(f, "Error while communicating with YubiKey: {}", e)?;
wlnfl!(f, "err-yk-general", err = e.to_string())?;
use std::error::Error;
if let Some(inner) = e.source() {
writeln!(f, "Cause: {}", inner)?;
wlnfl!(f, "err-yk-general-cause", inner_err = inner.to_string())?;
}
}
},
}
writeln!(f)?;
writeln!(
f,
"[ Did this not do what you expected? Could an error be more useful? ]"
)?;
writeln!(f, "[ {} ]", crate::fl!("err-ux-A"))?;
write!(
f,
"[ Tell us: https://str4d.xyz/age-plugin-yubikey/report ]"
"[ {}: https://str4d.xyz/age-plugin-yubikey/report {} ]",
crate::fl!("err-ux-B"),
crate::fl!("err-ux-C")
)
}
}
-129
View File
@@ -1,129 +0,0 @@
use age_core::{
format::{FileKey, Stanza},
primitives::{aead_encrypt, hkdf},
};
use p256::{ecdh::EphemeralSecret, elliptic_curve::sec1::ToEncodedPoint};
use rand::rngs::OsRng;
use secrecy::ExposeSecret;
use std::convert::TryInto;
use crate::{p256::Recipient, STANZA_TAG};
pub(crate) const STANZA_KEY_LABEL: &[u8] = b"piv-p256";
const TAG_BYTES: usize = 4;
const EPK_BYTES: usize = 33;
const ENCRYPTED_FILE_KEY_BYTES: usize = 32;
/// The ephemeral key bytes in a piv-p256 stanza.
///
/// The bytes contain a compressed SEC-1 encoding of a valid point.
#[derive(Debug)]
pub(crate) struct EphemeralKeyBytes(p256::EncodedPoint);
impl EphemeralKeyBytes {
fn from_bytes(bytes: [u8; EPK_BYTES]) -> Option<Self> {
let encoded = p256::EncodedPoint::from_bytes(&bytes).ok()?;
if encoded.is_compressed() && encoded.decompress().is_some() {
Some(EphemeralKeyBytes(encoded))
} else {
None
}
}
fn from_public_key(epk: &p256::PublicKey) -> Self {
EphemeralKeyBytes(epk.to_encoded_point(true))
}
pub(crate) fn as_bytes(&self) -> &[u8] {
self.0.as_bytes()
}
pub(crate) fn decompress(&self) -> p256::EncodedPoint {
self.0
.decompress()
.expect("EphemeralKeyBytes is a valid compressed encoding by construction")
}
}
#[derive(Debug)]
pub(crate) struct RecipientLine {
pub(crate) tag: [u8; TAG_BYTES],
pub(crate) epk_bytes: EphemeralKeyBytes,
pub(crate) encrypted_file_key: [u8; ENCRYPTED_FILE_KEY_BYTES],
}
impl From<RecipientLine> for Stanza {
fn from(r: RecipientLine) -> Self {
Stanza {
tag: STANZA_TAG.to_owned(),
args: vec![
base64::encode_config(&r.tag, base64::STANDARD_NO_PAD),
base64::encode_config(r.epk_bytes.as_bytes(), base64::STANDARD_NO_PAD),
],
body: r.encrypted_file_key.to_vec(),
}
}
}
impl RecipientLine {
pub(super) fn from_stanza(s: &Stanza) -> Option<Result<Self, ()>> {
if s.tag != STANZA_TAG {
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::decode_config_slice(arg, base64::STANDARD_NO_PAD, buf.as_mut())
.ok()
.map(|_| buf)
}
let (tag, epk_bytes) = match &s.args[..] {
[tag, epk_bytes] => (
base64_arg(tag, [0; TAG_BYTES]),
base64_arg(epk_bytes, [0; EPK_BYTES]).and_then(EphemeralKeyBytes::from_bytes),
),
_ => (None, None),
};
Some(match (tag, epk_bytes, s.body[..].try_into()) {
(Some(tag), Some(epk_bytes), Ok(encrypted_file_key)) => Ok(RecipientLine {
tag,
epk_bytes,
encrypted_file_key,
}),
// Anything else indicates a structurally-invalid stanza.
_ => Err(()),
})
}
pub(crate) fn wrap_file_key(file_key: &FileKey, pk: &Recipient) -> Self {
let esk = EphemeralSecret::random(OsRng);
let epk = esk.public_key();
let epk_bytes = EphemeralKeyBytes::from_public_key(&epk);
let shared_secret = esk.diffie_hellman(&pk.public_key());
let mut salt = vec![];
salt.extend_from_slice(epk_bytes.as_bytes());
salt.extend_from_slice(pk.to_encoded().as_bytes());
let enc_key = hkdf(&salt, STANZA_KEY_LABEL, shared_secret.as_bytes());
let encrypted_file_key = {
let mut key = [0; ENCRYPTED_FILE_KEY_BYTES];
key.copy_from_slice(&aead_encrypt(&enc_key, file_key.expose_secret()));
key
};
RecipientLine {
tag: pk.tag(),
epk_bytes,
encrypted_file_key,
}
}
}
+860
View File
@@ -0,0 +1,860 @@
//! Structs for handling YubiKeys.
use age_core::primitives::bech32_encode;
use age_core::secrecy::{ExposeSecret, SecretString};
use age_plugin::{identity, Callbacks};
use dialoguer::Password;
use log::{debug, error, warn};
use std::convert::Infallible;
use std::env;
use std::fmt;
use std::io::{self, Read, Write};
use std::iter;
#[cfg(unix)]
use std::os::unix::net::UnixStream;
use std::thread::sleep;
use std::time::{Duration, Instant, SystemTime};
use x509_parser::der_parser::oid::Oid;
use yubikey::{
certificate::Certificate,
piv::{decrypt_data, AlgorithmId, RetiredSlotId, SlotId},
reader::{Context, Reader},
Key, MgmKey, PinPolicy, Serial, TouchPolicy, YubiKey,
};
use crate::{
error::Error,
fl,
native::p256tag,
recipient::TAG_BYTES,
util::{otp_serial_prefix, Metadata, POLICY_EXTENSION_OID},
Recipient, IDENTITY_PREFIX,
};
const ONE_SECOND: Duration = Duration::from_secs(1);
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 {
filter_connected(&reader)
}
pub(crate) fn filter_connected(reader: &Reader) -> bool {
match reader.open() {
Err(yubikey::Error::PcscError {
inner: Some(pcsc::Error::NoSmartcard | pcsc::Error::RemovedCard),
}) => {
warn!(
"{}",
fl!("warn-yk-not-connected", yubikey_name = reader.name())
);
false
}
Err(yubikey::Error::AppletNotFound { applet_name }) => {
warn!(
"{}",
fl!(
"warn-yk-missing-applet",
yubikey_name = reader.name(),
applet_name = applet_name,
),
);
false
}
Err(_) => true,
Ok(yubikey) => {
// We only connected as a side-effect of confirming that we can connect, so
// avoid resetting the YubiKey.
disconnect_without_reset(yubikey);
true
}
}
}
pub(crate) fn wait_for_readers() -> Result<Context, Error> {
// Start a 15-second timer waiting for a YubiKey to be inserted (if necessary).
let start = SystemTime::now();
loop {
let mut readers = Context::open()?;
if readers.iter()?.any(is_connected) {
break Ok(readers);
}
match SystemTime::now().duration_since(start) {
Ok(end) if end >= FIFTEEN_SECONDS => return Err(Error::TimedOut),
_ => sleep(ONE_SECOND),
}
}
}
/// Looks for agent processes that might be holding exclusive access to a YubiKey, and
/// asks them as nicely as possible to release it.
///
/// Returns `true` if any known agent was running and was successfully interrupted (or
/// killed if the platform doesn't support interrupts).
fn hunt_agents() -> bool {
debug!("Sharing violation encountered, looking for agent processes");
use sysinfo::{ProcessExt, ProcessRefreshKind, RefreshKind, Signal, System, SystemExt};
let mut interrupted = false;
let sys =
System::new_with_specifics(RefreshKind::new().with_processes(ProcessRefreshKind::new()));
for process in sys.processes().values() {
match process.name() {
"scdaemon" | "scdaemon.exe" => {
// gpg-agent runs scdaemon to interact with smart cards. The canonical way
// to reload it is `gpgconf --reload scdaemon`, which kills and restarts
// the process. We emulate that here with SIGINT (which it listens to).
if process
.kill_with(Signal::Interrupt)
.unwrap_or_else(|| process.kill())
{
debug!("Stopped scdaemon (PID {})", process.pid());
interrupted = true;
}
}
"yubikey-agent" | "yubikey-agent.exe" => {
// yubikey-agent releases all YubiKey locks when it receives a SIGHUP.
match process.kill_with(Signal::Hangup) {
Some(true) => {
debug!("Sent SIGHUP to yubikey-agent (PID {})", process.pid());
interrupted = true;
}
Some(false) => (),
None => debug!(
"Found yubikey-agent (PID {}) but platform doesn't support SIGHUP",
process.pid(),
),
}
}
_ => (),
}
}
// If we did interrupt an agent, pause briefly to allow it to finish up.
if interrupted {
sleep(Duration::from_millis(100));
}
interrupted
}
fn open_sesame(
op: impl Fn() -> Result<YubiKey, yubikey::Error>,
) -> Result<YubiKey, yubikey::Error> {
op().or_else(|e| match e {
yubikey::Error::PcscError {
inner: Some(pcsc::Error::SharingViolation),
} if hunt_agents() => op(),
_ => Err(e),
})
}
/// Opens a connection to this reader, returning a `YubiKey` if successful.
///
/// This is equivalent to [`Reader::open`], but additionally handles the presence of
/// agents (which can indefinitely hold exclusive access to a YubiKey).
pub(crate) fn open_connection(reader: &Reader) -> Result<YubiKey, yubikey::Error> {
open_sesame(|| reader.open())
}
/// Opens a YubiKey with a specific serial number.
///
/// This is equivalent to [`YubiKey::open_by_serial`], but additionally handles the
/// presence of agents (which can indefinitely hold exclusive access to a YubiKey).
fn open_by_serial(serial: Serial) -> Result<YubiKey, yubikey::Error> {
// `YubiKey::open_by_serial` has a bug where it ignores all opening errors, even if
// it potentially could have found a matching YubiKey if not for an error, and thus
// returns `Error::NotFound` if another agent is holding exclusive access to the
// required YubiKey. This gives misleading UX behaviour where age-plugin-yubikey asks
// the user to insert a YubiKey they have already inserted.
//
// For now, we instead implement the correct behaviour manually. Once MSRV has been
// raised to 1.60, we can upstream this into the `yubikey` crate.
open_sesame(|| {
let mut readers = Context::open()?;
let mut open_error = None;
for reader in readers.iter()? {
let yubikey = match reader.open() {
Ok(yk) => yk,
Err(e) => {
// Save the first error we see that indicates we might have been able
// to find a matching YubiKey.
if open_error.is_none() {
if let yubikey::Error::PcscError {
inner: Some(pcsc::Error::SharingViolation),
} = e
{
open_error = Some(e);
}
}
continue;
}
};
if serial == yubikey.serial() {
return Ok(yubikey);
} else {
// We didn't want this YubiKey; don't reset it.
disconnect_without_reset(yubikey);
}
}
Err(if let Some(e) = open_error {
e
} else {
error!("no YubiKey detected with serial: {}", serial);
yubikey::Error::NotFound
})
})
}
pub(crate) fn open(serial: Option<Serial>) -> Result<YubiKey, Error> {
if !Context::open()?.iter()?.any(is_connected) {
if let Some(serial) = serial {
eprintln!(
"{}",
fl!("open-yk-with-serial", yubikey_serial = serial.to_string())
);
} else {
eprintln!("{}", fl!("open-yk-without-serial"));
}
}
let mut readers = wait_for_readers()?;
let mut readers_iter = readers.iter()?.filter(filter_connected);
// --serial selects the YubiKey to use. If not provided, and more than one YubiKey is
// connected, an error is returned.
let yubikey = match (readers_iter.next(), readers_iter.next(), serial) {
(None, _, _) => unreachable!(),
(Some(reader), None, None) => open_connection(&reader)?,
(Some(reader), None, Some(serial)) => {
let yubikey = open_connection(&reader)?;
if yubikey.serial() != serial {
return Err(Error::NoMatchingSerial(serial));
}
yubikey
}
(Some(a), Some(b), Some(serial)) => {
let reader = iter::empty()
.chain(Some(a))
.chain(Some(b))
.chain(readers_iter)
.find(|reader| match open_connection(reader) {
Ok(yk) => yk.serial() == serial,
_ => false,
})
.ok_or(Error::NoMatchingSerial(serial))?;
open_connection(&reader)?
}
(Some(_), Some(_), None) => return Err(Error::MultipleYubiKeys),
};
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.
///
/// This can be used to preserve the YubiKey's PIN and touch caches. There are two cases
/// where we want to do this:
///
/// - We connected to this YubiKey in a read-only context, so we have not made any changes
/// to the YubiKey's state. However, we might have asked an agent to release the YubiKey
/// in `key::open_connection`, and we want to allow any state it may have left behind
/// (such as cached PINs or touches) to persist beyond our execution, for usability.
/// - We opened this connection in a decryption context, so the only changes to 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
/// 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) {
let _ = yubikey.disconnect(pcsc::Disposition::LeaveCard);
poke_ssh_agent();
}
fn request_pin<E, E2>(
mut prompt: impl FnMut(Option<String>) -> Result<Result<SecretString, E>, E2>,
serial: Serial,
) -> Result<Result<SecretString, E>, E2> {
let mut prev_error = None;
loop {
prev_error = Some(match prompt(prev_error)? {
Ok(pin) => match pin.expose_secret().len() {
// A PIN must be between 6 and 8 characters.
6..=8 => break Ok(Ok(pin)),
// If the string is 44 bytes and starts with the YubiKey's serial
// encoded as 12-byte modhex, the user probably touched the YubiKey
// early and "typed" an OTP.
44 if pin.expose_secret().starts_with(&otp_serial_prefix(serial)) => {
fl!("plugin-err-accidental-touch")
}
// Otherwise, the PIN is either too short or too long.
0..=5 => fl!("plugin-err-pin-too-short"),
_ => fl!("plugin-err-pin-too-long"),
},
Err(e) => break Ok(Err(e)),
});
}
}
pub(crate) fn manage(yubikey: &mut YubiKey) -> Result<(), Error> {
const DEFAULT_PIN: &str = "123456";
const DEFAULT_PUK: &str = "12345678";
eprintln!();
let pin = Password::new()
.with_prompt(fl!(
"mgr-enter-pin",
yubikey_serial = yubikey.serial().to_string(),
default_pin = DEFAULT_PIN,
))
.report(true)
.interact()?;
yubikey.verify_pin(pin.as_bytes())?;
// If the user is using the default PIN, help them to change it.
if pin == DEFAULT_PIN {
eprintln!();
eprintln!("{}", fl!("mgr-change-default-pin"));
eprintln!();
let current_puk = Password::new()
.with_prompt(fl!("mgr-enter-current-puk", default_puk = DEFAULT_PUK))
.interact()?;
let new_pin = loop {
let pin = request_pin(
|prev_error| {
if let Some(err) = prev_error {
eprintln!("{err}");
}
Password::new()
.with_prompt(fl!("mgr-choose-new-pin"))
.with_confirmation(fl!("mgr-repeat-new-pin"), fl!("mgr-pin-mismatch"))
.interact()
.map(|pin| Result::<_, Infallible>::Ok(SecretString::from(pin)))
},
yubikey.serial(),
)?
.unwrap();
if pin.expose_secret() == DEFAULT_PIN {
eprintln!("{}", fl!("mgr-nope-default-pin"));
} else {
break pin;
}
};
let new_pin = new_pin.expose_secret();
yubikey
.change_puk(current_puk.as_bytes(), new_pin.as_bytes())
.map_err(|e| match e {
yubikey::Error::PinLocked => Error::PukLocked,
yubikey::Error::WrongPin { tries } => Error::WrongPuk(tries),
_ => Error::YubiKey(e),
})?;
yubikey.change_pin(pin.as_bytes(), new_pin.as_bytes())?;
}
match MgmKey::get_protected(yubikey) {
Ok(mgm_key) => yubikey.authenticate(mgm_key).map_err(|e| match e {
yubikey::Error::AuthenticationError => Error::ManagementKeyAuth,
_ => e.into(),
})?,
Err(yubikey::Error::AuthenticationError) => Err(Error::ManagementKeyAuth)?,
_ => {
// Try to authenticate with the default management key.
yubikey
.authenticate(MgmKey::default())
.map_err(|_| Error::CustomManagementKey)?;
// Migrate to a PIN-protected management key.
let mgm_key = MgmKey::generate();
eprintln!();
eprintln!("{}", fl!("mgr-changing-mgmt-key"));
eprint!("... ");
mgm_key.set_protected(yubikey).map_err(|e| {
eprintln!(
"{}",
fl!(
"mgr-changing-mgmt-key-error",
management_key = hex::encode(mgm_key.as_ref()),
)
);
e
})?;
eprintln!("{}", fl!("mgr-changing-mgmt-key-success"));
}
}
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
/// corresponding recipient if the key is compatible with this plugin.
pub(crate) fn list_slots(
yubikey: &mut YubiKey,
) -> Result<impl Iterator<Item = (Key, RetiredSlotId, Option<Recipient>)>, Error> {
Ok(Key::list(yubikey)?.into_iter().filter_map(|key| {
// We only use the retired slots.
match key.slot() {
SlotId::Retired(slot) => {
let recipient = identify_recipient(key.certificate());
Some((key, slot, recipient))
}
_ => None,
}
}))
}
/// Returns an iterator of keys that are compatible with this plugin.
pub(crate) fn list_compatible(
yubikey: &mut YubiKey,
) -> Result<impl Iterator<Item = (Key, RetiredSlotId, Recipient)>, Error> {
list_slots(yubikey)
.map(|iter| iter.filter_map(|(key, slot, res)| res.map(|recipient| (key, slot, recipient))))
}
/// A reference to an age key stored in a YubiKey.
#[derive(Debug)]
pub struct Stub {
pub(crate) serial: Serial,
pub(crate) slot: RetiredSlotId,
pub(crate) tag: [u8; TAG_BYTES],
pub(crate) identity_index: usize,
}
impl fmt::Display for Stub {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(
bech32_encode(IDENTITY_PREFIX, &self.to_bytes())
.to_uppercase()
.as_str(),
)
}
}
impl PartialEq for Stub {
fn eq(&self, other: &Self) -> bool {
self.to_bytes().eq(&other.to_bytes())
}
}
impl Stub {
/// Returns a key stub and recipient for this `(Serial, SlotId, PublicKey)` tuple.
///
/// Does not check that the `PublicKey` matches the given `(Serial, SlotId)` tuple;
/// this is checked at decryption time.
pub(crate) fn new(serial: Serial, slot: RetiredSlotId, recipient: &Recipient) -> Self {
Stub {
serial,
slot,
tag: recipient.static_tag(),
identity_index: 0,
}
}
pub(crate) fn from_bytes(bytes: &[u8], identity_index: usize) -> Option<Self> {
if bytes.len() < 9 {
return None;
}
let serial = Serial::from(u32::from_le_bytes(bytes[0..4].try_into().unwrap()));
let slot: RetiredSlotId = bytes[4].try_into().ok()?;
Some(Stub {
serial,
slot,
tag: bytes[5..9].try_into().unwrap(),
identity_index,
})
}
fn to_bytes(&self) -> Vec<u8> {
let mut bytes = Vec::with_capacity(9);
bytes.extend_from_slice(&self.serial.0.to_le_bytes());
bytes.push(self.slot.into());
bytes.extend_from_slice(&self.tag);
bytes
}
/// Returns:
/// - `Ok(Ok(Some(connection)))` if we successfully connected to this YubiKey.
/// - `Ok(Ok(None))` if the user told us to skip this YubiKey.
/// - `Ok(Err(_))` if we encountered an error while trying to connect to the YubiKey.
/// - `Err(_)` on communication errors with the age client.
pub(crate) fn connect<E>(
&self,
callbacks: &mut dyn Callbacks<E>,
) -> io::Result<Result<Option<Connection>, identity::Error>> {
let mut yubikey = match open_by_serial(self.serial) {
Ok(yk) => yk,
Err(yubikey::Error::NotFound) => {
let mut message = fl!("plugin-insert-yk", yubikey_serial = self.serial.to_string());
// If the `confirm` command is available, we loop until either the YubiKey
// we want is inserted, or the used explicitly skips.
let yubikey = loop {
match callbacks.confirm(
&message,
&fl!("plugin-yk-is-plugged-in"),
Some(&fl!("plugin-skip-this-yk")),
)? {
// `confirm` command is not available.
Err(age_core::plugin::Error::Unsupported) => break None,
// User told us to skip this key.
Ok(false) => return Ok(Ok(None)),
// User said they plugged it in; try it.
Ok(true) => match open_by_serial(self.serial) {
Ok(yubikey) => break Some(yubikey),
Err(yubikey::Error::NotFound) => (),
Err(_) => {
return Ok(Err(identity::Error::Identity {
index: self.identity_index,
message: fl!(
"plugin-err-yk-opening",
yubikey_serial = self.serial.to_string(),
),
}));
}
},
// We can't communicate with the user.
Err(age_core::plugin::Error::Fail) => {
return Ok(Err(identity::Error::Identity {
index: self.identity_index,
message: fl!(
"plugin-err-yk-opening",
yubikey_serial = self.serial.to_string(),
),
}))
}
}
// We're going to loop around, meaning that the first attempt failed.
// Change the message to indicate this to the user.
message = fl!(
"plugin-insert-yk-retry",
yubikey_serial = self.serial.to_string(),
);
};
if let Some(yk) = yubikey {
yk
} else {
// `confirm` is not available; fall back to `message` with a timeout.
if callbacks.message(&message)?.is_err() {
return Ok(Err(identity::Error::Identity {
index: self.identity_index,
message: fl!(
"plugin-err-yk-not-found",
yubikey_serial = self.serial.to_string(),
),
}));
}
// Start a 15-second timer waiting for the YubiKey to be inserted
let start = SystemTime::now();
loop {
match open_by_serial(self.serial) {
Ok(yubikey) => break yubikey,
Err(yubikey::Error::NotFound) => (),
Err(_) => {
return Ok(Err(identity::Error::Identity {
index: self.identity_index,
message: fl!(
"plugin-err-yk-opening",
yubikey_serial = self.serial.to_string(),
),
}));
}
}
match SystemTime::now().duration_since(start) {
Ok(end) if end >= FIFTEEN_SECONDS => {
return Ok(Err(identity::Error::Identity {
index: self.identity_index,
message: fl!(
"plugin-err-yk-timed-out",
yubikey_serial = self.serial.to_string(),
),
}))
}
_ => sleep(ONE_SECOND),
}
}
}
}
Err(_) => {
return Ok(Err(identity::Error::Identity {
index: self.identity_index,
message: fl!(
"plugin-err-yk-opening",
yubikey_serial = self.serial.to_string(),
),
}))
}
};
// Read the pubkey from the YubiKey slot and check it still matches.
let (cert, pk) = match Certificate::read(&mut yubikey, SlotId::Retired(self.slot))
.ok()
.and_then(|cert| {
// Parse as the preferred recipient for each identity type.
identify_recipient(&cert)
.filter(|recipient| recipient.static_tag() == self.tag)
.map(|r| (cert, r))
}) {
Some(pk) => pk,
None => {
return Ok(Err(identity::Error::Identity {
index: self.identity_index,
message: fl!("plugin-err-yk-stub-mismatch"),
}))
}
};
Ok(Ok(Some(Connection {
yubikey,
cert,
pk,
slot: self.slot,
identity_index: self.identity_index,
cached_metadata: None,
last_touch: None,
})))
}
}
pub(crate) struct Connection {
yubikey: YubiKey,
cert: Certificate,
pk: Recipient,
slot: RetiredSlotId,
identity_index: usize,
cached_metadata: Option<Metadata>,
last_touch: Option<Instant>,
}
impl Connection {
/// Returns the preferred recipient for encrypting to this identity.
pub(crate) fn recipient(&self) -> &Recipient {
&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>(
&mut self,
callbacks: &mut dyn Callbacks<E>,
) -> io::Result<Result<(), identity::Error>> {
// Check if we can skip requesting a PIN.
if self.cached_metadata.is_none() {
self.cached_metadata =
match Metadata::extract(&mut self.yubikey, self.slot, &self.cert, true) {
None => {
return Ok(Err(identity::Error::Identity {
index: self.identity_index,
message: fl!("plugin-err-yk-invalid-pin-policy"),
}))
}
metadata => metadata,
};
}
match self.cached_metadata.as_ref().and_then(|m| m.pin_policy) {
Some(PinPolicy::Never) => return Ok(Ok(())),
Some(PinPolicy::Once) if self.yubikey.verify_pin(&[]).is_ok() => return Ok(Ok(())),
_ => (),
}
// The policy requires a PIN, so request it.
let pin = match request_pin(
|prev_error| {
callbacks.request_secret(&format!(
"{}{}{}",
prev_error.as_deref().unwrap_or(""),
prev_error.as_deref().map(|_| " ").unwrap_or(""),
fl!(
"plugin-enter-pin",
yubikey_serial = self.yubikey.serial().to_string(),
)
))
},
self.yubikey.serial(),
)? {
Ok(pin) => pin,
Err(_) => {
return Ok(Err(identity::Error::Identity {
index: self.identity_index,
message: fl!(
"plugin-err-pin-required",
yubikey_serial = self.yubikey.serial().to_string(),
),
}))
}
};
if let Err(e) = self.yubikey.verify_pin(pin.expose_secret().as_bytes()) {
return Ok(Err(identity::Error::Identity {
index: self.identity_index,
message: format!("{:?}", Error::YubiKey(e)),
}));
}
Ok(Ok(()))
}
pub(crate) fn p256_ecdh(&mut self, epk_bytes: &[u8]) -> Result<yubikey::Buffer, ()> {
// 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.
let needs_touch = match (
self.cached_metadata.as_ref().and_then(|m| m.touch_policy),
self.last_touch,
) {
(Some(TouchPolicy::Always), _) | (Some(TouchPolicy::Cached), None) => true,
(Some(TouchPolicy::Cached), Some(last)) if last.elapsed() >= FIFTEEN_SECONDS => true,
_ => false,
};
let shared_secret = match decrypt_data(
&mut self.yubikey,
epk_bytes,
AlgorithmId::EccP256,
SlotId::Retired(self.slot),
) {
Ok(res) => res,
Err(_) => return Err(()),
};
// If we requested a touch and reached here, the user touched the YubiKey.
if needs_touch {
if let Some(TouchPolicy::Cached) =
self.cached_metadata.as_ref().and_then(|m| m.touch_policy)
{
self.last_touch = Some(Instant::now());
}
}
Ok(shared_secret)
}
/// Close this connection without resetting the YubiKey.
///
/// This can be used to preserve the YubiKey's PIN and touch caches.
pub(crate) fn disconnect_without_reset(self) {
disconnect_without_reset(self.yubikey);
}
}
#[cfg(test)]
mod tests {
use yubikey::{piv::RetiredSlotId, Serial};
use super::Stub;
#[test]
fn stub_round_trip() {
let stub = Stub {
serial: Serial::from(42),
slot: RetiredSlotId::R1,
tag: [7; 4],
identity_index: 0,
};
let encoded = stub.to_bytes();
assert_eq!(Stub::from_bytes(&[], 0), None);
assert_eq!(Stub::from_bytes(&encoded, 0), Some(stub));
assert_eq!(Stub::from_bytes(&encoded[..encoded.len() - 1], 0), None);
}
}
+256 -197
View File
@@ -1,32 +1,35 @@
use std::convert::{TryFrom, TryInto};
#![forbid(unsafe_code)]
use std::fs::{File, OpenOptions};
use std::io::{self, Write};
use age_plugin::run_state_machine;
use dialoguer::{Confirm, Input, Select};
use gumdrop::Options;
use yubikey_piv::{
certificate::PublicKeyInfo,
key::{RetiredSlotId, SlotId},
policy::{PinPolicy, TouchPolicy},
Key, Readers, Serial,
use i18n_embed::{
fluent::{fluent_language_loader, FluentLanguageLoader},
DesktopLanguageRequester,
};
use lazy_static::lazy_static;
use rust_embed::RustEmbed;
use yubikey::{piv::RetiredSlotId, reader::Context, PinPolicy, Serial, TouchPolicy};
mod builder;
mod error;
mod format;
mod p256;
mod key;
mod native;
mod piv_p256;
mod plugin;
mod util;
mod yubikey;
mod recipient;
use recipient::Recipient;
use error::Error;
const PLUGIN_NAME: &str = "yubikey";
const BINARY_NAME: &str = "age-plugin-yubikey";
const RECIPIENT_PREFIX: &str = "age1yubikey";
const IDENTITY_PREFIX: &str = "age-plugin-yubikey-";
const STANZA_TAG: &str = "piv-p256";
const IDENTITY_PREFIX: bech32::Hrp = bech32::Hrp::parse_unchecked("AGE-PLUGIN-YUBIKEY-");
const USABLE_SLOTS: [RetiredSlotId; 20] = [
RetiredSlotId::R1,
@@ -51,6 +54,26 @@ const USABLE_SLOTS: [RetiredSlotId; 20] = [
RetiredSlotId::R20,
];
#[derive(RustEmbed)]
#[folder = "i18n"]
struct Translations;
const TRANSLATIONS: Translations = Translations {};
lazy_static! {
static ref LANGUAGE_LOADER: FluentLanguageLoader = fluent_language_loader!();
}
#[macro_export]
macro_rules! fl {
($message_id:literal) => {{
i18n_embed_fl::fl!($crate::LANGUAGE_LOADER, $message_id)
}};
($message_id:literal, $($kwarg:expr),* $(,)*) => {{
i18n_embed_fl::fl!($crate::LANGUAGE_LOADER, $message_id, $($kwarg,)*)
}};
}
#[derive(Debug, Options)]
struct PluginOptions {
#[options(help = "Print this help message and exit.")]
@@ -148,7 +171,7 @@ impl TryFrom<PluginOptions> for PluginFlags {
}
fn generate(flags: PluginFlags) -> Result<(), Error> {
let mut yubikey = yubikey::open(flags.serial)?;
let mut yubikey = key::open(flags.serial)?;
let (stub, recipient, metadata) = builder::IdentityBuilder::new(flags.slot)
.with_name(flags.name)
@@ -159,39 +182,34 @@ fn generate(flags: PluginFlags) -> Result<(), Error> {
util::print_identity(stub, recipient, metadata);
// We have written to the YubiKey, which means we've authenticated with the management
// key. Out of an abundance of caution, we let the YubiKey be reset on disconnect,
// which will clear its PIN and touch caches. This has as small negative UX effect,
// but identity generation is a relatively infrequent occurrence, and users are more
// likely to see their cached PINs reset due to switching applets (e.g. from PIV to
// FIDO2).
Ok(())
}
fn print_single(
serial: Option<Serial>,
slot: RetiredSlotId,
printer: impl Fn(yubikey::Stub, p256::Recipient, util::Metadata),
printer: impl Fn(key::Stub, Recipient, util::Metadata),
) -> Result<(), Error> {
let mut yubikey = yubikey::open(serial)?;
let mut yubikey = key::open(serial)?;
let mut keys = Key::list(&mut yubikey)?.into_iter().filter_map(|key| {
// - We only use the retired slots.
// - Only P-256 keys are compatible with us.
match (key.slot(), key.certificate().subject_pki()) {
(SlotId::Retired(slot), PublicKeyInfo::EcP256(pubkey)) => {
p256::Recipient::from_encoded(pubkey).map(|r| (key, slot, r))
}
_ => None,
}
});
let (key, slot, recipient) = keys
let (key, slot, recipient) = key::list_compatible(&mut yubikey)?
.find(|(_, s, _)| s == &slot)
.ok_or(Error::SlotHasNoIdentity(slot))?;
let stub = yubikey::Stub::new(yubikey.serial(), slot, &recipient);
let metadata = x509_parser::parse_x509_certificate(key.certificate().as_ref())
.ok()
.and_then(|(_, cert)| util::Metadata::extract(&mut yubikey, slot, &cert, true))
.unwrap();
let stub = key::Stub::new(yubikey.serial(), slot, &recipient);
let metadata = util::Metadata::extract(&mut yubikey, slot, key.certificate(), true).unwrap();
printer(stub, recipient, metadata);
key::disconnect_without_reset(yubikey);
Ok(())
}
@@ -199,39 +217,22 @@ fn print_multiple(
kind: &str,
serial: Option<Serial>,
all: bool,
printer: impl Fn(yubikey::Stub, p256::Recipient, util::Metadata),
printer: impl Fn(key::Stub, Recipient, util::Metadata),
) -> Result<(), Error> {
let mut readers = Readers::open()?;
let mut readers = Context::open()?;
let mut printed = 0;
for reader in readers.iter()?.filter(yubikey::filter_connected) {
let mut yubikey = reader.open()?;
for reader in readers.iter()?.filter(key::filter_connected) {
let mut yubikey = key::open_connection(&reader)?;
if let Some(serial) = serial {
if yubikey.serial() != serial {
continue;
}
}
for key in Key::list(&mut yubikey)? {
// We only use the retired slots.
let slot = match key.slot() {
SlotId::Retired(slot) => slot,
_ => continue,
};
// Only P-256 keys are compatible with us.
let recipient = match key.certificate().subject_pki() {
PublicKeyInfo::EcP256(pubkey) => match p256::Recipient::from_encoded(pubkey) {
Some(recipient) => recipient,
None => continue,
},
_ => continue,
};
let stub = yubikey::Stub::new(yubikey.serial(), slot, &recipient);
let metadata = match x509_parser::parse_x509_certificate(key.certificate().as_ref())
.ok()
.and_then(|(_, cert)| util::Metadata::extract(&mut yubikey, slot, &cert, all))
for (key, slot, recipient) in key::list_compatible(&mut yubikey)? {
let stub = key::Stub::new(yubikey.serial(), slot, &recipient);
let metadata = match util::Metadata::extract(&mut yubikey, slot, key.certificate(), all)
{
Some(res) => res,
None => continue,
@@ -242,12 +243,11 @@ fn print_multiple(
println!();
}
println!();
key::disconnect_without_reset(yubikey);
}
if printed > 1 {
eprintln!(
"Generated {} for {} slots. If you intended to select a slot, use --slot.",
kind, printed,
);
eprintln!("{}", fl!("printed-multiple", kind = kind, count = printed));
}
Ok(())
@@ -257,7 +257,7 @@ fn print_details(
kind: &str,
flags: PluginFlags,
all: bool,
printer: impl Fn(yubikey::Stub, p256::Recipient, util::Metadata),
printer: impl Fn(key::Stub, Recipient, util::Metadata),
) -> Result<(), Error> {
if let Some(slot) = flags.slot {
print_single(flags.serial, slot, printer)
@@ -273,7 +273,12 @@ fn identity(flags: PluginFlags) -> Result<(), Error> {
"--identity".into(),
));
}
print_details("identities", flags, false, util::print_identity)
print_details(
&fl!("printed-kind-identities"),
flags,
false,
util::print_identity,
)
}
fn list(flags: PluginFlags, all: bool) -> Result<(), Error> {
@@ -287,10 +292,21 @@ fn list(flags: PluginFlags, all: bool) -> Result<(), Error> {
));
}
print_details("recipients", flags, all, |_, recipient, metadata| {
println!("{}", metadata);
println!("{}", recipient.to_string());
})
print_details(
&fl!("printed-kind-recipients"),
flags,
all,
|_, recipient, metadata| {
println!("{metadata}");
if let Some(legacy_recipient) = recipient.legacy_recipient(&metadata) {
println!(
"{}",
fl!("yubikey-legacy-recipient", recipient = legacy_recipient)
);
}
println!("{recipient}");
},
)
}
fn main() -> Result<(), Error> {
@@ -300,6 +316,12 @@ fn main() -> Result<(), Error> {
.parse_default_env()
.init();
let requested_languages = DesktopLanguageRequester::requested_languages();
i18n_embed::select(&*LANGUAGE_LOADER, &TRANSLATIONS, &requested_languages).unwrap();
// Unfortunately the common Windows terminals don't support Unicode Directionality
// Isolation Marks, so we disable them for now.
LANGUAGE_LOADER.set_use_isolating(false);
let opts = PluginOptions::parse_args_default_or_exit();
if [opts.generate, opts.identity, opts.list, opts.list_all]
@@ -312,11 +334,7 @@ fn main() -> Result<(), Error> {
}
if let Some(state_machine) = opts.age_plugin {
run_state_machine(
&state_machine,
plugin::RecipientPlugin::default,
plugin::IdentityPlugin::default,
)?;
run_state_machine(&state_machine, plugin::Handler)?;
Ok(())
} else if opts.version {
println!("age-plugin-yubikey {}", env!("CARGO_PKG_VERSION"));
@@ -335,69 +353,71 @@ fn main() -> Result<(), Error> {
}
let flags: PluginFlags = opts.try_into()?;
eprintln!("✨ Let's get your YubiKey set up for age! ✨");
eprintln!();
eprintln!("This tool can create a new age identity in a free slot of your YubiKey.");
eprintln!("It will generate an identity file that you can use with an age client,");
eprintln!("along with the corresponding recipient. You can also do this directly");
eprintln!("with:");
eprintln!(" age-plugin-yubikey --generate");
eprintln!();
eprintln!("If you are already using a YubiKey with age, you can select an existing");
eprintln!("slot to recreate its corresponding identity file and recipient.");
eprintln!();
eprintln!("When asked below to select an option, use the up/down arrow keys to");
eprintln!("make your choice, or press [Esc] or [q] to quit.");
eprintln!(
"{}",
fl!(
"cli-setup-intro",
generate_usage = "age-plugin-yubikey --generate",
)
);
eprintln!();
if !Readers::open()?.iter()?.any(yubikey::is_connected) {
eprintln!("⏳ Please insert the YubiKey you want to set up.");
if !Context::open()?.iter()?.any(key::is_connected) {
eprintln!("{}", fl!("cli-setup-insert-yk"));
};
let mut readers = yubikey::wait_for_readers()?;
let mut readers = key::wait_for_readers()?;
// Filter out readers we can't connect to.
let readers_list: Vec<_> = readers.iter()?.filter(yubikey::filter_connected).collect();
let readers_list: Vec<_> = readers.iter()?.filter(key::filter_connected).collect();
let reader_names = readers_list
.iter()
.map(|reader| {
reader
.open()
.map(|yk| format!("{} (Serial: {})", reader.name(), yk.serial()))
key::open_connection(reader).map(|yk| {
let name = fl!(
"cli-setup-yk-name",
yubikey_name = reader.name(),
yubikey_serial = yk.serial().to_string(),
);
key::disconnect_without_reset(yk);
name
})
})
.collect::<Result<Vec<_>, _>>()?;
let mut yubikey = match Select::new()
.with_prompt("🔑 Select a YubiKey")
.with_prompt(fl!("cli-setup-select-yk"))
.items(&reader_names)
.default(0)
.report(true)
.interact_opt()?
{
Some(yk) => readers_list[yk].open()?,
None => return Ok(()),
};
let keys = Key::list(&mut yubikey)?;
let keys = key::list_slots(&mut yubikey)?.collect::<Vec<_>>();
// Identify slots that we can't allow the user to select.
let slot_details: Vec<_> = USABLE_SLOTS
.iter()
.map(|&slot| {
keys.iter()
.find(|key| key.slot() == SlotId::Retired(slot))
.map(|key| match key.certificate().subject_pki() {
PublicKeyInfo::EcP256(pubkey) => {
p256::Recipient::from_encoded(pubkey).map(|_| {
// Cache the details we need to display to the user.
let (_, cert) =
x509_parser::parse_x509_certificate(key.certificate().as_ref())
.unwrap();
let (name, _) = util::extract_name(&cert, true).unwrap();
let created = cert.validity().not_before.to_rfc2822();
.find(|(_, s, _)| s == &slot)
.map(|(key, _, recipient)| {
recipient.as_ref().map(|_| {
// Cache the details we need to display to the user.
let (_, cert) =
x509_parser::parse_x509_certificate(key.certificate().as_ref())
.unwrap();
let (name, _) = util::extract_name_and_version(&cert, true).unwrap();
let created = cert
.validity()
.not_before
.to_rfc2822()
.unwrap_or_else(|e| format!("Invalid date: {e}"));
format!("{}, created: {}", name, created)
})
}
_ => None,
format!("{name}, created: {created}")
})
})
})
.collect();
@@ -410,9 +430,13 @@ fn main() -> Result<(), Error> {
let i = i + 1;
match occupied {
Some(Some(name)) => format!("Slot {} ({})", i, name),
Some(None) => format!("Slot {} (Unusable)", i),
None => format!("Slot {} (Empty)", i),
Some(Some(name)) => fl!(
"cli-setup-slot-usable",
slot_index = i,
slot_name = name.as_str(),
),
Some(None) => fl!("cli-setup-slot-unusable", slot_index = i),
None => fl!("cli-setup-slot-empty", slot_index = i),
}
})
.collect();
@@ -420,9 +444,10 @@ fn main() -> Result<(), Error> {
let ((stub, recipient, metadata), is_new) = {
let (slot_index, slot) = loop {
match Select::new()
.with_prompt("🕳️ Select a slot for your age identity")
.with_prompt(fl!("cli-setup-select-slot"))
.items(&slots)
.default(0)
.report(true)
.interact_opt()?
{
Some(slot) => {
@@ -435,86 +460,114 @@ fn main() -> Result<(), Error> {
}
};
if let Some(key) = keys.iter().find(|key| key.slot() == SlotId::Retired(slot)) {
let recipient = match key.certificate().subject_pki() {
PublicKeyInfo::EcP256(pubkey) => {
p256::Recipient::from_encoded(pubkey).expect("We checked this above")
}
_ => unreachable!(),
};
if let Some((key, _, recipient)) = keys.into_iter().find(|(_, s, _)| s == &slot) {
let recipient = recipient.expect("We checked this above");
if Confirm::new()
.with_prompt(&format!("Use existing identity in slot {}?", slot_index))
.with_prompt(fl!("cli-setup-use-existing", slot_index = slot_index))
.report(true)
.interact()?
{
let stub = yubikey::Stub::new(yubikey.serial(), slot, &recipient);
let (_, cert) =
x509_parser::parse_x509_certificate(key.certificate().as_ref()).unwrap();
let stub = key::Stub::new(yubikey.serial(), slot, &recipient);
let metadata =
util::Metadata::extract(&mut yubikey, slot, &cert, true).unwrap();
util::Metadata::extract(&mut yubikey, slot, key.certificate(), true)
.unwrap();
key::disconnect_without_reset(yubikey);
((stub, recipient, metadata), false)
} else {
key::disconnect_without_reset(yubikey);
return Ok(());
}
} else {
let name = Input::<String>::new()
.with_prompt(format!(
"📛 Name this identity [{}]",
"{} [{}]",
fl!("cli-setup-name-identity"),
flags.name.as_deref().unwrap_or("age identity TAG_HEX")
))
.allow_empty(true)
.report(true)
.interact_text()?;
let pin_policy = match Select::new()
.with_prompt("🔤 Select a PIN policy")
.items(&[
"Always (A PIN is required for every decryption, if set)",
"Once (A PIN is required once per session, if set)",
"Never (A PIN is NOT required to decrypt)",
])
.default(
[PinPolicy::Always, PinPolicy::Once, PinPolicy::Never]
.iter()
.position(|p| {
p == &flags.pin_policy.unwrap_or(builder::DEFAULT_PIN_POLICY)
})
.unwrap(),
)
.interact_opt()?
{
Some(0) => PinPolicy::Always,
Some(1) => PinPolicy::Once,
Some(2) => PinPolicy::Never,
Some(_) => unreachable!(),
None => return Ok(()),
};
let touch_policy = match Select::new()
.with_prompt("👆 Select a touch policy")
let mut displayed_yk4_warning = false;
let pin_policy = loop {
let pin_policy = match Select::new()
.with_prompt(fl!("cli-setup-select-pin-policy"))
.items(&[
"Always (A physical touch is required for every decryption)",
"Cached (A physical touch is required for decryption, and is cached for 15 seconds)",
"Never (A physical touch is NOT required to decrypt)",
fl!("pin-policy-always"),
fl!("pin-policy-once"),
fl!("pin-policy-never"),
])
.default(
[TouchPolicy::Always, TouchPolicy::Cached, TouchPolicy::Never]
[PinPolicy::Always, PinPolicy::Once, PinPolicy::Never]
.iter()
.position(|p| p == &flags
.touch_policy.unwrap_or(builder::DEFAULT_TOUCH_POLICY))
.position(|p| {
p == &flags.pin_policy.unwrap_or(builder::DEFAULT_PIN_POLICY)
})
.unwrap(),
)
.report(true)
.interact_opt()?
{
Some(0) => TouchPolicy::Always,
Some(1) => TouchPolicy::Cached,
Some(2) => TouchPolicy::Never,
Some(0) => PinPolicy::Always,
Some(1) => PinPolicy::Once,
Some(2) => PinPolicy::Never,
Some(_) => unreachable!(),
None => return Ok(()),
};
// We can't preserve the PIN cache for YubiKey 4 series, because to
// retrieve the serial we switch to the OTP applet.
match (pin_policy, yubikey.version().major) {
(PinPolicy::Once, 4) => {
if !displayed_yk4_warning {
eprintln!();
eprintln!("{}", fl!("cli-setup-yk4-pin-policy"));
eprintln!();
displayed_yk4_warning = true;
}
if Confirm::new()
.with_prompt(fl!("cli-setup-yk4-pin-policy-confirm"))
.report(true)
.interact()?
{
break pin_policy;
}
}
_ => break pin_policy,
}
};
let touch_policy = match Select::new()
.with_prompt(fl!("cli-setup-select-touch-policy"))
.items(&[
fl!("touch-policy-always"),
fl!("touch-policy-cached"),
fl!("touch-policy-never"),
])
.default(
[TouchPolicy::Always, TouchPolicy::Cached, TouchPolicy::Never]
.iter()
.position(|p| {
p == &flags.touch_policy.unwrap_or(builder::DEFAULT_TOUCH_POLICY)
})
.unwrap(),
)
.report(true)
.interact_opt()?
{
Some(0) => TouchPolicy::Always,
Some(1) => TouchPolicy::Cached,
Some(2) => TouchPolicy::Never,
Some(_) => unreachable!(),
None => return Ok(()),
};
if Confirm::new()
.with_prompt(&format!("Generate new identity in slot {}?", slot_index))
.with_prompt(fl!("cli-setup-generate-new", slot_index = slot_index))
.report(true)
.interact()?
{
eprintln!();
@@ -530,6 +583,7 @@ fn main() -> Result<(), Error> {
true,
)
} else {
key::disconnect_without_reset(yubikey);
return Ok(());
}
}
@@ -537,11 +591,12 @@ fn main() -> Result<(), Error> {
eprintln!();
let file_name = Input::<String>::new()
.with_prompt("📝 File name to write this identity to")
.with_prompt(fl!("cli-setup-identity-file-name"))
.default(format!(
"age-yubikey-identity-{}.txt",
hex::encode(stub.tag)
))
.report(true)
.interact_text()?;
let mut file = match OpenOptions::new()
@@ -552,7 +607,8 @@ fn main() -> Result<(), Error> {
Ok(file) => file,
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
if Confirm::new()
.with_prompt("File exists. Overwrite it?")
.with_prompt(fl!("cli-setup-identity-file-exists"))
.report(true)
.interact()?
{
File::create(&file_name)?
@@ -563,54 +619,57 @@ fn main() -> Result<(), Error> {
Err(e) => return Err(e.into()),
};
writeln!(file, "{}", metadata)?;
writeln!(file, "# Recipient: {}", recipient)?;
writeln!(file, "{}", stub.to_string())?;
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!(
file,
"{}",
fl!(
"yubikey-identity",
yubikey_metadata = metadata.to_string(),
recipient = recipient.to_string(),
identity = identity,
)
)?;
file.sync_data()?;
// If `rage` binary is installed, use it in examples. Otherwise default to `age`.
let age_binary = which::which("rage").map(|_| "rage").unwrap_or("age");
eprintln!();
eprintln!("✅ Done! This YubiKey identity is ready to go.");
eprintln!();
if is_new {
eprintln!("🔑 Here's your shiny new YubiKey recipient:");
} else {
eprintln!("🔑 Here's the corresponding YubiKey recipient:");
}
eprintln!(" {}", recipient);
eprintln!();
eprintln!("Here are some example things you can do with it:");
eprintln!();
eprintln!("- Encrypt a file to this identity:");
eprintln!(
" $ cat foo.txt | {} -r {} -o foo.txt.age",
age_binary, recipient
);
eprintln!();
eprintln!("- Decrypt a file with this identity:");
eprintln!(
" $ cat foo.txt.age | {} -d -i {} > foo.txt",
age_binary, file_name
);
eprintln!();
eprintln!("- Recreate the identity file:");
eprintln!(
" $ age-plugin-yubikey -i --serial {} --slot {} > {}",
let encrypt_usage = format!("$ cat foo.txt | {age_binary} -r {recipient} -o foo.txt.age");
let decrypt_usage = format!("$ cat foo.txt.age | {age_binary} -d -i {file_name} > foo.txt");
let identity_usage = format!(
"$ age-plugin-yubikey -i --serial {} --slot {} > {}",
stub.serial,
util::slot_to_ui(&stub.slot),
file_name,
);
eprintln!();
eprintln!("- Recreate the recipient:");
eprintln!(
" $ age-plugin-yubikey -l --serial {} --slot {}",
let recipient_usage = format!(
"$ age-plugin-yubikey -l --serial {} --slot {}",
stub.serial,
util::slot_to_ui(&stub.slot),
);
eprintln!();
eprintln!("💭 Remember: everything breaks, have a backup plan for when this YubiKey does.");
eprintln!(
"{}",
fl!(
"cli-setup-finished",
is_new = if is_new { "true" } else { "false" },
recipient = recipient.to_string(),
encrypt_usage = encrypt_usage,
decrypt_usage = decrypt_usage,
identity_usage = identity_usage,
recipient_usage = recipient_usage,
)
);
Ok(())
}
+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")
}
}
+178
View File
@@ -0,0 +1,178 @@
use age_core::{
format::{FileKey, Stanza, FILE_KEY_BYTES},
primitives::{aead_decrypt, aead_encrypt, hkdf},
secrecy::{zeroize::Zeroize, ExposeSecret},
};
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
use p256::{
ecdh::EphemeralSecret,
elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint},
};
use rand::rngs::OsRng;
use sha2::Sha256;
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";
const EPK_BYTES: usize = 33;
const ENCRYPTED_FILE_KEY_BYTES: usize = 32;
/// The ephemeral key bytes in a piv-p256 stanza.
///
/// The bytes contain a compressed SEC-1 encoding of a valid point.
#[derive(Debug)]
pub(crate) struct EphemeralKeyBytes(p256::EncodedPoint);
impl EphemeralKeyBytes {
fn from_bytes(bytes: [u8; EPK_BYTES]) -> Option<Self> {
let encoded = p256::EncodedPoint::from_bytes(bytes).ok()?;
if encoded.is_compressed()
&& p256::PublicKey::from_encoded_point(&encoded)
.is_some()
.into()
{
Some(EphemeralKeyBytes(encoded))
} else {
None
}
}
fn from_public_key(epk: &p256::PublicKey) -> Self {
EphemeralKeyBytes(epk.to_encoded_point(true))
}
pub(crate) fn as_bytes(&self) -> &[u8] {
self.0.as_bytes()
}
pub(crate) fn decompress(&self) -> p256::EncodedPoint {
// EphemeralKeyBytes is a valid compressed encoding by construction.
let p = p256::PublicKey::from_encoded_point(&self.0).unwrap();
p.to_encoded_point(false)
}
}
#[derive(Debug)]
pub(crate) struct RecipientLine {
pub(crate) tag: [u8; TAG_BYTES],
pub(crate) epk_bytes: EphemeralKeyBytes,
pub(crate) encrypted_file_key: [u8; ENCRYPTED_FILE_KEY_BYTES],
}
impl From<RecipientLine> for Stanza {
fn from(r: RecipientLine) -> Self {
Stanza {
tag: STANZA_TAG.to_owned(),
args: vec![
BASE64_STANDARD_NO_PAD.encode(r.tag),
BASE64_STANDARD_NO_PAD.encode(r.epk_bytes.as_bytes()),
],
body: r.encrypted_file_key.to_vec(),
}
}
}
impl RecipientLine {
pub(super) fn from_stanza(s: &Stanza) -> Option<Result<Self, ()>> {
if s.tag != STANZA_TAG {
return None;
}
let (tag, epk_bytes) = match &s.args[..] {
[tag, epk_bytes] => (
base64_arg(tag, [0; TAG_BYTES]),
base64_arg(epk_bytes, [0; EPK_BYTES]).and_then(EphemeralKeyBytes::from_bytes),
),
_ => (None, None),
};
Some(match (tag, epk_bytes, s.body[..].try_into()) {
(Some(tag), Some(epk_bytes), Ok(encrypted_file_key)) => Ok(RecipientLine {
tag,
epk_bytes,
encrypted_file_key,
}),
// Anything else indicates a structurally-invalid stanza.
_ => Err(()),
})
}
}
impl Recipient {
pub(crate) fn wrap_file_key(&self, file_key: &FileKey) -> RecipientLine {
let esk = EphemeralSecret::random(&mut OsRng);
let epk = esk.public_key();
let epk_bytes = EphemeralKeyBytes::from_public_key(&epk);
let shared_secret = esk.diffie_hellman(self.public_key());
let salt = salt(&epk_bytes, self.to_encoded());
let enc_key = {
let mut okm = [0; 32];
shared_secret
.extract::<Sha256>(Some(&salt))
.expand(STANZA_KEY_LABEL, &mut okm)
.expect("okm is the correct length");
okm
};
let encrypted_file_key = {
let mut key = [0; ENCRYPTED_FILE_KEY_BYTES];
key.copy_from_slice(&aead_encrypt(&enc_key, file_key.expose_secret()));
key
};
RecipientLine {
tag: self.tag(),
epk_bytes,
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
}
+8 -18
View File
@@ -1,12 +1,11 @@
use bech32::{ToBase32, Variant};
use age_core::primitives::bech32_encode_to_fmt;
use p256::elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint};
use sha2::{Digest, Sha256};
use std::convert::TryInto;
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.
#[derive(Clone)]
@@ -20,15 +19,7 @@ impl fmt::Debug for Recipient {
impl fmt::Display for Recipient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(
bech32::encode(
RECIPIENT_PREFIX,
self.to_encoded().as_bytes().to_base32(),
Variant::Bech32,
)
.expect("HRP is valid")
.as_str(),
)
bech32_encode_to_fmt(f, RECIPIENT_PREFIX, self.to_encoded().as_bytes())
}
}
@@ -47,8 +38,8 @@ impl Recipient {
///
/// This accepts both compressed (as used by the plugin) and uncompressed (as used in
/// the YubiKey certificate) encodings.
pub(crate) fn from_encoded(encoded: &p256::EncodedPoint) -> Option<Self> {
p256::PublicKey::from_encoded_point(&encoded).map(Recipient)
fn from_encoded(encoded: &p256::EncodedPoint) -> Option<Self> {
Option::from(p256::PublicKey::from_encoded_point(encoded)).map(Recipient)
}
/// Returns the compressed SEC-1 encoding of this recipient.
@@ -57,8 +48,7 @@ impl Recipient {
}
pub(crate) fn tag(&self) -> [u8; TAG_BYTES] {
let tag = Sha256::digest(self.to_encoded().as_bytes());
(&tag[0..TAG_BYTES]).try_into().expect("length is correct")
static_tag(self.to_encoded().as_bytes())
}
/// Exposes the wrapped public key.
+88 -29
View File
@@ -2,17 +2,32 @@ use age_core::format::{FileKey, Stanza};
use age_plugin::{
identity::{self, IdentityPluginV1},
recipient::{self, RecipientPluginV1},
Callbacks,
Callbacks, PluginHandler,
};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::io;
use crate::{format, p256::Recipient, yubikey, 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)]
pub(crate) struct RecipientPlugin {
recipients: Vec<Recipient>,
yubikeys: Vec<yubikey::Stub>,
yubikeys: Vec<key::Stub>,
}
impl RecipientPluginV1 for RecipientPlugin {
@@ -22,17 +37,13 @@ impl RecipientPluginV1 for RecipientPlugin {
plugin_name: &str,
bytes: &[u8],
) -> Result<(), recipient::Error> {
if let Some(pk) = if plugin_name == PLUGIN_NAME {
Recipient::from_bytes(&bytes)
} else {
None
} {
if let Some(pk) = Recipient::from_bytes(plugin_name, bytes) {
self.recipients.push(pk);
Ok(())
} else {
Err(recipient::Error::Recipient {
index,
message: "Invalid recipient".to_owned(),
message: fl!("plugin-err-invalid-recipient"),
})
}
}
@@ -44,7 +55,7 @@ impl RecipientPluginV1 for RecipientPlugin {
bytes: &[u8],
) -> Result<(), recipient::Error> {
if let Some(stub) = if plugin_name == PLUGIN_NAME {
yubikey::Stub::from_bytes(&bytes, index)
key::Stub::from_bytes(bytes, index)
} else {
None
} {
@@ -53,11 +64,15 @@ impl RecipientPluginV1 for RecipientPlugin {
} else {
Err(recipient::Error::Identity {
index,
message: "Invalid Yubikey stub".to_owned(),
message: fl!("plugin-err-invalid-identity"),
})
}
}
fn labels(&mut self) -> HashSet<String> {
HashSet::new()
}
fn wrap_file_keys(
&mut self,
file_keys: Vec<FileKey>,
@@ -68,7 +83,14 @@ impl RecipientPluginV1 for RecipientPlugin {
let mut yk_errors = vec![];
for stub in &self.yubikeys {
match stub.connect(&mut callbacks)? {
Ok(conn) => yk_recipients.push(conn.recipient().clone()),
Ok(Some(conn)) => yk_recipients.push(conn.recipient().clone()),
Ok(None) => yk_errors.push(recipient::Error::Identity {
index: stub.identity_index,
message: fl!(
"plugin-err-yk-opening",
yubikey_serial = stub.serial.to_string(),
),
}),
Err(e) => yk_errors.push(match e {
identity::Error::Identity { index, message } => {
recipient::Error::Identity { index, message }
@@ -88,7 +110,7 @@ impl RecipientPluginV1 for RecipientPlugin {
self.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())
@@ -100,7 +122,7 @@ impl RecipientPluginV1 for RecipientPlugin {
#[derive(Debug, Default)]
pub(crate) struct IdentityPlugin {
yubikeys: Vec<yubikey::Stub>,
yubikeys: Vec<key::Stub>,
}
impl IdentityPluginV1 for IdentityPlugin {
@@ -111,7 +133,7 @@ impl IdentityPluginV1 for IdentityPlugin {
bytes: &[u8],
) -> Result<(), identity::Error> {
if let Some(stub) = if plugin_name == PLUGIN_NAME {
yubikey::Stub::from_bytes(&bytes, index)
key::Stub::from_bytes(bytes, index)
} else {
None
} {
@@ -120,7 +142,7 @@ impl IdentityPluginV1 for IdentityPlugin {
} else {
Err(identity::Error::Identity {
index,
message: "Invalid Yubikey stub".to_owned(),
message: fl!("plugin-err-invalid-identity"),
})
}
}
@@ -133,23 +155,20 @@ impl IdentityPluginV1 for IdentityPlugin {
let mut file_keys = HashMap::with_capacity(files.len());
// Filter to files / stanzas for which we have matching YubiKeys
let mut candidate_stanzas: Vec<(
&yubikey::Stub,
HashMap<usize, Vec<format::RecipientLine>>,
)> = self
let mut candidate_stanzas: Vec<(&key::Stub, HashMap<usize, Vec<SupportedStanza>>)> = self
.yubikeys
.iter()
.map(|stub| (stub, HashMap::new()))
.collect();
for (file, stanzas) in files.iter().enumerate() {
for (stanza_index, stanza) in stanzas.iter().enumerate() {
for (file, stanzas) in files.into_iter().enumerate() {
for (stanza_index, stanza) in stanzas.into_iter().enumerate() {
match (
format::RecipientLine::from_stanza(&stanza).map(|res| {
SupportedStanza::parse(stanza).map(|res| {
res.map_err(|_| identity::Error::Stanza {
file_index: file,
stanza_index,
message: "Invalid yubikey stanza".to_owned(),
message: fl!("plugin-err-invalid-stanza"),
})
}),
file_keys.contains_key(&file),
@@ -159,7 +178,7 @@ impl IdentityPluginV1 for IdentityPlugin {
// A line will match at most one YubiKey.
if let Some(files) =
candidate_stanzas.iter_mut().find_map(|(stub, files)| {
if stub.matches(&line) {
if line.matches_stub(stub) {
Some(files)
} else {
None
@@ -206,13 +225,22 @@ impl IdentityPluginV1 for IdentityPlugin {
for (stub, files) in candidate_stanzas.iter() {
let mut conn = match stub.connect(&mut callbacks)? {
Ok(conn) => conn,
// The user skipped this YubiKey.
Ok(None) => continue,
// We connected to this YubiKey.
Ok(Some(conn)) => conn,
// We failed to connect to this YubiKey.
Err(e) => {
callbacks.error(e)?.unwrap();
continue;
}
};
if let Err(e) = conn.request_pin_if_necessary(&mut callbacks)? {
callbacks.error(e)?.unwrap();
continue;
}
for (&file_index, stanzas) in files {
if file_keys.contains_key(&file_index) {
// We decrypted this file with an earlier YubiKey.
@@ -220,7 +248,7 @@ impl IdentityPluginV1 for IdentityPlugin {
}
for (stanza_index, line) in stanzas.iter().enumerate() {
match conn.unwrap_file_key(&line) {
match line.unwrap_file_key(&mut conn) {
Ok(file_key) => {
// We've managed to decrypt this file!
file_keys.entry(file_index).or_insert(Ok(file_key));
@@ -230,13 +258,44 @@ impl IdentityPluginV1 for IdentityPlugin {
.error(identity::Error::Stanza {
file_index,
stanza_index,
message: "Failed to decrypt YubiKey stanza".to_owned(),
message: fl!("plugin-err-decryption-failed"),
})?
.unwrap(),
}
}
}
conn.disconnect_without_reset();
}
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")
}
+128 -59
View File
@@ -1,13 +1,15 @@
use std::fmt;
use std::iter;
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
use x509_parser::{certificate::X509Certificate, der_parser::oid::Oid};
use yubikey_piv::{
key::{RetiredSlotId, SlotId},
policy::{PinPolicy, TouchPolicy},
Serial, YubiKey,
use yubikey::{
piv::{RetiredSlotId, SlotId},
Certificate, PinPolicy, Serial, TouchPolicy, YubiKey,
};
use crate::{error::Error, p256::Recipient, yubikey::Stub, BINARY_NAME, USABLE_SLOTS};
use crate::fl;
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];
@@ -42,27 +44,37 @@ pub(crate) fn touch_policy_from_string(s: String) -> Result<TouchPolicy, Error>
}
}
pub(crate) fn pin_policy_to_str(policy: Option<PinPolicy>) -> &'static str {
pub(crate) fn pin_policy_to_str(policy: Option<PinPolicy>) -> String {
match policy {
Some(PinPolicy::Always) => "Always (A PIN is required for every decryption, if set)",
Some(PinPolicy::Once) => "Once (A PIN is required once per session, if set)",
Some(PinPolicy::Never) => "Never (A PIN is NOT required to decrypt)",
_ => "Unknown",
Some(PinPolicy::Always) => fl!("pin-policy-always"),
Some(PinPolicy::Once) => fl!("pin-policy-once"),
Some(PinPolicy::Never) => fl!("pin-policy-never"),
_ => fl!("unknown-policy"),
}
}
pub(crate) fn touch_policy_to_str(policy: Option<TouchPolicy>) -> &'static str {
pub(crate) fn touch_policy_to_str(policy: Option<TouchPolicy>) -> String {
match policy {
Some(TouchPolicy::Always) => "Always (A physical touch is required for every decryption)",
Some(TouchPolicy::Cached) => {
"Cached (A physical touch is required for decryption, and is cached for 15 seconds)"
}
Some(TouchPolicy::Never) => "Never (A physical touch is NOT required to decrypt)",
_ => "Unknown",
Some(TouchPolicy::Always) => fl!("touch-policy-always"),
Some(TouchPolicy::Cached) => fl!("touch-policy-cached"),
Some(TouchPolicy::Never) => fl!("touch-policy-never"),
_ => fl!("unknown-policy"),
}
}
pub(crate) fn extract_name(cert: &X509Certificate, all: bool) -> Option<(String, bool)> {
const MODHEX: &str = "cbdefghijklnrtuv";
pub(crate) fn otp_serial_prefix(serial: Serial) -> String {
iter::repeat(0)
.take(4)
.chain((0..8).rev().map(|i| (serial.0 >> (4 * i)) & 0x0f))
.map(|i| MODHEX.char_indices().nth(i as usize).unwrap().1)
.collect()
}
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.
match cert.subject().iter_organization().next() {
Some(org) if org.as_str() == Ok(BINARY_NAME) => {
@@ -75,7 +87,16 @@ pub(crate) fn extract_name(cert: &X509Certificate, all: bool) -> Option<(String,
.map(|s| s.to_owned())
.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.
@@ -86,7 +107,7 @@ pub(crate) fn extract_name(cert: &X509Certificate, all: bool) -> Option<(String,
// Display the entire subject.
let name = cert.subject().to_string();
Some((name, false))
Some((name, None))
}
}
}
@@ -95,24 +116,30 @@ pub(crate) struct Metadata {
serial: Serial,
slot: RetiredSlotId,
name: String,
version: Option<String>,
created: String,
pin_policy: Option<PinPolicy>,
touch_policy: Option<TouchPolicy>,
pub(crate) pin_policy: Option<PinPolicy>,
pub(crate) touch_policy: Option<TouchPolicy>,
}
impl Metadata {
pub(crate) fn extract(
yubikey: &mut YubiKey,
slot: RetiredSlotId,
cert: &X509Certificate,
cert: &Certificate,
all: bool,
) -> Option<Self> {
let (_, cert) = x509_parser::parse_x509_certificate(cert.as_ref()).ok()?;
// We store the PIN and touch policies for identities in their certificates
// using the same certificate extension as PIV attestations.
// https://developers.yubico.com/PIV/Introduction/PIV_attestation.html
let policies = |c: &X509Certificate| {
c.extensions()
.get(&Oid::from(POLICY_EXTENSION_OID).unwrap())
c.tbs_certificate
.get_extension_unique(&Oid::from(POLICY_EXTENSION_OID).unwrap())
// If the extension is duplicated, we assume it is invalid.
.ok()
.flatten()
// If the encoded extension doesn't have 2 bytes, we assume it is invalid.
.filter(|policy| policy.value.len() >= 2)
.map(|policy| {
@@ -135,64 +162,106 @@ impl Metadata {
.unwrap_or((None, None))
};
extract_name(cert, all)
.map(|(name, ours)| {
if ours {
let (pin_policy, touch_policy) = policies(&cert);
(name, pin_policy, touch_policy)
extract_name_and_version(&cert, all)
.map(|(name, version)| {
let (pin_policy, touch_policy) = if version.is_some() {
policies(&cert)
} else {
// We can extract the PIN and touch policies via an attestation. This
// is slow, but the user has asked for all compatible keys, so...
let (pin_policy, touch_policy) =
yubikey_piv::key::attest(yubikey, SlotId::Retired(slot))
.ok()
.and_then(|buf| {
x509_parser::parse_x509_certificate(&buf)
.map(|(_, c)| policies(&c))
.ok()
})
.unwrap_or((None, None));
(name, pin_policy, touch_policy)
}
yubikey::piv::attest(yubikey, SlotId::Retired(slot))
.ok()
.and_then(|buf| {
x509_parser::parse_x509_certificate(&buf)
.map(|(_, c)| policies(&c))
.ok()
})
.unwrap_or((None, None))
};
(name, version, pin_policy, touch_policy)
})
.map(|(name, pin_policy, touch_policy)| Metadata {
.map(|(name, version, pin_policy, touch_policy)| Metadata {
serial: yubikey.serial(),
slot,
name,
created: cert.validity().not_before.to_rfc2822(),
version,
created: cert
.validity()
.not_before
.to_rfc2822()
.unwrap_or_else(|e| format!("Invalid date: {e}")),
pin_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 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(
f,
"# Serial: {}, Slot: {}",
self.serial,
slot_to_ui(&self.slot)
)?;
writeln!(f, "# Name: {}", self.name)?;
writeln!(f, "# Created: {}", self.created)?;
writeln!(f, "# PIN policy: {}", pin_policy_to_str(self.pin_policy))?;
write!(
f,
"# Touch policy: {}",
touch_policy_to_str(self.touch_policy)
"{}",
fl!(
"yubikey-metadata",
serial = self.serial.to_string(),
slot = slot_to_ui(&self.slot),
name = self.name.as_str(),
created = self.created.as_str(),
pin_policy = pin_policy_to_str(self.pin_policy),
touch_policy = touch_policy_to_str(self.touch_policy),
)
)
}
}
pub(crate) fn print_identity(stub: Stub, recipient: Recipient, metadata: Metadata) {
let legacy_recipient = recipient.legacy_recipient(&metadata);
let recipient = recipient.to_string();
if !console::user_attended() {
eprintln!("Recipient: {}", recipient);
let recipient = recipient.as_str();
eprintln!("{}", fl!("print-recipient", recipient = recipient));
}
println!("{}", metadata);
println!("# Recipient: {}", recipient);
println!("{}", stub.to_string());
let identity = if let Some(legacy_recipient) = legacy_recipient {
format!(
"{}\n{stub}",
fl!("yubikey-legacy-recipient", recipient = legacy_recipient),
)
} else {
stub.to_string()
};
println!(
"{}",
fl!(
"yubikey-identity",
yubikey_metadata = metadata.to_string(),
recipient = recipient,
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))
}
-409
View File
@@ -1,409 +0,0 @@
//! Structs for handling YubiKeys.
use age_core::{
format::{FileKey, FILE_KEY_BYTES},
primitives::{aead_decrypt, hkdf},
};
use age_plugin::{identity, Callbacks};
use bech32::{ToBase32, Variant};
use dialoguer::Password;
use log::warn;
use secrecy::ExposeSecret;
use std::convert::TryInto;
use std::fmt;
use std::io;
use std::iter;
use std::thread::sleep;
use std::time::{Duration, SystemTime};
use yubikey_piv::{
certificate::{Certificate, PublicKeyInfo},
key::{decrypt_data, AlgorithmId, RetiredSlotId, SlotId},
readers::Reader,
yubikey::Serial,
MgmKey, Readers, YubiKey,
};
use crate::{
error::Error,
format::{RecipientLine, STANZA_KEY_LABEL},
p256::{Recipient, TAG_BYTES},
IDENTITY_PREFIX,
};
const ONE_SECOND: Duration = Duration::from_secs(1);
const FIFTEEN_SECONDS: Duration = Duration::from_secs(15);
pub(crate) fn is_connected(reader: Reader) -> bool {
filter_connected(&reader)
}
pub(crate) fn filter_connected(reader: &Reader) -> bool {
match reader.open() {
Ok(_) => true,
Err(e) => {
use std::error::Error;
if let Some(pcsc::Error::RemovedCard) =
e.source().and_then(|inner| inner.downcast_ref())
{
warn!("Ignoring {}: not connected", reader.name());
false
} else {
true
}
}
}
}
pub(crate) fn wait_for_readers() -> Result<Readers, Error> {
// Start a 15-second timer waiting for a YubiKey to be inserted (if necessary).
let start = SystemTime::now();
loop {
let mut readers = Readers::open()?;
if readers.iter()?.any(is_connected) {
break Ok(readers);
}
match SystemTime::now().duration_since(start) {
Ok(end) if end >= FIFTEEN_SECONDS => return Err(Error::TimedOut),
_ => sleep(ONE_SECOND),
}
}
}
pub(crate) fn open(serial: Option<Serial>) -> Result<YubiKey, Error> {
if !Readers::open()?.iter()?.any(is_connected) {
if let Some(serial) = serial {
eprintln!("⏳ Please insert the YubiKey with serial {}.", serial);
} else {
eprintln!("⏳ Please insert the YubiKey.");
}
}
let mut readers = wait_for_readers()?;
let mut readers_iter = readers.iter()?.filter(filter_connected);
// --serial selects the YubiKey to use. If not provided, and more than one YubiKey is
// connected, an error is returned.
let yubikey = match (readers_iter.next(), readers_iter.next(), serial) {
(None, _, _) => unreachable!(),
(Some(reader), None, None) => reader.open()?,
(Some(reader), None, Some(serial)) => {
let yubikey = reader.open()?;
if yubikey.serial() != serial {
return Err(Error::NoMatchingSerial(serial));
}
yubikey
}
(Some(a), Some(b), Some(serial)) => {
let reader = iter::empty()
.chain(Some(a))
.chain(Some(b))
.chain(readers_iter)
.find(|reader| match reader.open() {
Ok(yk) => yk.serial() == serial,
_ => false,
})
.ok_or(Error::NoMatchingSerial(serial))?;
reader.open()?
}
(Some(_), Some(_), None) => return Err(Error::MultipleYubiKeys),
};
Ok(yubikey)
}
pub(crate) fn manage(yubikey: &mut YubiKey) -> Result<(), Error> {
eprintln!();
let pin = Password::new()
.with_prompt(&format!(
"Enter PIN for YubiKey with serial {} (default is 123456)",
yubikey.serial(),
))
.interact()?;
yubikey.verify_pin(pin.as_bytes())?;
// If the user is using the default PIN, help them to change it.
if pin == "123456" {
eprintln!();
eprintln!("✨ Your YubiKey is using the default PIN. Let's change it!");
eprintln!("✨ We'll also set the PUK equal to the PIN.");
eprintln!();
eprintln!("🔐 The PIN is up to 8 numbers, letters, or symbols. Not just numbers!");
eprintln!(
"❌ Your keys will be lost if the PIN and PUK are locked after 3 incorrect tries."
);
eprintln!();
let current_puk = Password::new()
.with_prompt("Enter current PUK (default is 12345678)")
.interact()?;
let new_pin = Password::new()
.with_prompt("Choose a new PIN/PUK")
.with_confirmation("Repeat the PIN/PUK", "PINs don't match")
.interact()?;
if new_pin.len() > 8 {
return Err(Error::InvalidPinLength);
}
yubikey.change_puk(current_puk.as_bytes(), new_pin.as_bytes())?;
yubikey.change_pin(pin.as_bytes(), new_pin.as_bytes())?;
}
if let Ok(mgm_key) = MgmKey::get_protected(yubikey) {
yubikey.authenticate(mgm_key)?;
} else {
// Try to authenticate with the default management key.
yubikey
.authenticate(MgmKey::default())
.map_err(|_| Error::CustomManagementKey)?;
// Migrate to a PIN-protected management key.
let mgm_key = MgmKey::generate()?;
eprintln!();
eprintln!("✨ Your YubiKey is using the default management key.");
eprintln!("✨ We'll migrate it to a PIN-protected management key.");
eprint!("... ");
mgm_key.set_protected(yubikey).map_err(|e| {
eprintln!("An error occurred while setting the new management key.");
eprintln!("⚠️ SAVE THIS MANAGEMENT KEY - YOU MAY NEED IT TO MANAGE YOUR YubiKey! ⚠️");
eprintln!(" {}", hex::encode(mgm_key.as_ref()));
e
})?;
eprintln!("Success!");
}
Ok(())
}
/// A reference to an age key stored in a YubiKey.
#[derive(Debug)]
pub struct Stub {
pub(crate) serial: Serial,
pub(crate) slot: RetiredSlotId,
pub(crate) tag: [u8; TAG_BYTES],
identity_index: usize,
}
impl fmt::Display for Stub {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(
bech32::encode(
IDENTITY_PREFIX,
self.to_bytes().to_base32(),
Variant::Bech32,
)
.expect("HRP is valid")
.to_uppercase()
.as_str(),
)
}
}
impl PartialEq for Stub {
fn eq(&self, other: &Self) -> bool {
self.to_bytes().eq(&other.to_bytes())
}
}
impl Stub {
/// Returns a key stub and recipient for this `(Serial, SlotId, PublicKey)` tuple.
///
/// Does not check that the `PublicKey` matches the given `(Serial, SlotId)` tuple;
/// this is checked at decryption time.
pub(crate) fn new(serial: Serial, slot: RetiredSlotId, recipient: &Recipient) -> Self {
Stub {
serial,
slot,
tag: recipient.tag(),
identity_index: 0,
}
}
pub(crate) fn from_bytes(bytes: &[u8], identity_index: usize) -> Option<Self> {
let serial = Serial::from(u32::from_le_bytes(bytes[0..4].try_into().unwrap()));
let slot: RetiredSlotId = bytes[4].try_into().ok()?;
Some(Stub {
serial,
slot,
tag: bytes[5..9].try_into().unwrap(),
identity_index,
})
}
fn to_bytes(&self) -> Vec<u8> {
let mut bytes = Vec::with_capacity(9);
bytes.extend_from_slice(&self.serial.0.to_le_bytes());
bytes.push(self.slot.into());
bytes.extend_from_slice(&self.tag);
bytes
}
pub(crate) fn matches(&self, line: &RecipientLine) -> bool {
self.tag == line.tag
}
pub(crate) fn connect<E>(
&self,
callbacks: &mut dyn Callbacks<E>,
) -> io::Result<Result<Connection, identity::Error>> {
let mut yubikey = match YubiKey::open_by_serial(self.serial) {
Ok(yk) => yk,
Err(yubikey_piv::Error::NotFound) => {
if callbacks
.message(&format!(
"Please insert YubiKey with serial {}",
self.serial
))?
.is_err()
{
return Ok(Err(identity::Error::Identity {
index: self.identity_index,
message: format!("Could not find YubiKey with serial {}", self.serial),
}));
}
// Start a 15-second timer waiting for the YubiKey to be inserted
let start = SystemTime::now();
loop {
match YubiKey::open_by_serial(self.serial) {
Ok(yubikey) => break yubikey,
Err(yubikey_piv::Error::NotFound) => (),
Err(_) => {
return Ok(Err(identity::Error::Identity {
index: self.identity_index,
message: format!(
"Could not open YubiKey with serial {}",
self.serial
),
}));
}
}
match SystemTime::now().duration_since(start) {
Ok(end) if end >= FIFTEEN_SECONDS => {
return Ok(Err(identity::Error::Identity {
index: self.identity_index,
message: format!(
"Timed out while waiting for YubiKey with serial {} to be inserted",
self.serial
),
}))
}
_ => sleep(ONE_SECOND),
}
}
}
Err(_) => {
return Ok(Err(identity::Error::Identity {
index: self.identity_index,
message: format!("Could not open YubiKey with serial {}", self.serial),
}))
}
};
// Read the pubkey from the YubiKey slot and check it still matches.
let pk = match Certificate::read(&mut yubikey, SlotId::Retired(self.slot))
.ok()
.and_then(|cert| match cert.subject_pki() {
PublicKeyInfo::EcP256(pubkey) => {
Recipient::from_encoded(pubkey).filter(|pk| pk.tag() == self.tag)
}
_ => None,
}) {
Some(pk) => pk,
None => {
return Ok(Err(identity::Error::Identity {
index: self.identity_index,
message: "A YubiKey stub did not match the YubiKey".to_owned(),
}))
}
};
let pin = match callbacks.request_secret(&format!(
"Enter PIN for YubiKey with serial {}",
self.serial
))? {
Ok(pin) => pin,
Err(_) => {
return Ok(Err(identity::Error::Identity {
index: self.identity_index,
message: format!("A PIN is required for YubiKey with serial {}", self.serial),
}))
}
};
if yubikey.verify_pin(pin.expose_secret().as_bytes()).is_err() {
return Ok(Err(identity::Error::Identity {
index: self.identity_index,
message: "Invalid YubiKey PIN".to_owned(),
}));
}
Ok(Ok(Connection {
yubikey,
pk,
slot: self.slot,
tag: self.tag,
}))
}
}
pub(crate) struct Connection {
yubikey: YubiKey,
pk: Recipient,
slot: RetiredSlotId,
tag: [u8; 4],
}
impl Connection {
pub(crate) fn recipient(&self) -> &Recipient {
&self.pk
}
pub(crate) fn unwrap_file_key(&mut self, line: &RecipientLine) -> Result<FileKey, ()> {
assert_eq!(self.tag, line.tag);
// The YubiKey API for performing scalar multiplication takes the point in its
// uncompressed SEC-1 encoding.
let shared_secret = match decrypt_data(
&mut self.yubikey,
line.epk_bytes.decompress().as_bytes(),
AlgorithmId::EccP256,
SlotId::Retired(self.slot),
) {
Ok(res) => res,
Err(_) => return Err(()),
};
let mut salt = vec![];
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(()),
}
}
}
#[cfg(test)]
mod tests {
use yubikey_piv::{key::RetiredSlotId, Serial};
use super::Stub;
#[test]
fn stub_round_trip() {
let stub = Stub {
serial: Serial::from(42),
slot: RetiredSlotId::R1,
tag: [7; 4],
identity_index: 0,
};
let encoded = stub.to_bytes();
assert_eq!(Stub::from_bytes(&encoded, 0), Some(stub));
}
}
+125
View File
@@ -0,0 +1,125 @@
use std::env;
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
const PLUGIN_BIN: &str = env!("CARGO_BIN_EXE_age-plugin-yubikey");
#[test_with::env(YUBIKEY_SERIAL, YUBIKEY_SLOT)]
#[cfg_attr(all(unix, not(target_os = "macos")), test_with::executable(pcscd))]
#[test]
fn recipient_and_identity_match() {
let recipient = Command::new(PLUGIN_BIN)
.arg("--list")
.arg("--serial")
.arg(env::var("YUBIKEY_SERIAL").unwrap())
.arg("--slot")
.arg(env::var("YUBIKEY_SLOT").unwrap())
.output()
.unwrap();
assert_eq!(recipient.status.code(), Some(0));
let identity = Command::new(PLUGIN_BIN)
.arg("--identity")
.arg("--serial")
.arg(env::var("YUBIKEY_SERIAL").unwrap())
.arg("--slot")
.arg(env::var("YUBIKEY_SLOT").unwrap())
.output()
.unwrap();
assert_eq!(identity.status.code(), Some(0));
let recipient_file = String::from_utf8_lossy(&recipient.stdout);
let recipient = recipient_file.lines().last().unwrap();
let identity = String::from_utf8_lossy(&identity.stdout);
assert!(identity.contains(recipient));
}
#[test_with::executable(rage)]
#[test]
fn plugin_encrypt() {
let enc_file = tempfile::NamedTempFile::new_in(env!("CARGO_TARGET_TMPDIR")).unwrap();
let mut process = Command::new(which::which("rage").unwrap())
.arg("-r")
.arg("age1yubikey1q2w7u3vpya839jxxuq8g0sedh3d740d4xvn639sqhr95ejj8vu3hyfumptt")
.arg("-o")
.arg(enc_file.path())
.stdin(Stdio::piped())
.env("PATH", Path::new(PLUGIN_BIN).parent().unwrap())
.spawn()
.unwrap();
// Scope to ensure stdin is closed.
{
let mut stdin = process.stdin.take().unwrap();
stdin.write_all(b"Testing YubiKey encryption").unwrap();
stdin.flush().unwrap();
}
let status = process.wait().unwrap();
assert_eq!(status.code(), Some(0));
}
#[test_with::env(YUBIKEY_SERIAL, YUBIKEY_SLOT)]
#[test_with::executable(rage)]
#[cfg_attr(all(unix, not(target_os = "macos")), test_with::executable(pcscd))]
#[test]
fn plugin_decrypt() {
let mut identity_file = tempfile::NamedTempFile::new_in(env!("CARGO_TARGET_TMPDIR")).unwrap();
let enc_file = tempfile::NamedTempFile::new_in(env!("CARGO_TARGET_TMPDIR")).unwrap();
let plaintext = "Testing YubiKey encryption";
// Write an identity file corresponding to this YubiKey slot.
let identity = Command::new(PLUGIN_BIN)
.arg("--identity")
.arg("--serial")
.arg(env::var("YUBIKEY_SERIAL").unwrap())
.arg("--slot")
.arg(env::var("YUBIKEY_SLOT").unwrap())
.output()
.unwrap();
assert_eq!(identity.status.code(), Some(0));
identity_file.write_all(&identity.stdout).unwrap();
identity_file.flush().unwrap();
// Encrypt to the YubiKey slot.
let mut enc_process = Command::new(which::which("rage").unwrap())
.arg("-e")
.arg("-i")
.arg(identity_file.path())
.arg("-o")
.arg(enc_file.path())
.stdin(Stdio::piped())
.env("PATH", Path::new(PLUGIN_BIN).parent().unwrap())
.spawn()
.unwrap();
// Scope to ensure stdin is closed.
{
let mut stdin = enc_process.stdin.take().unwrap();
stdin.write_all(plaintext.as_bytes()).unwrap();
stdin.flush().unwrap();
}
let enc_status = enc_process.wait().unwrap();
assert_eq!(enc_status.code(), Some(0));
// Decrypt with the YubiKey.
let dec_process = Command::new(which::which("rage").unwrap())
.arg("-d")
.arg("-i")
.arg(identity_file.path())
.arg(enc_file.path())
.stdin(Stdio::piped())
.env("PATH", Path::new(PLUGIN_BIN).parent().unwrap())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&dec_process.stderr);
if !stderr.is_empty() {
assert!(stderr.contains("age-plugin-yubikey"));
assert!(stderr.ends_with("...\n"));
}
assert_eq!(String::from_utf8_lossy(&dec_process.stdout), plaintext);
}