52 Commits

Author SHA1 Message Date
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
19 changed files with 2111 additions and 1114 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
+16 -15
View File
@@ -22,10 +22,10 @@ jobs:
os: macos-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1
with:
toolchain: 1.51.0
toolchain: 1.56.0
override: true
- name: Install build dependencies
run: sudo apt install ${{ matrix.build_deps }}
@@ -46,14 +46,14 @@ jobs:
args: --verbose
clippy:
name: Clippy (1.51.0)
name: Clippy (1.56.0)
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1
with:
toolchain: 1.51.0
toolchain: 1.56.0
components: clippy
override: true
- name: Install build dependencies
@@ -61,7 +61,7 @@ jobs:
- name: Run clippy
uses: actions-rs/clippy-check@v1
with:
name: Clippy (1.51.0)
name: Clippy (1.56.0)
token: ${{ secrets.GITHUB_TOKEN }}
args: --all-features --all-targets -- -D warnings
@@ -71,7 +71,7 @@ jobs:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1
with:
toolchain: nightly
@@ -92,7 +92,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
# Use stable for this to ensure that cargo-tarpaulin can be built.
- uses: actions-rs/toolchain@v1
with:
@@ -105,7 +105,7 @@ jobs:
with:
args: --release --timeout 180 --out Xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v1.0.3
uses: codecov/codecov-action@v3.1.0
with:
token: ${{secrets.CODECOV_TOKEN}}
@@ -114,10 +114,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1
with:
toolchain: 1.51.0
toolchain: 1.56.0
override: true
- name: Install build dependencies
run: sudo apt install libpcsclite-dev
@@ -139,13 +139,14 @@ jobs:
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1
with:
toolchain: 1.51.0
toolchain: 1.56.0
components: rustfmt
override: true
- run: rustup component add rustfmt
- uses: actions-rs/cargo@v1
- name: Check formatting
uses: actions-rs/cargo@v1
with:
command: fmt
args: -- --check
+5 -5
View File
@@ -20,7 +20,7 @@ jobs:
name: [linux, windows, macos]
include:
- name: linux
os: ubuntu-16.04
os: ubuntu-18.04
build_deps: >
libpcsclite-dev
archive_name: age-plugin-yubikey.tar.gz
@@ -37,7 +37,7 @@ jobs:
asset_suffix: x86_64-darwin.tar.gz
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
@@ -76,7 +76,7 @@ jobs:
if: matrix.name == 'windows'
- name: Upload archive to release
uses: svenstaro/upload-release-action@2.2.0
uses: svenstaro/upload-release-action@2.2.1
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ matrix.archive_name }}
@@ -98,7 +98,7 @@ jobs:
libpcsclite-dev
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
@@ -137,7 +137,7 @@ 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.2.1
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: target/${{ matrix.target }}/debian/*.deb
+39
View File
@@ -0,0 +1,39 @@
# 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]
## [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
+838 -429
View File
File diff suppressed because it is too large Load Diff
+19 -15
View File
@@ -1,19 +1,18 @@
[package]
name = "age-plugin-yubikey"
description = "[BETA] YubiKey plugin for age clients"
version = "0.1.0"
description = "YubiKey plugin for age clients"
version = "0.3.0"
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"
[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,25 +21,30 @@ assets = [
]
[dependencies]
age-core = "0.6"
age-plugin = "0.1"
age-core = "0.8"
age-plugin = "0.3"
base64 = "0.13"
bech32 = "0.8"
console = "0.14"
dialoguer = "0.8"
env_logger = "0.8"
console = { version = "0.15", default-features = false }
dialoguer = { version = "0.9", default-features = false, features = ["password"] }
env_logger = "0.9"
gumdrop = "0.8"
hex = "0.4"
log = "0.4"
p256 = { version = "0.7", features = ["ecdh"] }
p256 = { version = "0.9", features = ["ecdh"] }
pcsc = "2.4"
rand = "0.7"
secrecy = "0.7"
rand = "0.8"
sha2 = "0.9"
which = "4.1"
x509 = "0.2"
x509-parser = "0.9"
yubikey-piv = { version = "0.3", features = ["untested"] }
x509-parser = "0.12"
yubikey = { version = "0.5", features = ["untested"] }
# Translations
i18n-embed = { version = "0.13", features = ["desktop-requester", "fluent-system"] }
i18n-embed-fl = "0.6"
lazy_static = "1"
rust-embed = "6"
[dev-dependencies]
flate2 = "1"
+10 -6
View File
@@ -4,15 +4,12 @@
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
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
If your system has Rust 1.56+ installed (either via `rustup` or a system
package), you can build directly from source:
```
@@ -98,8 +95,15 @@ 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:
+4
View File
@@ -0,0 +1,4 @@
fallback_language = "en-US"
[fluent]
assets_dir = "i18n"
+196
View File
@@ -0,0 +1,196 @@
# 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
## 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-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-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
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 is up to 8 numbers, letters, or symbols. Not just numbers!
❌ 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-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!
## 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-custom-mgmt-key = Custom unprotected management keys are not supported.
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-length = The PIN needs to be 1-8 characters.
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 = 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-not-found = Please insert the {-yubikey} you want to set up
err-yk-wrong-pin = Invalid PIN ({$tries} tries remaining before it is blocked)
err-yk-general = Error while communicating with {-yubikey}: {$err}
err-yk-general-cause = Cause: {$inner_err}
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 = {" "}
+1
View File
@@ -0,0 +1 @@
1.56.0
+5 -6
View File
@@ -1,17 +1,16 @@
use rand::{rngs::OsRng, RngCore};
use x509::RelativeDistinguishedName;
use yubikey_piv::{
use yubikey::{
certificate::{Certificate, PublicKeyInfo},
key::{generate as yubikey_generate, AlgorithmId, RetiredSlotId, SlotId},
policy::{PinPolicy, TouchPolicy},
Key, YubiKey,
piv::{generate as yubikey_generate, AlgorithmId, RetiredSlotId, SlotId},
Key, PinPolicy, TouchPolicy, YubiKey,
};
use crate::{
error::Error,
key::{self, Stub},
p256::Recipient,
util::{Metadata, POLICY_EXTENSION_OID},
yubikey::{self, Stub},
BINARY_NAME, USABLE_SLOTS,
};
@@ -90,7 +89,7 @@ impl IdentityBuilder {
// 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)?;
key::manage(yubikey)?;
if let TouchPolicy::Never = touch_policy {
// No need to touch YubiKey
+106 -53
View File
@@ -1,9 +1,16 @@
use i18n_embed_fl::fl;
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))
};
}
pub enum Error {
CustomManagementKey,
InvalidFlagCommand(String, String),
@@ -21,7 +28,7 @@ pub enum Error {
SlotIsNotEmpty(RetiredSlotId),
TimedOut,
UseListForSingleSlot,
YubiKey(yubikey_piv::Error),
YubiKey(yubikey::Error),
}
impl From<io::Error> for Error {
@@ -30,8 +37,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)
}
}
@@ -41,90 +48,136 @@ impl From<yubikey_piv::error::Error> for Error {
impl fmt::Debug for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::CustomManagementKey => {
writeln!(f, "Custom unprotected management keys are not supported.")?
}
Error::InvalidFlagCommand(flag, command) => {
writeln!(f, "Flag '{}' cannot be used with '{}'.", flag, command)?
}
Error::CustomManagementKey => wlnfl!(f, "err-custom-mgmt-key")?,
Error::InvalidFlagCommand(flag, command) => writeln!(
f,
"{}",
fl!(
crate::LANGUAGE_LOADER,
"err-invalid-flag-command",
flag = flag.as_str(),
command = command.as_str(),
),
)?,
Error::InvalidFlagTui(flag) => writeln!(
f,
"Flag '{}' cannot be used with the interactive interface.",
flag
"{}",
fl!(
crate::LANGUAGE_LOADER,
"err-invalid-flag-tui",
flag = flag.as_str(),
),
)?,
Error::InvalidPinLength => writeln!(f, "The PIN needs to be 1-8 characters.")?,
Error::InvalidPinLength => wlnfl!(f, "err-invalid-pin-length")?,
Error::InvalidPinPolicy(s) => writeln!(
f,
"Invalid PIN policy '{}' (expected [always, once, never]).",
s
"{}",
fl!(
crate::LANGUAGE_LOADER,
"err-invalid-pin-policy",
policy = s.as_str(),
expected = "always, once, never",
),
)?,
Error::InvalidSlot(slot) => writeln!(
f,
"Invalid slot '{}' (expected number between 1 and 20).",
slot
"{}",
fl!(crate::LANGUAGE_LOADER, "err-invalid-slot", slot = slot),
)?,
Error::InvalidTouchPolicy(s) => writeln!(
f,
"Invalid touch policy '{}' (expected [always, cached, never]).",
s
"{}",
fl!(
crate::LANGUAGE_LOADER,
"err-invalid-touch-policy",
policy = s.as_str(),
expected = "always, cached, never",
),
)?,
Error::Io(e) => writeln!(f, "Failed to set up YubiKey: {}", e)?,
Error::MultipleCommands => writeln!(
Error::Io(e) => writeln!(
f,
"Only one of --generate, --identity, --list, --list-all can be specified."
"{}",
fl!(crate::LANGUAGE_LOADER, "err-io", err = e.to_string()),
)?,
Error::MultipleYubiKeys => writeln!(
Error::MultipleCommands => wlnfl!(f, "err-multiple-commands")?,
Error::MultipleYubiKeys => wlnfl!(f, "err-multiple-yubikeys")?,
Error::NoEmptySlots(serial) => writeln!(
f,
"Multiple YubiKeys are plugged in. Use --serial to select a single YubiKey."
"{}",
fl!(
crate::LANGUAGE_LOADER,
"err-no-empty-slots",
serial = serial.to_string(),
),
)?,
Error::NoMatchingSerial(serial) => writeln!(
f,
"{}",
fl!(
crate::LANGUAGE_LOADER,
"err-no-matching-serial",
serial = serial.to_string(),
),
)?,
Error::NoEmptySlots(serial) => {
writeln!(f, "YubiKey with serial {} has no empty slots.", serial)?
}
Error::NoMatchingSerial(serial) => {
writeln!(f, "Could not find YubiKey with serial {}.", serial)?
}
Error::SlotHasNoIdentity(slot) => writeln!(
f,
"Slot {} does not contain an age identity or compatible key.",
slot_to_ui(slot)
"{}",
fl!(
crate::LANGUAGE_LOADER,
"err-slot-has-no-identity",
slot = slot_to_ui(slot),
),
)?,
Error::SlotIsNotEmpty(slot) => writeln!(
f,
"Slot {} is not empty. Use --force to overwrite the slot.",
slot_to_ui(slot)
"{}",
fl!(
crate::LANGUAGE_LOADER,
"err-slot-is-not-empty",
slot = slot_to_ui(slot),
),
)?,
Error::TimedOut => {
writeln!(f, "Timed out while waiting for a YubiKey to be inserted.")?
}
Error::UseListForSingleSlot => {
writeln!(f, "Use --list to print the recipient for a single slot.")?
}
Error::TimedOut => wlnfl!(f, "err-timed-out")?,
Error::UseListForSingleSlot => wlnfl!(f, "err-use-list-for-single")?,
Error::YubiKey(e) => match e {
yubikey_piv::error::Error::NotFound => {
writeln!(f, "Please insert the YubiKey you want to set up")?
}
yubikey_piv::error::Error::WrongPin { tries } => writeln!(
yubikey::Error::NotFound => wlnfl!(f, "err-yk-not-found")?,
yubikey::Error::WrongPin { tries } => writeln!(
f,
"Invalid PIN ({} tries remaining before it is blocked)",
tries
"{}",
fl!(crate::LANGUAGE_LOADER, "err-yk-wrong-pin", tries = tries),
)?,
e => {
writeln!(f, "Error while communicating with YubiKey: {}", e)?;
writeln!(
f,
"{}",
fl!(
crate::LANGUAGE_LOADER,
"err-yk-general",
err = e.to_string(),
),
)?;
use std::error::Error;
if let Some(inner) = e.source() {
writeln!(f, "Cause: {}", inner)?;
writeln!(
f,
"{}",
fl!(
crate::LANGUAGE_LOADER,
"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")
)
}
}
+2 -3
View File
@@ -1,11 +1,10 @@
use age_core::{
format::{FileKey, Stanza},
primitives::{aead_encrypt, hkdf},
secrecy::ExposeSecret,
};
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};
@@ -106,7 +105,7 @@ impl RecipientLine {
let epk = esk.public_key();
let epk_bytes = EphemeralKeyBytes::from_public_key(&epk);
let shared_secret = esk.diffie_hellman(&pk.public_key());
let shared_secret = esk.diffie_hellman(pk.public_key());
let mut salt = vec![];
salt.extend_from_slice(epk_bytes.as_bytes());
+582
View File
@@ -0,0 +1,582 @@
//! Structs for handling YubiKeys.
use age_core::{
format::{FileKey, FILE_KEY_BYTES},
primitives::{aead_decrypt, hkdf},
secrecy::ExposeSecret,
};
use age_plugin::{identity, Callbacks};
use bech32::{ToBase32, Variant};
use dialoguer::Password;
use log::warn;
use std::fmt;
use std::io;
use std::iter;
use std::thread::sleep;
use std::time::{Duration, Instant, SystemTime};
use yubikey::{
certificate::{Certificate, PublicKeyInfo},
piv::{decrypt_data, AlgorithmId, RetiredSlotId, SlotId},
reader::{Context, Reader},
MgmKey, PinPolicy, Serial, TouchPolicy, YubiKey,
};
use crate::{
error::Error,
fl,
format::{RecipientLine, STANZA_KEY_LABEL},
p256::{Recipient, TAG_BYTES},
util::{otp_serial_prefix, Metadata},
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!(
"{}",
i18n_embed_fl::fl!(
crate::LANGUAGE_LOADER,
"warn-yk-not-connected",
yubikey_name = reader.name(),
)
);
false
} else {
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),
}
}
}
pub(crate) fn open(serial: Option<Serial>) -> Result<YubiKey, Error> {
if !Context::open()?.iter()?.any(is_connected) {
if let Some(serial) = serial {
eprintln!(
"{}",
i18n_embed_fl::fl!(
crate::LANGUAGE_LOADER,
"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) => 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> {
const DEFAULT_PIN: &str = "123456";
const DEFAULT_PUK: &str = "12345678";
eprintln!();
let pin = Password::new()
.with_prompt(i18n_embed_fl::fl!(
crate::LANGUAGE_LOADER,
"mgr-enter-pin",
yubikey_serial = yubikey.serial().to_string(),
default_pin = DEFAULT_PIN,
))
.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(i18n_embed_fl::fl!(
crate::LANGUAGE_LOADER,
"mgr-enter-current-puk",
default_puk = DEFAULT_PUK,
))
.interact()?;
let new_pin = Password::new()
.with_prompt(fl!("mgr-choose-new-pin"))
.with_confirmation(fl!("mgr-repeat-new-pin"), fl!("mgr-pin-mismatch"))
.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!("{}", fl!("mgr-changing-mgmt-key"));
eprint!("... ");
mgm_key.set_protected(yubikey).map_err(|e| {
eprintln!(
"{}",
i18n_embed_fl::fl!(
crate::LANGUAGE_LOADER,
"mgr-changing-mgmt-key-error",
management_key = hex::encode(mgm_key.as_ref()),
)
);
e
})?;
eprintln!("{}", fl!("mgr-changing-mgmt-key-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],
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_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> {
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
}
pub(crate) fn matches(&self, line: &RecipientLine) -> bool {
self.tag == line.tag
}
/// 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 YubiKey::open_by_serial(self.serial) {
Ok(yk) => yk,
Err(yubikey::Error::NotFound) => {
let mut message = i18n_embed_fl::fl!(
crate::LANGUAGE_LOADER,
"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 YubiKey::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: i18n_embed_fl::fl!(
crate::LANGUAGE_LOADER,
"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: i18n_embed_fl::fl!(
crate::LANGUAGE_LOADER,
"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 = i18n_embed_fl::fl!(
crate::LANGUAGE_LOADER,
"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: i18n_embed_fl::fl!(
crate::LANGUAGE_LOADER,
"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 YubiKey::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: i18n_embed_fl::fl!(
crate::LANGUAGE_LOADER,
"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: i18n_embed_fl::fl!(
crate::LANGUAGE_LOADER,
"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: i18n_embed_fl::fl!(
crate::LANGUAGE_LOADER,
"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| match cert.subject_pki() {
PublicKeyInfo::EcP256(pubkey) => Recipient::from_encoded(pubkey)
.filter(|pk| pk.tag() == self.tag)
.map(|pk| (cert, pk)),
_ => None,
}) {
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,
tag: self.tag,
identity_index: self.identity_index,
cached_metadata: None,
last_touch: None,
})))
}
}
pub(crate) struct Connection {
yubikey: YubiKey,
cert: Certificate,
pk: Recipient,
slot: RetiredSlotId,
tag: [u8; 4],
identity_index: usize,
cached_metadata: Option<Metadata>,
last_touch: Option<Instant>,
}
impl Connection {
pub(crate) fn recipient(&self) -> &Recipient {
&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() {
let (_, cert) = x509_parser::parse_x509_certificate(self.cert.as_ref()).unwrap();
self.cached_metadata =
match Metadata::extract(&mut self.yubikey, self.slot, &cert, true) {
None => {
return Ok(Err(identity::Error::Identity {
index: self.identity_index,
message: fl!("plugin-err-yk-invalid-pin-policy"),
}))
}
metadata => metadata,
};
}
if let Some(PinPolicy::Never) = self.cached_metadata.as_ref().and_then(|m| m.pin_policy) {
return Ok(Ok(()));
}
// The policy requires a PIN, so request it.
// Note that we can't distinguish between PinPolicy::Once and PinPolicy::Always
// because this plugin is ephemeral, so we always request the PIN.
let enter_pin_msg = i18n_embed_fl::fl!(
crate::LANGUAGE_LOADER,
"plugin-enter-pin",
yubikey_serial = self.yubikey.serial().to_string(),
);
let mut message = enter_pin_msg.clone();
let pin = loop {
message = match callbacks.request_secret(&message)? {
Ok(pin) => match pin.expose_secret().len() {
// A PIN must be between 6 and 8 characters.
6..=8 => break 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(self.yubikey.serial())) =>
{
format!("{} {}", fl!("plugin-err-accidental-touch"), enter_pin_msg)
}
// Otherwise, the PIN is either too short or too long.
0..=5 => format!("{} {}", fl!("plugin-err-pin-too-short"), enter_pin_msg),
_ => format!("{} {}", fl!("plugin-err-pin-too-long"), enter_pin_msg),
},
Err(_) => {
return Ok(Err(identity::Error::Identity {
index: self.identity_index,
message: i18n_embed_fl::fl!(
crate::LANGUAGE_LOADER,
"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 unwrap_file_key(&mut self, line: &RecipientLine) -> Result<FileKey, ()> {
assert_eq!(self.tag, line.tag);
// 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,
};
// 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(()),
};
// 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());
}
}
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::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);
}
}
+177 -109
View File
@@ -1,24 +1,31 @@
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::{
use i18n_embed::{
fluent::{fluent_language_loader, FluentLanguageLoader},
DesktopLanguageRequester,
};
use lazy_static::lazy_static;
use rust_embed::RustEmbed;
use yubikey::{
certificate::PublicKeyInfo,
key::{RetiredSlotId, SlotId},
policy::{PinPolicy, TouchPolicy},
Key, Readers, Serial,
piv::{RetiredSlotId, SlotId},
reader::Context,
Key, PinPolicy, Serial, TouchPolicy,
};
mod builder;
mod error;
mod format;
mod key;
mod p256;
mod plugin;
mod util;
mod yubikey;
use error::Error;
@@ -51,6 +58,23 @@ 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)
}};
}
#[derive(Debug, Options)]
struct PluginOptions {
#[options(help = "Print this help message and exit.")]
@@ -148,7 +172,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)
@@ -165,9 +189,9 @@ fn generate(flags: PluginFlags) -> Result<(), Error> {
fn print_single(
serial: Option<Serial>,
slot: RetiredSlotId,
printer: impl Fn(yubikey::Stub, p256::Recipient, util::Metadata),
printer: impl Fn(key::Stub, p256::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.
@@ -184,7 +208,7 @@ fn print_single(
.find(|(_, s, _)| s == &slot)
.ok_or(Error::SlotHasNoIdentity(slot))?;
let stub = yubikey::Stub::new(yubikey.serial(), slot, &recipient);
let stub = key::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))
@@ -199,12 +223,12 @@ fn print_multiple(
kind: &str,
serial: Option<Serial>,
all: bool,
printer: impl Fn(yubikey::Stub, p256::Recipient, util::Metadata),
printer: impl Fn(key::Stub, p256::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) {
for reader in readers.iter()?.filter(key::filter_connected) {
let mut yubikey = reader.open()?;
if let Some(serial) = serial {
if yubikey.serial() != serial {
@@ -228,7 +252,7 @@ fn print_multiple(
_ => continue,
};
let stub = yubikey::Stub::new(yubikey.serial(), slot, &recipient);
let stub = key::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))
@@ -245,8 +269,13 @@ fn print_multiple(
}
if printed > 1 {
eprintln!(
"Generated {} for {} slots. If you intended to select a slot, use --slot.",
kind, printed,
"{}",
i18n_embed_fl::fl!(
LANGUAGE_LOADER,
"printed-multiple",
kind = kind,
count = printed,
)
);
}
@@ -257,7 +286,7 @@ fn print_details(
kind: &str,
flags: PluginFlags,
all: bool,
printer: impl Fn(yubikey::Stub, p256::Recipient, util::Metadata),
printer: impl Fn(key::Stub, p256::Recipient, util::Metadata),
) -> Result<(), Error> {
if let Some(slot) = flags.slot {
print_single(flags.serial, slot, printer)
@@ -273,7 +302,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 +321,15 @@ 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);
println!("{}", recipient.to_string());
},
)
}
fn main() -> Result<(), Error> {
@@ -300,6 +339,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]
@@ -335,39 +380,39 @@ 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!(
"{}",
i18n_embed_fl::fl!(
LANGUAGE_LOADER,
"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()))
reader.open().map(|yk| {
i18n_embed_fl::fl!(
LANGUAGE_LOADER,
"cli-setup-yk-name",
yubikey_name = reader.name(),
yubikey_serial = yk.serial().to_string(),
)
})
})
.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)
.interact_opt()?
@@ -410,9 +455,20 @@ 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)) => i18n_embed_fl::fl!(
LANGUAGE_LOADER,
"cli-setup-slot-usable",
slot_index = i,
slot_name = name.as_str(),
),
Some(None) => i18n_embed_fl::fl!(
LANGUAGE_LOADER,
"cli-setup-slot-unusable",
slot_index = i,
),
None => {
i18n_embed_fl::fl!(LANGUAGE_LOADER, "cli-setup-slot-empty", slot_index = i)
}
}
})
.collect();
@@ -420,7 +476,7 @@ 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)
.interact_opt()?
@@ -444,10 +500,14 @@ fn main() -> Result<(), Error> {
};
if Confirm::new()
.with_prompt(&format!("Use existing identity in slot {}?", slot_index))
.with_prompt(i18n_embed_fl::fl!(
LANGUAGE_LOADER,
"cli-setup-use-existing",
slot_index = slot_index,
))
.interact()?
{
let stub = yubikey::Stub::new(yubikey.serial(), slot, &recipient);
let stub = key::Stub::new(yubikey.serial(), slot, &recipient);
let (_, cert) =
x509_parser::parse_x509_certificate(key.certificate().as_ref()).unwrap();
let metadata =
@@ -460,18 +520,19 @@ fn main() -> Result<(), Error> {
} 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)
.interact_text()?;
let pin_policy = match Select::new()
.with_prompt("🔤 Select a PIN policy")
.with_prompt(fl!("cli-setup-select-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)",
fl!("pin-policy-always"),
fl!("pin-policy-once"),
fl!("pin-policy-never"),
])
.default(
[PinPolicy::Always, PinPolicy::Once, PinPolicy::Never]
@@ -491,30 +552,35 @@ fn main() -> Result<(), Error> {
};
let touch_policy = match Select::new()
.with_prompt("👆 Select a touch 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)",
])
.default(
[TouchPolicy::Always, TouchPolicy::Cached, TouchPolicy::Never]
.iter()
.position(|p| p == &flags
.touch_policy.unwrap_or(builder::DEFAULT_TOUCH_POLICY))
.unwrap(),
)
.interact_opt()?
{
Some(0) => TouchPolicy::Always,
Some(1) => TouchPolicy::Cached,
Some(2) => TouchPolicy::Never,
Some(_) => unreachable!(),
None => return Ok(()),
};
.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(),
)
.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(i18n_embed_fl::fl!(
LANGUAGE_LOADER,
"cli-setup-generate-new",
slot_index = slot_index,
))
.interact()?
{
eprintln!();
@@ -537,7 +603,7 @@ 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)
@@ -552,7 +618,7 @@ 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"))
.interact()?
{
File::create(&file_name)?
@@ -563,54 +629,56 @@ fn main() -> Result<(), Error> {
Err(e) => return Err(e.into()),
};
writeln!(file, "{}", metadata)?;
writeln!(file, "# Recipient: {}", recipient)?;
writeln!(file, "{}", stub.to_string())?;
writeln!(
file,
"{}",
i18n_embed_fl::fl!(
LANGUAGE_LOADER,
"yubikey-identity",
yubikey_metadata = metadata.to_string(),
recipient = recipient.to_string(),
identity = stub.to_string(),
)
)?;
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",
let encrypt_usage = format!(
"$ 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",
let decrypt_usage = format!(
"$ 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 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!(
"{}",
i18n_embed_fl::fl!(
LANGUAGE_LOADER,
"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(())
}
+1 -2
View File
@@ -1,7 +1,6 @@
use bech32::{ToBase32, Variant};
use p256::elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint};
use sha2::{Digest, Sha256};
use std::convert::TryInto;
use std::fmt;
use crate::RECIPIENT_PREFIX;
@@ -48,7 +47,7 @@ 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)
p256::PublicKey::from_encoded_point(encoded).map(Recipient)
}
/// Returns the compressed SEC-1 encoding of this recipient.
+38 -24
View File
@@ -7,12 +7,12 @@ use age_plugin::{
use std::collections::HashMap;
use std::io;
use crate::{format, p256::Recipient, yubikey, PLUGIN_NAME};
use crate::{fl, format, key, p256::Recipient, PLUGIN_NAME};
#[derive(Debug, Default)]
pub(crate) struct RecipientPlugin {
recipients: Vec<Recipient>,
yubikeys: Vec<yubikey::Stub>,
yubikeys: Vec<key::Stub>,
}
impl RecipientPluginV1 for RecipientPlugin {
@@ -23,7 +23,7 @@ impl RecipientPluginV1 for RecipientPlugin {
bytes: &[u8],
) -> Result<(), recipient::Error> {
if let Some(pk) = if plugin_name == PLUGIN_NAME {
Recipient::from_bytes(&bytes)
Recipient::from_bytes(bytes)
} else {
None
} {
@@ -32,7 +32,7 @@ impl RecipientPluginV1 for RecipientPlugin {
} else {
Err(recipient::Error::Recipient {
index,
message: "Invalid recipient".to_owned(),
message: fl!("plugin-err-invalid-recipient"),
})
}
}
@@ -44,7 +44,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,7 +53,7 @@ impl RecipientPluginV1 for RecipientPlugin {
} else {
Err(recipient::Error::Identity {
index,
message: "Invalid Yubikey stub".to_owned(),
message: fl!("plugin-err-invalid-identity"),
})
}
}
@@ -68,7 +68,15 @@ 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: i18n_embed_fl::fl!(
crate::LANGUAGE_LOADER,
"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 +96,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| format::RecipientLine::wrap_file_key(&file_key, pk).into())
.collect()
})
.collect())
@@ -100,7 +108,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 +119,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 +128,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 +141,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
.yubikeys
.iter()
.map(|stub| (stub, HashMap::new()))
.collect();
let mut candidate_stanzas: Vec<(&key::Stub, HashMap<usize, Vec<format::RecipientLine>>)> =
self.yubikeys
.iter()
.map(|stub| (stub, HashMap::new()))
.collect();
for (file, stanzas) in files.iter().enumerate() {
for (stanza_index, stanza) in stanzas.iter().enumerate() {
match (
format::RecipientLine::from_stanza(&stanza).map(|res| {
format::RecipientLine::from_stanza(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),
@@ -206,13 +211,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 +234,7 @@ impl IdentityPluginV1 for IdentityPlugin {
}
for (stanza_index, line) in stanzas.iter().enumerate() {
match conn.unwrap_file_key(&line) {
match conn.unwrap_file_key(line) {
Ok(file_key) => {
// We've managed to decrypt this file!
file_keys.entry(file_index).or_insert(Ok(file_key));
@@ -230,7 +244,7 @@ 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(),
}
+60 -38
View File
@@ -1,13 +1,14 @@
use std::fmt;
use std::iter;
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},
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, p256::Recipient, BINARY_NAME, USABLE_SLOTS};
pub(crate) const POLICY_EXTENSION_OID: &[u64] = &[1, 3, 6, 1, 4, 1, 41482, 3, 8];
@@ -42,26 +43,33 @@ 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"),
}
}
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(cert: &X509Certificate, all: bool) -> Option<(String, bool)> {
// Look at Subject Organization to determine if we created this.
match cert.subject().iter_organization().next() {
@@ -96,8 +104,8 @@ pub(crate) struct Metadata {
slot: RetiredSlotId,
name: 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 {
@@ -111,8 +119,8 @@ impl Metadata {
// 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
.find_extension(&Oid::from(POLICY_EXTENSION_OID).unwrap())
// If the encoded extension doesn't have 2 bytes, we assume it is invalid.
.filter(|policy| policy.value.len() >= 2)
.map(|policy| {
@@ -138,13 +146,13 @@ impl Metadata {
extract_name(cert, all)
.map(|(name, ours)| {
if ours {
let (pin_policy, touch_policy) = policies(&cert);
let (pin_policy, touch_policy) = policies(cert);
(name, pin_policy, touch_policy)
} 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))
yubikey::piv::attest(yubikey, SlotId::Retired(slot))
.ok()
.and_then(|buf| {
x509_parser::parse_x509_certificate(&buf)
@@ -169,19 +177,19 @@ impl Metadata {
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)
"{}",
i18n_embed_fl::fl!(
crate::LANGUAGE_LOADER,
"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),
)
)
}
}
@@ -189,10 +197,24 @@ impl fmt::Display for Metadata {
pub(crate) fn print_identity(stub: Stub, recipient: Recipient, metadata: Metadata) {
let recipient = recipient.to_string();
if !console::user_attended() {
eprintln!("Recipient: {}", recipient);
eprintln!(
"{}",
i18n_embed_fl::fl!(
crate::LANGUAGE_LOADER,
"print-recipient",
recipient = recipient.as_str(),
)
);
}
println!("{}", metadata);
println!("# Recipient: {}", recipient);
println!("{}", stub.to_string());
println!(
"{}",
i18n_embed_fl::fl!(
crate::LANGUAGE_LOADER,
"yubikey-identity",
yubikey_metadata = metadata.to_string(),
recipient = recipient,
identity = stub.to_string(),
)
);
}
-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));
}
}