From 3175edfbc5b62cfe415987de1b93d6218918fd65 Mon Sep 17 00:00:00 2001 From: Hatter Jiang Date: Mon, 23 Sep 2024 23:51:08 +0800 Subject: [PATCH] feat: from https://github.com/wiktor-k/age-plugin-openpgp-card --- CONTRIBUTING.md | 76 ++++++++++ Cargo.toml | 23 +++ Dockerfile | 13 ++ LICENSE-APACHE | 201 ++++++++++++++++++++++++ LICENSE-MIT | 23 +++ README.md | 84 +++++++++- SECURITY.md | 27 ++++ deny.toml | 20 +++ scripts/encrypt-decrypt.sh | 19 +++ scripts/fake-pinentry.sh | 33 ++++ scripts/hooks/pre-commit | 1 + scripts/hooks/pre-push | 5 + src/main.rs | 304 +++++++++++++++++++++++++++++++++++++ 13 files changed, 827 insertions(+), 2 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT create mode 100644 SECURITY.md create mode 100644 deny.toml create mode 100755 scripts/encrypt-decrypt.sh create mode 100755 scripts/fake-pinentry.sh create mode 120000 scripts/hooks/pre-commit create mode 100755 scripts/hooks/pre-push create mode 100644 src/main.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0a40cc5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,76 @@ +# Contributing + +Thanks for taking the time to contribute to this project! + +All changes need to: + +- pass basic checks, including tests, formatting and lints, +- be signed-off. + +## Basic checks + +We are using standard Rust ecosystem tools including `rustfmt` and `clippy` with one minor difference. +Due to a couple of `rustfmt` features being available only in nightly (see the `.rustfmt.toml` file) nightly `rustfmt` is necessary. + +All of these details are captured in a `.justfile` and can be checked by running [`just`'](https://just.systems/). + +To run all checks locally before sending them to CI you can set your git hooks directory: + +```sh +git config core.hooksPath scripts/hooks/ +``` + +## Developer Certificate of Origin + +The sign-off is a simple line at the end of the git commit message, which certifies that you wrote it or otherwise have the right to pass it on as a open-source patch. + +The rules are pretty simple: if you can [certify the below][DCO]: + +``` +Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +(b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + +(c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +(d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. +``` + +then you just add a line saying + + Signed-off-by: Random J Developer + +using your name. + +If you set your `user.name` and `user.email`, you can sign your commit automatically with [`git commit --signoff`][GSO]. + +To sign-off your last commit: + + git commit --amend --signoff + +[DCO]: https://developercertificate.org +[GSO]: https://git-scm.com/docs/git-commit#git-commit---signoff + +If you want to fix multiple commits use: + + git rebase --signoff main + +To check if your commits are correctly signed-off locally use `just check-commits`. diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8961398 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "age-plugin-openpgp-card" +description = "Age plugin for using ed25519 on OpenPGP Card devices (Yubikeys, Nitrokeys)" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0 OR MIT" +keywords = ["age", "cli", "encryption", "openpgp", "yubikey"] +categories = ["command-line-utilities", "cryptography"] +authors = ["Wiktor Kwapisiewicz "] +repository = "https://github.com/wiktor-k/age-plugin-openpgp-card" + +[dependencies] +age-core = "0.10.0" +age-plugin = "0.5.0" +base64 = "0.22.0" +bech32 = "0.9" +card-backend-pcsc = "0.5.0" +clap = { version = "4.5.4", features = ["derive"] } +openpgp-card = "0.4.2" +subtle = "2.5.0" +thiserror = "1.0.58" +x25519-dalek = "2.0.1" +zeroize = "1.7.0" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..18246d0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM rust + +RUN apt-get update && apt-get install -y --no-install-recommends \ + vsmartcard-vpcd libpcsclite-dev pcscd opensc git libclang-dev nettle-dev \ + && rm -rf /var/lib/apt/lists/* +RUN cargo install --features="vpicc" --git https://github.com/Nitrokey/opcard-rs#v1.3.0 --example vpicc opcard +RUN cargo install openpgp-card-tools rage +COPY Cargo.toml Cargo.lock README.md /app/ +COPY src /app/src +WORKDIR /app/ +RUN cargo install --path . +COPY scripts /app/scripts +RUN ./scripts/encrypt-decrypt.sh diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..16fe87b --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..31aa793 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,23 @@ +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 7e36af3..6164417 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,83 @@ -# age-plugin-openpgp-card +# Age Plugin: OpenPGP Card -From: https://github.com/wiktor-k/age-plugin-openpgp-card \ No newline at end of file +[![CI](https://github.com/wiktor-k/age-plugin-openpgp-card/actions/workflows/rust.yml/badge.svg)](https://github.com/wiktor-k/age-plugin-openpgp-card/actions/workflows/rust.yml) +[![Crates.io](https://img.shields.io/crates/v/age-plugin-openpgp-card)](https://crates.io/crates/age-plugin-openpgp-card) + +This age plugin lets you reuse your OpenPGP Card devices (such as [Yubikeys](https://www.yubico.com/products/yubikey-5-overview/) or [Nitrokeys](https://www.nitrokey.com/products/nitrokeys)) for [age decryption](https://age-encryption.org/). + +Why? [OpenPGP Card](https://en.wikipedia.org/wiki/OpenPGP_card), contrary to its name, is just a generic cryptographic device standard. +Most importantly the specification and the real-world devices (e.g. [Yubikeys](https://docs.yubico.com/hardware/yubikey/yk-tech-manual/yk5-apps.html#elliptic-curve-cryptographic-ecc-algorithms) and [Nitrokeys](https://docs.nitrokey.com/nitrokey3/faq#which-algorithms-and-maximum-key-length-are-supported)) support [curve25519](https://en.wikipedia.org/wiki/Curve25519). + +This application is a no-moving-parts solution which requires only [`pcsc-lite`](https://github.com/LudovicRousseau/PCSC) on Linux and reuses built-in smartcard services on Windows and macOS. No GnuPG needed, no other OpenPGP software is used or accessed. + +If you don't need curve25519 and are using Yubikeys then the [`age-plugin-yubikey`](https://github.com/str4d/age-plugin-yubikey) provides a more polished experience. + +This plugin assumes that you have already provisioned the card. +[`oct admin generate`](https://codeberg.org/openpgp-card/openpgp-card-tools/#generate-keys-on-the-card) may be used to provision the card with a new curve25519 key. +(This is actually how end-to-end tests are implemented. See `scripts/encrypt-and-decrypt.sh`). + +## Installation + +At this moment the installation from `crates.io` is the only supported method: + +```sh +cargo install --locked age-plugin-openpgp-card +``` + +## Usage + +Running the tool directly outputs the public keys and the identity stubs for all connected cards: + +```sh +$ age-plugin-openpgp-card | tee identity.txt +# Card ident 0006:15422467 +# age1dkfzfyk58yvkf07n32nygkyuqxtnq2am427sy79gjkh6krf96frsucn0me +AGE-PLUGIN-OPENPGP-CARD-1XQCRQD36XY6NGV3JXSMRWAN88PC +``` + +Note that the public key looks like a regular age ed25519 key. +The stub encodes the card identifier and is mostly irrelevant. +If the stub is lost it may be regenerated - if the key on the card is the same the decryption will succeed. + +Any age-compatible tool can be used for encryption: + +```sh +$ echo I like strawberries | age -r age1dkfzfyk58yvkf07n32nygkyuqxtnq2am427sy79gjkh6krf96frsucn0me -a > encrypted.age +``` + +And the identity stubs are required for decryption: + +```sh +$ age -d -i identity.txt < encrypted.age +I like strawberries +``` + +The plugin will ask you for the PIN using built-in plugin protocol (e.g. [`rage`](https://github.com/str4d/rage) would show a pin-entry prompt). + +## Tests + +This repository contains end-to-end integration tests which run a [virtual Nitrokey card](https://github.com/Nitrokey/opcard-rs), provision it with a new key and then encrypt and decrypt data using `rage`. + +## Thanks + +The plugin is basically glue code for already existing, awesome libraries and tools: + +- [`openpgp-card`](https://crates.io/crates/openpgp-card) which interacts with the smartcards, +- [`age-plugin`](https://crates.io/crates/age-plugin) which provides easy to use framework for writing age plugins, + +And, last but not least, [`opcard`](https://github.com/Nitrokey/opcard-rs) which provides us with a virtual card to test that all of this really works! + +Thank you very much for all contributors to these projects 🙇‍♂️ + +## License + +This project is licensed under either of: + + - [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0), + - [MIT license](https://opensource.org/licenses/MIT). + +at your option. + +### Contribution + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..490de7e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +# Security policy + +If you have discovered a security vulnerability in this project, please report it privately. +Do not disclose it as a public issue. +This gives us time to work with you to fix the issue before public exposure, reducing the chance that the exploit will be used before a patch is released. + +This project is maintained by a team of volunteers on a reasonable-effort basis. +As such, please give us at least 90 days to work on a fix before public exposure. +We will contact you back within 2 business days after reporting the issue. + +Thanks for helping make the project safe for everyone! + +## Reporting a vulnerability + +Please, report the vulnerability either through [new security advisory form][ADV] or by directly contacting our security contacts. + +[ADV]: https://github.com/wiktor-k/age-plugin-openpgp-card/security/advisories/new + +Security contacts: + - [Wiktor Kwapisiewicz][WK], preferably encrypted with the following OpenPGP certificate: [`6539 09A2 F0E3 7C10 6F5F AF54 6C88 57E0 D8E8 F074`][KEY]. + +[WK]: https://github.com/wiktor-k +[KEY]: https://keys.openpgp.org/vks/v1/by-fingerprint/653909A2F0E37C106F5FAF546C8857E0D8E8F074 + +## Supported Versions + +Security updates are applied only to the most recent release. diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..50dcec2 --- /dev/null +++ b/deny.toml @@ -0,0 +1,20 @@ +[advisories] +version = 2 +yanked = "deny" +ignore = [ +] + +[bans] +deny = [ +] +multiple-versions = "allow" + +[licenses] +version = 2 +allow = [ + "Apache-2.0", + "MIT", + "ISC", + "Unicode-DFS-2016", + "BSD-3-Clause", +] diff --git a/scripts/encrypt-decrypt.sh b/scripts/encrypt-decrypt.sh new file mode 100755 index 0000000..f89df3e --- /dev/null +++ b/scripts/encrypt-decrypt.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +set -euxo pipefail + +/etc/init.d/pcscd start + +# start a virtual smartcard +vpicc & +sleep 2 + +export PINENTRY_PROGRAM=/app/scripts/fake-pinentry.sh +echo 12345678 > admin-pin +echo 123456 > user-pin +oct admin --card 0000:00000000 --admin-pin admin-pin generate --user-pin user-pin --output /tmp/no-need-for-this cv25519 + +age-plugin-openpgp-card | tee identity.txt +grep -oh "age1.*" identity.txt > recipients.txt +echo I like strawberries | rage -R recipients.txt -a | tee encrypted.age +rage -d -i identity.txt < encrypted.age diff --git a/scripts/fake-pinentry.sh b/scripts/fake-pinentry.sh new file mode 100755 index 0000000..f02d91a --- /dev/null +++ b/scripts/fake-pinentry.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# Use this for your test suites when a POSIX shell is available. +# +# The encrypted keys in your test suite that you expect to work must +# be locked with a passphrase of "passphrase" +# +# Author: Daniel Kahn Gillmor +# +# License: Creative Commons Zero ("Public Domain Dedication") -- +# Anyone may reuse it, modify it, redistribute it for any purpose. + +echo "OK This is only for test suites, and should never be used in production" +while read cmd rest; do + cmd=$(printf "%s" "$cmd" | tr 'A-Z' 'a-z') + if [ -z "$cmd" ]; then + continue; + fi + case "$cmd" in + \#*) + ;; + getpin) + echo "D 123456" + echo "OK" + ;; + bye) + echo "OK" + exit 0 + ;; + *) + echo "OK" + ;; + esac +done diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit new file mode 120000 index 0000000..4838f41 --- /dev/null +++ b/scripts/hooks/pre-commit @@ -0,0 +1 @@ +../../.justfile \ No newline at end of file diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push new file mode 100755 index 0000000..b5d54da --- /dev/null +++ b/scripts/hooks/pre-push @@ -0,0 +1,5 @@ +#!/usr/bin/env sh + +set -euo pipefail + +just check-commits diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d9a1417 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,304 @@ +#![doc = include_str!("../README.md")] + +use std::collections::HashMap; +use std::io; + +use age_core::{ + format::FILE_KEY_BYTES, + primitives::{aead_decrypt, hkdf}, + secrecy::ExposeSecret, +}; +use age_core::{ + format::{FileKey, Stanza}, + secrecy::Zeroize as _, +}; +use age_plugin::{ + identity::{self, IdentityPluginV1}, + recipient::{self, RecipientPluginV1}, + run_state_machine, Callbacks, +}; +use bech32::{ToBase32, Variant}; +use card_backend_pcsc::PcscBackend; +use clap::Parser; +use openpgp_card::{crypto_data::PublicKeyMaterial, Card}; +use subtle::ConstantTimeEq; +use x25519_dalek::PublicKey; + +// Use lower-case HRP to avoid https://github.com/rust-bitcoin/rust-bech32/issues/40 +const IDENTITY_PREFIX: &str = "age-plugin-openpgp-card-"; +const PUBLIC_KEY_PREFIX: &str = "age"; +const PLUGIN_NAME: &str = "openpgp-card"; + +pub const X25519_RECIPIENT_TAG: &str = "X25519"; +const X25519_RECIPIENT_KEY_LABEL: &[u8] = b"age-encryption.org/v1/X25519"; + +pub const EPK_LEN_BYTES: usize = 32; +pub const ENCRYPTED_FILE_KEY_BYTES: usize = FILE_KEY_BYTES + 16; +struct RecipientPlugin; + +impl RecipientPluginV1 for RecipientPlugin { + fn add_recipient( + &mut self, + _index: usize, + _plugin_name: &str, + _bytes: &[u8], + ) -> Result<(), recipient::Error> { + todo!() + } + + fn add_identity( + &mut self, + _index: usize, + _plugin_name: &str, + _bytes: &[u8], + ) -> Result<(), recipient::Error> { + todo!() + } + + fn wrap_file_keys( + &mut self, + _file_keys: Vec, + _callbacks: impl Callbacks, + ) -> io::Result>, Vec>> { + todo!() + } +} + +struct CardStub { + ident: String, +} + +struct IdentityPlugin { + cards: Vec, +} + +use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine}; +pub(crate) fn base64_arg, const N: usize, const B: usize>( + arg: &A, +) -> Option<[u8; N]> { + if N > B { + return None; + } + + let mut buf = [0; B]; + match BASE64_STANDARD_NO_PAD.decode_slice(arg, buf.as_mut()) { + Ok(n) if n == N => Some(buf[..N].try_into().unwrap()), + _ => None, + } +} + +#[derive(Debug, thiserror::Error)] +enum DecryptError { + #[error("Invalid header")] + InvalidHeader, + #[error("Card does not contain ECC key")] + NonEccCard, +} + +impl IdentityPlugin { + fn get_card(ident: &str) -> Result, Box> { + for backend in PcscBackend::cards(None)? { + let mut card = Card::new(backend?)?; + let tx = card.transaction()?; + if ident == tx.application_identifier()?.ident() { + drop(tx); + return Ok(Some(card)); + } + } + Ok(None) + } + + fn unwrap_stanza( + &mut self, + stanza: &Stanza, + callbacks: &mut impl Callbacks, + ) -> Result, Box> { + if stanza.tag != X25519_RECIPIENT_TAG { + return Err(std::io::Error::other("bad stanza tag").into()); + } + + // Enforce valid and canonical stanza format. + // https://c2sp.org/age#x25519-recipient-stanza + let ephemeral_share = match &stanza.args[..] { + [arg] => match base64_arg::<_, EPK_LEN_BYTES, 33>(arg) { + Some(ephemeral_share) => ephemeral_share, + None => return Err(DecryptError::InvalidHeader.into()), + }, + _ => return Err(DecryptError::InvalidHeader.into()), + }; + if stanza.body.len() != ENCRYPTED_FILE_KEY_BYTES { + return Err(DecryptError::InvalidHeader.into()); + } + + let epk: PublicKey = ephemeral_share.into(); + let encrypted_file_key: [u8; ENCRYPTED_FILE_KEY_BYTES] = stanza.body[..] + .try_into() + .expect("Length should have been checked above"); + + 'cards: for card_stub in self.cards.iter() { + let mut card = loop { + let car = Self::get_card(&card_stub.ident)?; + if let Some(card) = car { + break card; + } else { + let res = callbacks.confirm( + &format!("Please insert card {}", card_stub.ident), + "OK", + None, + )??; + if !res { + continue 'cards; + }; + } + }; + let mut tx = card.transaction()?; + let pk: Vec = if let PublicKeyMaterial::E(ecc) = + tx.public_key(openpgp_card::KeyType::Decryption)? + { + ecc.data().into() + } else { + return Err(DecryptError::NonEccCard.into()); + }; + tx.verify_pw1_user( + callbacks + .request_secret(&format!("Unlock card {}", card_stub.ident))?? + .expose_secret() + .as_bytes(), + )?; + + if let Ok(Some(uif)) = tx.application_related_data()?.uif_pso_aut() { + if uif.touch_policy().touch_required() { + callbacks.message(&format!( + "Touch your card {} now to decrypt.", + card_stub.ident + ))??; + } + }; + + let shared_secret = tx.decipher(openpgp_card::crypto_data::Cryptogram::ECDH( + &ephemeral_share, + ))?; + if shared_secret + .iter() + .fold(0, |acc, b| acc | b) + .ct_eq(&0) + .into() + { + return Err(DecryptError::InvalidHeader.into()); + } + + let mut salt = [0; 64]; + salt[..32].copy_from_slice(epk.as_bytes()); + salt[32..].copy_from_slice(&pk[..]); + + let enc_key = hkdf(&salt, X25519_RECIPIENT_KEY_LABEL, &shared_secret); + + // A failure to decrypt is non-fatal (we try to decrypt the recipient + // stanza with other X25519 keys), because we cannot tell which key + // matches a particular stanza. + if let Some(result) = aead_decrypt(&enc_key, FILE_KEY_BYTES, &encrypted_file_key) + .ok() + .map(|mut pt| { + // It's ours! + let file_key: [u8; FILE_KEY_BYTES] = pt[..].try_into().unwrap(); + pt.zeroize(); + FileKey::from(file_key) + }) + { + return Ok(Some(result)); + } + } + Ok(None) + } +} + +impl IdentityPluginV1 for IdentityPlugin { + fn add_identity( + &mut self, + index: usize, + plugin_name: &str, + bytes: &[u8], + ) -> Result<(), identity::Error> { + if plugin_name == PLUGIN_NAME { + self.cards.push(CardStub { + ident: String::from_utf8_lossy(bytes).to_string(), + }); + Ok(()) + } else { + Err(identity::Error::Identity { + index, + message: "invalid recipient".into(), + }) + } + } + + fn unwrap_file_keys( + &mut self, + files: Vec>, + mut callbacks: impl Callbacks, + ) -> io::Result>>> { + let mut file_keys = HashMap::with_capacity(files.len()); + for (file_index, stanzas) in files.iter().enumerate() { + for (stanza_index, stanza) in stanzas.iter().enumerate() { + match self.unwrap_stanza(stanza, &mut callbacks).map_err(|e| { + vec![identity::Error::Stanza { + file_index, + stanza_index, + message: e.to_string(), + }] + }) { + Ok(Some(file_key)) => { + file_keys.entry(file_index).or_insert(Ok(file_key)); + } + + Err(error) => { + file_keys.entry(file_index).or_insert(Err(error)); + } + _ => {} + } + } + } + + Ok(file_keys) + } +} + +#[derive(Debug, Parser)] +struct PluginOptions { + #[arg(help = "run the given age plugin state machine", long)] + age_plugin: Option, +} + +fn main() -> Result<(), Box> { + let opts = PluginOptions::parse(); + + if let Some(state_machine) = opts.age_plugin { + return Ok(run_state_machine( + &state_machine, + Some(|| RecipientPlugin), + Some(|| IdentityPlugin { cards: vec![] }), + )?); + } + + for backend in PcscBackend::cards(None)? { + let mut card = Card::new(backend?)?; + let mut tx = card.transaction()?; + if let PublicKeyMaterial::E(ecc) = tx.public_key(openpgp_card::KeyType::Decryption)? { + let ident = tx.application_identifier()?.ident(); + println!("# Card ident {}", ident); + println!( + "# {}", + bech32::encode(PUBLIC_KEY_PREFIX, ecc.data().to_base32(), Variant::Bech32)? + ); + + println!( + "{}", + bech32::encode(IDENTITY_PREFIX, ident.to_base32(), Variant::Bech32,)? + .to_uppercase() + ); + println!(); + } + } + + Ok(()) +}