Compare commits

..

105 commits

Author SHA1 Message Date
Jack Grigg
d7c727aef9
Merge pull request #554 from str4d/bugfix-0.11.1
plugin: restrict characters in plugin names
2024-12-19 04:46:19 +13:00
Jack Grigg
0780882307 Update changelog with GHSA for security vulnerability
Thanks to ⬡-49016 for reporting this issue.
2024-12-18 15:18:02 +00:00
Jack Grigg
a82a76a849 v0.11.1 2024-11-18 07:11:33 +00:00
Jack Grigg
383b6f52aa Replace the test NoCallbacks with the library version 2024-11-18 07:06:16 +00:00
Jack Grigg
741de973ee Merge branch 'bugfix-0.10.1' into bugfix-0.11.1 2024-11-18 07:04:30 +00:00
Jack Grigg
17446612f8
Merge pull request #545 from str4d/release-0.11.0
Release 0.11.0
2024-11-03 10:50:41 +00:00
Jack Grigg
d35d442f91 v0.11.0 2024-11-03 10:42:17 +00:00
Jack Grigg
e3a5c5fe8c Update user handles in readmes 2024-11-03 10:41:37 +00:00
Jack Grigg
25e050362c fuzz: Fix targets 2024-11-03 10:41:22 +00:00
Jack Grigg
597f1aa05f
Merge pull request #544 from str4d/pre-release-changes
Pre-release changes
2024-11-03 09:30:56 +00:00
Jack Grigg
ae5a392925 Provide a better error on invalid filename or missing directory
Closes str4d/rage#530.
2024-11-03 08:22:43 +00:00
Jack Grigg
1d2b3bfa37 age: Merge error cases in cli_common::file_io 2024-11-03 08:04:59 +00:00
Jack Grigg
bca6916bac Update docs to permit multiple stanzas from recipients
Closes str4d/rage#524.
2024-11-03 07:30:16 +00:00
Jack Grigg
d0889c90af age: Document crate's calling contract of Identity::unwrap_stanza
Closes str4d/rage#509.
2024-11-03 07:04:20 +00:00
Jack Grigg
93fa28ad78 Migrate to secrecy 0.10 2024-11-03 05:38:51 +00:00
Jack Grigg
a59f0479d0 cargo update 2024-11-03 04:41:41 +00:00
Jack Grigg
baf277a749
Merge pull request #542 from str4d/plugin-old-client-fix
age-plugin: Fix no-label recipient plugins with old clients
2024-10-20 05:26:20 +01:00
Jack Grigg
e8f14448e4 Update cargo-vet 2024-10-20 04:17:31 +00:00
Jack Grigg
5bae3f1eae age-plugin: Fix no-label recipient plugins with old clients 2024-10-19 23:56:48 +00:00
Jack Grigg
bdec23fe8d
Merge pull request #532 from str4d/dependabot/github_actions/codecov/codecov-action-4.6.0
build(deps): bump codecov/codecov-action from 4.5.0 to 4.6.0
2024-10-07 04:31:42 +01:00
dependabot[bot]
a5661495f6
build(deps): bump codecov/codecov-action from 4.5.0 to 4.6.0
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.5.0 to 4.6.0.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v4.5.0...v4.6.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-02 03:14:33 +00:00
Jack Grigg
504d784931
Merge pull request #502 from str4d/dependabot/github_actions/codecov/codecov-action-4.5.0
build(deps): bump codecov/codecov-action from 4.4.1 to 4.5.0
2024-09-03 18:27:17 -07:00
Jack Grigg
deb59935c1
Merge pull request #527 from str4d/526-macports
rage: Add MacPorts package to installation list
2024-09-03 18:08:18 -07:00
Jack Grigg
5237281929 rage: Add MacPorts package to installation list
Closes str4d/rage#526.
2024-09-04 01:06:05 +00:00
dependabot[bot]
5955e489b7
build(deps): bump codecov/codecov-action from 4.4.1 to 4.5.0
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.4.1 to 4.5.0.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v4.4.1...v4.5.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-31 13:04:31 +00:00
Jack Grigg
53d018a9c2
Merge pull request #525 from str4d/333-streamlined-apis
age: Add streamlined APIs for encryption and decryption
2024-08-30 18:04:58 -07:00
Jack Grigg
195b86b6bc age: Add streamlined APIs for encryption and decryption
Closes str4d/rage#333.
2024-08-30 11:34:01 -04:00
Jack Grigg
9af479086b
Merge pull request #523 from str4d/recipients-by-ref
age: Take recipients by reference in `Encryptor::with_recipients`
2024-08-30 08:29:12 -07:00
Jack Grigg
9ab26bf360 age: Take recipients by reference in Encryptor::with_recipients
This aligns it with `Decryptor`, and means that recipients can be
used to encrypt multiple files without cloning.

Part of str4d/rage#353.
2024-08-30 10:40:34 -04:00
Jack Grigg
8b0b65e98a
Merge pull request #522 from str4d/release-workflow-0.11
Release workflow updates for 0.11.0
2024-08-28 09:22:54 -07:00
Jack Grigg
9343af9324 CI: Generate Artifact Attestations for release artifacts 2024-08-28 16:10:33 +00:00
Jack Grigg
05f996c919 CI: Add ubuntu-24.04 to release testing 2024-08-28 15:47:41 +00:00
Jack Grigg
e67f4016dc CI: Remove macos-11 from release testing
It was removed by GitHub in Q2 2024.
2024-08-28 15:45:59 +00:00
Jack Grigg
0cdde60315 CI: Build releases for arm64-darwin 2024-08-28 15:44:47 +00:00
Jack Grigg
917fc489f6 CI: Build x86_64-darwin release with macos-13 runner
`macos-latest` now points to `macos-14` which is an ARM64 chip.
2024-08-28 15:42:21 +00:00
Jack Grigg
c5f0d61400
Merge pull request #521 from str4d/374-pipe-input-with-passphrase
rage: Allow piping input when encrypting with passphrase on Unix
2024-08-28 07:27:29 -07:00
Jack Grigg
a709c93c92 rage: Allow piping input when encrypting with passphrase on Unix
Closes str4d/rage#374.
2024-08-28 14:17:03 +00:00
Jack Grigg
51760e34ba
Merge pull request #513 from BoostCookie/betterCompletions
Better completions
2024-08-28 05:44:15 -07:00
Stefan Gehr
d483e0b069
cargo fmt --all 2024-08-28 10:41:42 +02:00
Stefan Gehr
5eb44a157e
Use DirPath for output of rage-keygen as overwriting is not allowed 2024-08-28 10:41:14 +02:00
Jack Grigg
b179b7cedd
Merge pull request #520 from str4d/383-scrypt-enc-work-factor
age: Add `scrypt::Recipient::set_work_factor` for overriding default
2024-08-27 22:24:42 -07:00
Jack Grigg
67a539791b age: Adjust scrypt::Identity::set_max_work_factor docs
They are now consistent with `scrypt::Recipient::set_work_factor`.
2024-08-28 05:16:32 +00:00
Jack Grigg
e84159365d age: Add scrypt::Recipient::set_work_factor for overriding default
This can only be configured by using `scrypt::Recipient` directly in a
library context. The helper method `Encryptor::with_user_passphrase`
does not expose this, and `rage` continues to use the default.

Closes str4d/rage#383.
2024-08-28 05:16:32 +00:00
Jack Grigg
84eacb7271
Merge pull request #519 from str4d/484-identity-file-newline-fix
age: Don't exit peeking state if entire identity file fits in the buffer
2024-08-27 20:05:46 -07:00
Jack Grigg
5a57e120a2 age: Don't exit peeking state if entire identity file fits in the buffer
This ensures we can call `PeekableReader::reset` when the file is a
single line without a trailing newline character, which rage-keygen does
not generate but users can.

Closes str4d/rage#484.
2024-08-28 02:56:58 +00:00
Jack Grigg
303fa6ebe1 rage: Add CLI test exposing bug with single-line identity files
Specifically, a single line and no trailing newline.
2024-08-28 02:41:36 +00:00
Jack Grigg
d76c85d585
Merge pull request #518 from str4d/380-expose-ife-methods
Refactor `IdentityFile` APIs
2024-08-26 20:57:55 -07:00
Jack Grigg
5e57ef07ca age: Return Box<dyn Identity> from IdentityFile::into_identities
This is doable now that `IdentityFile` stores callbacks, and is more
useful to crate users than `IdentityFileEntry`. The one place we were
relying on the latter was in `rage-keygen` to distinguish plugin
identities (which cannot be re-encoded as recipients); we now move that
functionality into the `age` crate.
2024-08-27 03:47:40 +00:00
Jack Grigg
f243d63c31 age: Improve documentation of Callbacks 2024-08-27 03:47:40 +00:00
Jack Grigg
ae2434216d age: Store C: Callbacks inside IdentityFile
This removes the need for explicit `callbacks` arguments in methods that
may act on plugin identities, and instead enables the caller to choose
whether or not to provide callbacks independently of plugin support
being compiled in. Enabling plugin support without providing callbacks
now has well-defined fallback behaviour via the default `NoCallbacks`
struct.
2024-08-27 03:47:40 +00:00
Jack Grigg
8dcdacc1ac age: Make recipients from encrypted identities more efficient
We now merge plugin recipients together, so we only run each plugin once
during encryption.
2024-08-27 03:47:40 +00:00
Jack Grigg
52fd675bbd age: Add IdentityFile::to_recipients 2024-08-27 03:47:40 +00:00
Jack Grigg
2f9cf3f86f age: Extract RecipientsAccumulator from cli_common::read_recipients 2024-08-27 03:47:40 +00:00
Jack Grigg
d31fb568b7 age: Pass entire IdentityFile to parse_identity_files closure 2024-08-23 22:49:47 +00:00
Jack Grigg
5086bd65d9 age: Remove two unnecessary clones from IdentityFileEntry decryption 2024-08-23 20:32:28 +00:00
Jack Grigg
5e88d75195
Merge pull request #517 from str4d/updates-0.11
Updates for 0.11.0
2024-08-23 11:39:48 -07:00
Jack Grigg
8688929723 Use stable toolchain for rust-analyzer in VS Code 2024-08-23 15:58:15 +00:00
Jack Grigg
cb36c4cd53 i18n-embed 0.15 2024-08-23 15:58:15 +00:00
Jack Grigg
f64f110f3e cargo update 2024-08-23 14:55:09 +00:00
Jack Grigg
dc885d86a1 cargo vet regenerate imports 2024-08-23 14:06:36 +00:00
Jack Grigg
cf96347fbe
Merge pull request #516 from str4d/515-cli-common-feature-bugs
age: Fix feature flag combination bugs in `cli_common` module
2024-08-23 06:40:22 -07:00
Jack Grigg
7e3c62b98b age: Fix feature flag combination bugs in cli_common module 2024-08-23 12:37:24 +00:00
Jack Grigg
0cf17d916a
Merge pull request #514 from str4d/403-labels
Implement labels
2024-08-23 05:06:16 -07:00
Jack Grigg
3c9483f78f age-plugin: Commit to order in which RecipientPluginV1 methods are called 2024-08-23 11:48:39 +00:00
Jack Grigg
9476af8e1f age-plugin: Add labels extension to recipient-v1 2024-08-12 04:36:12 +00:00
Jack Grigg
2d29668712 age: Add labels extension to client side of recipient-v1 2024-08-12 04:35:52 +00:00
Jack Grigg
8f1d6af149 age: Return label set from Recipient::wrap_file_key 2024-08-12 04:35:07 +00:00
Jack Grigg
8091015514 age: Add test that X25519 and scrypt recipients are incompatible 2024-08-10 06:53:39 +00:00
Stefan Gehr
26ebfbfc88
add value hints to rage-mount completions 2024-08-09 11:13:30 +02:00
Stefan Gehr
daf0829142
add value hints to rage-keygen completions 2024-08-09 11:13:21 +02:00
Stefan Gehr
4ff5e01ae9
add value hints to rage completions 2024-08-09 11:03:56 +02:00
Jack Grigg
d2c2e895bf
Merge pull request #511 from str4d/494-fix-plugin-helper
age-plugin: Fix `run_state_machine` to enable recipient-only or identity-only plugins
2024-08-05 01:51:48 +01:00
Jack Grigg
2eec45718c age-plugin: Slightly improve trait documentation 2024-08-05 00:42:04 +00:00
Jack Grigg
2f79c8201b age-plugin: Replace run_state_machine arguments with a trait 2024-08-05 00:05:35 +00:00
Jack Grigg
18b27b377d age-plugin: Add impls of state machine traits for Infallible
This enables representing a plugin without a recipient or identity
handler in the type system.
2024-08-04 23:49:10 +00:00
Jack Grigg
0689e95927 Revert "age-plugin: Make arguments to run_state_machine optional"
This reverts commit 480c621a40.
2024-08-04 21:49:47 +00:00
Jack Grigg
a510e76bf8
Merge pull request #507 from str4d/504-the-day-of-reckoning
Remove split between recipient and passphrase encryption
2024-08-04 20:51:44 +01:00
Jack Grigg
f69c29bf6f age: Clean up crate documentation 2024-07-29 03:05:38 +00:00
Jack Grigg
944f56a4a9 age: Remove EncryptorType 2024-07-29 03:05:38 +00:00
Jack Grigg
219ac41b60 age: Merge RecipientsDecryptor into Decryptor 2024-07-29 02:27:05 +00:00
Jack Grigg
a1f16094b8 age: Remove PassphraseDecryptor 2024-07-29 02:27:05 +00:00
Jack Grigg
f253ff2ff1 age: Expose scrypt::{Recipient, Identity} 2024-07-29 02:27:05 +00:00
Jack Grigg
4ba982254c age: Make scrypt::Identity an owning type 2024-07-29 02:27:05 +00:00
Jack Grigg
0c2acd5306 age: Move scrypt structural requirement checks to HeaderV1 2024-07-29 02:27:05 +00:00
Jack Grigg
e568a640ba
Merge pull request #506 from str4d/update-fixes
Update fixes
2024-07-29 03:26:45 +01:00
Jack Grigg
6b46ada5e8 fuzz: afl 0.15 2024-07-29 02:18:19 +00:00
Jack Grigg
d4eb811ef9 fuzz: Update lockfiles for fuzzers 2024-07-29 02:18:19 +00:00
Jack Grigg
ef5112fedd rage: Fix intra-doc link lint 2024-07-29 01:53:23 +00:00
Jack Grigg
3aa3fca8a7
Merge pull request #505 from str4d/ci-tarpaulin-0.31
CI:Migrate to `cargo-tarpaulin` container for code coverage
2024-07-28 21:59:59 +01:00
Jack Grigg
e47cf49b3e CI: Migrate to cargo-tarpaulin container for code coverage 2024-07-28 20:53:07 +00:00
Jack Grigg
a552210939
Merge pull request #503 from str4d/updates
Updates
2024-07-28 21:39:38 +01:00
Jack Grigg
6d8d1515fc i18n-embed-fl 0.8 2024-07-28 18:28:04 +00:00
Jack Grigg
b22b60ff7f cargo update 2024-07-28 18:28:02 +00:00
Jack Grigg
b9de00a29a cargo vet prune 2024-07-28 17:23:42 +00:00
Jack Grigg
67ee02b47e Update changelogs for partial French translations 2024-07-28 17:22:54 +00:00
Jack Grigg
0e4d3e1163
Merge pull request #501 from str4d/dependabot/cargo/curve25519-dalek-4.1.3
build(deps): bump curve25519-dalek from 4.1.1 to 4.1.3
2024-07-19 00:29:00 +01:00
Jack Grigg
f93244565f
Merge pull request #492 from pavelzw/patch-1
Use homebrew formula from homebrew/core
2024-07-19 00:28:30 +01:00
dependabot[bot]
ce3aa6dc9f
build(deps): bump curve25519-dalek from 4.1.1 to 4.1.3
Bumps [curve25519-dalek](https://github.com/dalek-cryptography/curve25519-dalek) from 4.1.1 to 4.1.3.
- [Release notes](https://github.com/dalek-cryptography/curve25519-dalek/releases)
- [Commits](https://github.com/dalek-cryptography/curve25519-dalek/compare/curve25519-4.1.1...curve25519-4.1.3)

---
updated-dependencies:
- dependency-name: curve25519-dalek
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-18 23:28:00 +00:00
Jack Grigg
818e5e5e1a
Merge pull request #486 from str4d/dependabot/github_actions/svenstaro/upload-release-action-2.9.0
build(deps): bump svenstaro/upload-release-action from 2.7.0 to 2.9.0
2024-07-19 00:26:47 +01:00
Jack Grigg
fec672d5c7
Merge pull request #498 from str4d/dependabot/github_actions/codecov/codecov-action-4.4.1
build(deps): bump codecov/codecov-action from 4.0.1 to 4.4.1
2024-07-19 00:26:27 +01:00
AnomalRoil
96f89b3400
[i18n] Partial French translation (#499) 2024-07-19 00:26:09 +01:00
dependabot[bot]
058c56d6b2
---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-21 03:03:07 +00:00
Pavel Zwerschke
daac4c9a9a
Use homebrew formula from homebrew/core 2024-04-07 15:23:17 +02:00
dependabot[bot]
948adca150
build(deps): bump svenstaro/upload-release-action from 2.7.0 to 2.9.0
Bumps [svenstaro/upload-release-action](https://github.com/svenstaro/upload-release-action) from 2.7.0 to 2.9.0.
- [Release notes](https://github.com/svenstaro/upload-release-action/releases)
- [Changelog](https://github.com/svenstaro/upload-release-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/svenstaro/upload-release-action/compare/2.7.0...2.9.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-22 03:23:39 +00:00
Jack Grigg
b17c81008b Update Homebrew formula to v0.10.0 2024-02-04 23:06:17 +00:00
92 changed files with 5651 additions and 3182 deletions

View file

@ -70,6 +70,9 @@ jobs:
codecov:
name: Code coverage
runs-on: ubuntu-latest
container:
image: xd009642/tarpaulin:develop-nightly
options: --security-opt seccomp=unconfined
steps:
- uses: actions/checkout@v4
@ -77,14 +80,18 @@ jobs:
id: toolchain
- run: rustup override set ${{steps.toolchain.outputs.name}}
- name: Install linux build dependencies
run: sudo apt update && sudo apt install libfuse-dev
run: apt update && apt -y install libfuse-dev
- name: Generate coverage report
uses: actions-rs/tarpaulin@v0.1
with:
version: '0.19.1'
args: --workspace --release --all-features --timeout 180 --out Xml
run: >
cargo tarpaulin
--engine llvm
--workspace
--release
--all-features
--timeout 180
--out xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4.0.1
uses: codecov/codecov-action@v4.6.0
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}

View file

@ -11,13 +11,25 @@ on:
required: true
default: 'true'
permissions:
attestations: write
contents: write
id-token: write
jobs:
build:
name: Publish for ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
name: [linux, armv7, arm64, windows, macos]
name:
- linux
- armv7
- arm64
- windows
- macos-arm64
- macos-x86_64
include:
- name: linux
os: ubuntu-20.04
@ -56,9 +68,14 @@ jobs:
archive_name: rage.zip
asset_suffix: x86_64-windows.zip
- name: macos
- name: macos-arm64
os: macos-latest
archive_name: rage.tar.gz
asset_suffix: arm64-darwin.tar.gz
- name: macos-x86_64
os: macos-13
archive_name: rage.tar.gz
asset_suffix: x86_64-darwin.tar.gz
steps:
@ -101,6 +118,11 @@ jobs:
shell: bash
if: matrix.name == 'windows'
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
with:
subject-path: 'release/rage/*'
- name: Upload archive as artifact
uses: actions/upload-artifact@v4
with:
@ -109,7 +131,7 @@ jobs:
if: github.event.inputs.test == 'true'
- name: Upload archive to release
uses: svenstaro/upload-release-action@2.7.0
uses: svenstaro/upload-release-action@2.9.0
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ matrix.archive_name }}
@ -129,11 +151,12 @@ jobs:
os:
- ubuntu-20.04
- ubuntu-22.04
- ubuntu-24.04
- windows-2019
- windows-2022
- macos-11
- macos-12
- macos-13
- macos-14
include:
- os: ubuntu-20.04
@ -146,6 +169,11 @@ jobs:
archive_name: rage.tar.gz
asset_suffix: x86_64-linux.tar.gz
- os: ubuntu-24.04
name: linux
archive_name: rage.tar.gz
asset_suffix: x86_64-linux.tar.gz
- os: windows-2019
name: windows
archive_name: rage.zip
@ -156,11 +184,6 @@ jobs:
archive_name: rage.zip
asset_suffix: x86_64-windows.zip
- os: macos-11
name: macos
archive_name: rage.tar.gz
asset_suffix: x86_64-darwin.tar.gz
- os: macos-12
name: macos
archive_name: rage.tar.gz
@ -171,6 +194,11 @@ jobs:
archive_name: rage.tar.gz
asset_suffix: x86_64-darwin.tar.gz
- os: macos-14
name: macos
archive_name: rage.tar.gz
asset_suffix: arm64-darwin.tar.gz
steps:
- name: Download archive
uses: actions/download-artifact@v4
@ -281,6 +309,11 @@ jobs:
- name: cargo deb
run: cargo deb --package rage --no-build --target ${{ matrix.target }} ${{ matrix.deb_flags }}
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
with:
subject-path: 'target/${{ matrix.target }}/debian/*.deb'
- name: Upload Debian package as artifact
uses: actions/upload-artifact@v4
with:
@ -289,7 +322,7 @@ jobs:
if: github.event.inputs.test == 'true'
- name: Upload Debian package to release
uses: svenstaro/upload-release-action@2.7.0
uses: svenstaro/upload-release-action@2.9.0
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: target/${{ matrix.target }}/debian/*.deb

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"rust-analyzer.server.extraEnv": { "RUSTUP_TOOLCHAIN": "stable" }
}

833
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -15,8 +15,8 @@ repository = "https://github.com/str4d/rage"
license = "MIT OR Apache-2.0"
[workspace.dependencies]
age = { version = "0.10.1", path = "age" }
age-core = { version = "0.10.0", path = "age-core" }
age = { version = "0.11.1", path = "age" }
age-core = { version = "0.11.0", path = "age-core" }
# Dependencies required by the age specification:
# - Base64 from RFC 4648
@ -48,14 +48,14 @@ cookie-factory = "0.3.1"
nom = { version = "7", default-features = false, features = ["alloc"] }
# Secret management
pinentry = "0.5"
secrecy = "0.8"
pinentry = "0.6"
secrecy = "0.10"
subtle = "2"
zeroize = "1"
# Localization
i18n-embed = { version = "0.14", features = ["fluent-system"] }
i18n-embed-fl = "0.7"
i18n-embed = { version = "0.15", features = ["fluent-system"] }
i18n-embed-fl = "0.9"
lazy_static = "1"
rust-embed = "8"

View file

@ -1,29 +0,0 @@
class Rage < Formula
desc "[BETA] A simple, secure, and modern encryption tool."
homepage "https://str4d.xyz/rage"
url "https://github.com/str4d/rage/archive/refs/tags/v0.9.2.tar.gz"
sha256 "3bd287372eb6226b246459c1b5c39ecdb36b3495d7af4d2bee93bb3aad9ccf65"
version "0.9.2"
depends_on "rust" => :build
def install
system "cargo", "install", *std_cargo_args(path: './rage')
end
test do
# Test key generation
system "#{bin}/rage-keygen -o #{testpath}/output.txt"
assert_predicate testpath/"output.txt", :exist?
# Test encryption
(testpath/"test.txt").write("Hello World!\n")
system "#{bin}/rage -r age1y8m84r6pwd4da5d45zzk03rlgv2xr7fn9px80suw3psrahul44ashl0usm -o #{testpath}/test.txt.age #{testpath}/test.txt"
assert_predicate testpath/"test.txt.age", :exist?
assert File.read(testpath/"test.txt.age").start_with?("age-encryption.org")
# Test decryption
(testpath/"test.key").write("AGE-SECRET-KEY-1TRYTV7PQS5XPUYSTAQZCD7DQCWC7Q77YJD7UVFJRMW4J82Q6930QS70MRX")
assert_equal "Hello World!", shell_output("#{bin}/rage -d -i #{testpath}/test.key #{testpath}/test.txt.age").strip
end
end

View file

@ -7,8 +7,8 @@ format. It features small explicit keys, no config options, and UNIX-style
composability.
The format specification is at [age-encryption.org/v1](https://age-encryption.org/v1).
age was designed by [@Benjojo12](https://twitter.com/Benjojo12) and
[@FiloSottile](https://twitter.com/FiloSottile).
age was designed by [@Benjojo](https://benjojo.co.uk/) and
[@FiloSottile](https://bsky.app/profile/did:plc:x2nsupeeo52oznrmplwapppl).
The reference interoperable Go implementation is available at
[filippo.io/age](https://filippo.io/age).
@ -24,7 +24,8 @@ For more plugins, implementations, tools, and integrations, check out the
| Environment | CLI command |
|-------------|-------------|
| Cargo (Rust 1.65+) | `cargo install rage` |
| Homebrew (macOS or Linux) | `brew tap str4d.xyz/rage https://str4d.xyz/rage`<br>`brew install rage` |
| Homebrew (macOS or Linux) | `brew install rage` |
| MacPorts | `port install rage` |
| Alpine Linux (edge) | `apk add rage` |
| Arch Linux | `pacman -S rage-encryption` |
| Debian | [Debian packages](https://github.com/str4d/rage/releases) |

View file

@ -8,6 +8,19 @@ to 1.0.0 are beta releases.
## [Unreleased]
## [0.11.0] - 2024-11-03
### Added
- `age_core::format`:
- `FileKey::new`
- `FileKey::init_with_mut`
- `FileKey::try_init_with_mut`
- `is_arbitrary_string`
### Changed
- Migrated to `secrecy 0.10`.
- `age::plugin::Connection::unidir_receive` now takes an additional argument to
enable handling an optional fourth command.
## [0.10.0] - 2024-02-04
### Added
- `impl Eq for age_core::format::Stanza`

View file

@ -1,7 +1,7 @@
[package]
name = "age-core"
description = "[BETA] Common functions used across the age crates"
version = "0.10.0"
version = "0.11.0"
authors.workspace = true
repository.workspace = true
readme = "README.md"

View file

@ -5,7 +5,7 @@ use rand::{
distributions::{Distribution, Uniform},
thread_rng, RngCore,
};
use secrecy::{ExposeSecret, Secret};
use secrecy::{ExposeSecret, ExposeSecretMut, SecretBox};
/// The prefix identifying an age stanza.
const STANZA_TAG: &str = "-> ";
@ -14,11 +14,26 @@ const STANZA_TAG: &str = "-> ";
pub const FILE_KEY_BYTES: usize = 16;
/// A file key for encrypting or decrypting an age file.
pub struct FileKey(Secret<[u8; FILE_KEY_BYTES]>);
pub struct FileKey(SecretBox<[u8; FILE_KEY_BYTES]>);
impl From<[u8; FILE_KEY_BYTES]> for FileKey {
fn from(file_key: [u8; FILE_KEY_BYTES]) -> Self {
FileKey(Secret::new(file_key))
impl FileKey {
/// Creates a file key using a pre-boxed key.
pub fn new(file_key: Box<[u8; FILE_KEY_BYTES]>) -> Self {
Self(SecretBox::new(file_key))
}
/// Creates a file key using a function that can initialize the key in-place.
pub fn init_with_mut(ctr: impl FnOnce(&mut [u8; FILE_KEY_BYTES])) -> Self {
Self(SecretBox::init_with_mut(ctr))
}
/// Same as [`Self::init_with_mut`], but the constructor can be fallible.
pub fn try_init_with_mut<E>(
ctr: impl FnOnce(&mut [u8; FILE_KEY_BYTES]) -> Result<(), E>,
) -> Result<Self, E> {
let mut file_key = SecretBox::new(Box::new([0; FILE_KEY_BYTES]));
ctr(file_key.expose_secret_mut())?;
Ok(Self(file_key))
}
}
@ -90,6 +105,16 @@ impl From<AgeStanza<'_>> for Stanza {
}
}
/// Checks whether the string is a valid age "arbitrary string" (`1*VCHAR` in ABNF).
pub fn is_arbitrary_string<S: AsRef<str>>(s: &S) -> bool {
let s = s.as_ref();
!s.is_empty()
&& s.chars().all(|c| match u8::try_from(c) {
Ok(u) => (33..=126).contains(&u),
Err(_) => false,
})
}
/// Creates a random recipient stanza that exercises the joint in the age v1 format.
///
/// This function is guaranteed to return a valid stanza, but makes no other guarantees

View file

@ -4,7 +4,7 @@
//! implementations built around the `age-plugin` crate.
use rand::{thread_rng, Rng};
use secrecy::Zeroize;
use secrecy::zeroize::Zeroize;
use std::env;
use std::fmt;
use std::io::{self, BufRead, BufReader, Read, Write};
@ -51,10 +51,11 @@ impl std::error::Error for Error {}
/// should explicitly handle.
pub type Result<T> = io::Result<std::result::Result<T, Error>>;
type UnidirResult<A, B, C, E> = io::Result<(
type UnidirResult<A, B, C, D, E> = io::Result<(
std::result::Result<Vec<A>, Vec<E>>,
std::result::Result<Vec<B>, Vec<E>>,
Option<std::result::Result<Vec<C>, Vec<E>>>,
Option<std::result::Result<Vec<D>, Vec<E>>>,
)>;
/// A connection to a plugin binary.
@ -205,23 +206,26 @@ impl<R: Read, W: Write> Connection<R, W> {
///
/// # Arguments
///
/// `command_a`, `command_b`, and (optionally) `command_c` are the known commands that
/// are expected to be received. All other received commands (including grease) will
/// be ignored.
pub fn unidir_receive<A, B, C, E, F, G, H>(
/// `command_a`, `command_b`, and (optionally) `command_c` and `command_d` are the
/// known commands that are expected to be received. All other received commands
/// (including grease) will be ignored.
pub fn unidir_receive<A, B, C, D, E, F, G, H, I>(
&mut self,
command_a: (&str, F),
command_b: (&str, G),
command_c: (Option<&str>, H),
) -> UnidirResult<A, B, C, E>
command_d: (Option<&str>, I),
) -> UnidirResult<A, B, C, D, E>
where
F: Fn(Stanza) -> std::result::Result<A, E>,
G: Fn(Stanza) -> std::result::Result<B, E>,
H: Fn(Stanza) -> std::result::Result<C, E>,
I: Fn(Stanza) -> std::result::Result<D, E>,
{
let mut res_a = Ok(vec![]);
let mut res_b = Ok(vec![]);
let mut res_c = Ok(vec![]);
let mut res_d = Ok(vec![]);
for stanza in iter::repeat_with(|| self.receive()).take_while(|res| match res {
Ok(stanza) => stanza.tag != COMMAND_DONE,
@ -251,14 +255,28 @@ impl<R: Read, W: Write> Connection<R, W> {
validate(command_a.1(stanza), &mut res_a)
} else if stanza.tag.as_str() == command_b.0 {
validate(command_b.1(stanza), &mut res_b)
} else if let Some(tag) = command_c.0 {
if stanza.tag.as_str() == tag {
validate(command_c.1(stanza), &mut res_c)
} else {
if let Some(tag) = command_c.0 {
if stanza.tag.as_str() == tag {
validate(command_c.1(stanza), &mut res_c);
continue;
}
}
if let Some(tag) = command_d.0 {
if stanza.tag.as_str() == tag {
validate(command_d.1(stanza), &mut res_d);
continue;
}
}
}
}
Ok((res_a, res_b, command_c.0.map(|_| res_c)))
Ok((
res_a,
res_b,
command_c.0.map(|_| res_c),
command_d.0.map(|_| res_d),
))
}
/// Runs a bidirectional phase as the controller.
@ -481,10 +499,11 @@ mod tests {
.unidir_send(|mut phase| phase.send("test", &["foo"], b"bar"))
.unwrap();
let stanza = plugin_conn
.unidir_receive::<_, (), (), _, _, _, _>(
.unidir_receive::<_, (), (), (), _, _, _, _, _>(
("test", Ok),
("other", |_| Err(())),
(None, |_| Ok(())),
(None, |_| Ok(())),
)
.unwrap();
assert_eq!(
@ -496,7 +515,8 @@ mod tests {
body: b"bar"[..].to_owned()
}]),
Ok(vec![]),
None
None,
None,
)
);
}

View file

@ -10,6 +10,26 @@ to 1.0.0 are beta releases.
## [Unreleased]
## [0.6.0] - 2024-11-03
### Added
- `age_plugin::PluginHandler`
- `impl age_plugin::identity::IdentityPluginV1 for std::convert::Infallible`
- `impl age_plugin::recipient::RecipientPluginV1 for std::convert::Infallible`
### Changed
- Migrated to `age-core 0.11`.
- `age_plugin::recipient::RecipientPluginV1` has a new `labels` method. Existing
implementations of the trait should either return `HashSet::new()` to maintain
existing compatibility, or return labels that apply the desired constraints.
- `age_plugin::run_state_machine` now supports the `recipient-v1` labels
extension.
### Fixed
- `age_plugin::run_state_machine` now takes an `impl age_plugin::PluginHandler`
argument, instead of its previous arguments.
- This fixes the change from the previous release, because the type parameters
were basically impossible to set correctly when attempting to pass `None`.
## [0.5.0] - 2024-02-04
### Changed
- MSRV is now 1.65.0.

View file

@ -1,7 +1,7 @@
[package]
name = "age-plugin"
description = "[BETA] API for writing age plugins."
version = "0.5.0"
version = "0.6.0"
authors.workspace = true
repository.workspace = true
readme = "README.md"

View file

@ -6,11 +6,12 @@ use age_plugin::{
identity::{self, IdentityPluginV1},
print_new_identity,
recipient::{self, RecipientPluginV1},
run_state_machine, Callbacks,
run_state_machine, Callbacks, PluginHandler,
};
use clap::Parser;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::convert::Infallible;
use std::env;
use std::io;
@ -25,6 +26,43 @@ fn explode(location: &str) {
}
}
struct FullHandler;
impl PluginHandler for FullHandler {
type RecipientV1 = RecipientPlugin;
type IdentityV1 = IdentityPlugin;
fn recipient_v1(self) -> io::Result<Self::RecipientV1> {
Ok(RecipientPlugin)
}
fn identity_v1(self) -> io::Result<Self::IdentityV1> {
Ok(IdentityPlugin)
}
}
struct RecipientHandler;
impl PluginHandler for RecipientHandler {
type RecipientV1 = RecipientPlugin;
type IdentityV1 = Infallible;
fn recipient_v1(self) -> io::Result<Self::RecipientV1> {
Ok(RecipientPlugin)
}
}
struct IdentityHandler;
impl PluginHandler for IdentityHandler {
type RecipientV1 = Infallible;
type IdentityV1 = IdentityPlugin;
fn identity_v1(self) -> io::Result<Self::IdentityV1> {
Ok(IdentityPlugin)
}
}
struct RecipientPlugin;
impl RecipientPluginV1 for RecipientPlugin {
@ -66,6 +104,16 @@ impl RecipientPluginV1 for RecipientPlugin {
}
}
fn labels(&mut self) -> HashSet<String> {
let mut labels = HashSet::new();
if let Ok(s) = env::var("AGE_PLUGIN_LABELS") {
for label in s.split(',') {
labels.insert(label.into());
}
}
labels
}
fn wrap_file_keys(
&mut self,
file_keys: Vec<FileKey>,
@ -127,9 +175,14 @@ impl IdentityPluginV1 for IdentityPlugin {
// identities.
let _ = callbacks.message("This identity does nothing!")?;
file_keys.entry(file_index).or_insert_with(|| {
Ok(FileKey::from(
TryInto::<[u8; 16]>::try_into(&stanza.body[..]).unwrap(),
))
FileKey::try_init_with_mut(|file_key| {
if stanza.body.len() == file_key.len() {
file_key.copy_from_slice(&stanza.body);
Ok(())
} else {
panic!("File key is wrong length")
}
})
});
break;
}
@ -149,11 +202,15 @@ fn main() -> io::Result<()> {
let opts = PluginOptions::parse();
if let Some(state_machine) = opts.age_plugin {
run_state_machine(
&state_machine,
Some(|| RecipientPlugin),
Some(|| IdentityPlugin),
)
if let Ok(s) = env::var("AGE_HALF_PLUGIN") {
match s.as_str() {
"recipient" => run_state_machine(&state_machine, RecipientHandler),
"identity" => run_state_machine(&state_machine, IdentityHandler),
_ => panic!("Env variable AGE_HALF_PLUGIN={s} has unknown value. Boom! 💥"),
}
} else {
run_state_machine(&state_machine, FullHandler)
}
} else {
// A real plugin would generate a new identity here.
print_new_identity(PLUGIN_NAME, &[], &[]);

View file

@ -7,7 +7,9 @@ use age_core::{
};
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
use bech32::FromBase32;
use std::collections::HashMap;
use std::convert::Infallible;
use std::io;
use crate::{Callbacks, PLUGIN_IDENTITY_PREFIX};
@ -16,6 +18,10 @@ const ADD_IDENTITY: &str = "add-identity";
const RECIPIENT_STANZA: &str = "recipient-stanza";
/// The interface that age implementations will use to interact with an age plugin.
///
/// Implementations of this trait will be used within the [`identity-v1`] state machine.
///
/// [`identity-v1`]: https://c2sp.org/age-plugin#unwrapping-with-identity-v1
pub trait IdentityPluginV1 {
/// Stores an identity that the user would like to use for decrypting age files.
///
@ -49,6 +55,22 @@ pub trait IdentityPluginV1 {
) -> io::Result<HashMap<usize, Result<FileKey, Vec<Error>>>>;
}
impl IdentityPluginV1 for Infallible {
fn add_identity(&mut self, _: usize, _: &str, _: &[u8]) -> Result<(), Error> {
// This is never executed.
Ok(())
}
fn unwrap_file_keys(
&mut self,
_: Vec<Vec<Stanza>>,
_: impl Callbacks<Error>,
) -> io::Result<HashMap<usize, Result<FileKey, Vec<Error>>>> {
// This is never executed.
Ok(HashMap::new())
}
}
/// The interface that age plugins can use to interact with an age implementation.
struct BidirCallbacks<'a, 'b, R: io::Read, W: io::Write>(&'b mut BidirSend<'a, R, W>);
@ -113,7 +135,7 @@ impl<'a, 'b, R: io::Read, W: io::Write> Callbacks<Error> for BidirCallbacks<'a,
.and_then(|res| match res {
Ok(s) => String::from_utf8(s.body)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "secret is not UTF-8"))
.map(|s| Ok(SecretString::new(s))),
.map(|s| Ok(SecretString::from(s))),
Err(e) => Ok(Err(e)),
})
}
@ -200,7 +222,7 @@ pub(crate) fn run_v1<P: IdentityPluginV1>(mut plugin: P) -> io::Result<()> {
// Phase 1: receive identities and stanzas
let (identities, recipient_stanzas) = {
let (identities, stanzas, _) = conn.unidir_receive(
let (identities, stanzas, _, _) = conn.unidir_receive(
(ADD_IDENTITY, |s| match (&s.args[..], &s.body[..]) {
([identity], []) => Ok(identity.clone()),
_ => Err(Error::Internal {
@ -233,6 +255,7 @@ pub(crate) fn run_v1<P: IdentityPluginV1>(mut plugin: P) -> io::Result<()> {
}
}),
(None, |_| Ok(())),
(None, |_| Ok(())),
)?;
// Now that we have the full list of identities, parse them as Bech32 and add them

View file

@ -74,13 +74,28 @@
//! identity::{self, IdentityPluginV1},
//! print_new_identity,
//! recipient::{self, RecipientPluginV1},
//! Callbacks, run_state_machine,
//! Callbacks, PluginHandler, run_state_machine,
//! };
//! use clap::Parser;
//!
//! use std::collections::HashMap;
//! use std::collections::{HashMap, HashSet};
//! use std::io;
//!
//! struct Handler;
//!
//! impl PluginHandler for Handler {
//! type RecipientV1 = RecipientPlugin;
//! type IdentityV1 = IdentityPlugin;
//!
//! fn recipient_v1(self) -> io::Result<Self::RecipientV1> {
//! Ok(RecipientPlugin)
//! }
//!
//! fn identity_v1(self) -> io::Result<Self::IdentityV1> {
//! Ok(IdentityPlugin)
//! }
//! }
//!
//! struct RecipientPlugin;
//!
//! impl RecipientPluginV1 for RecipientPlugin {
@ -102,6 +117,10 @@
//! todo!()
//! }
//!
//! fn labels(&mut self) -> HashSet<String> {
//! todo!()
//! }
//!
//! fn wrap_file_keys(
//! &mut self,
//! file_keys: Vec<FileKey>,
@ -143,11 +162,7 @@
//!
//! if let Some(state_machine) = opts.age_plugin {
//! // The plugin was started by an age client; run the state machine.
//! run_state_machine(
//! &state_machine,
//! Some(|| RecipientPlugin),
//! Some(|| IdentityPlugin),
//! )?;
//! run_state_machine(&state_machine, Handler)?;
//! return Ok(());
//! }
//!
@ -209,34 +224,12 @@ pub fn print_new_identity(plugin_name: &str, identity: &[u8], recipient: &[u8])
///
/// This should be triggered if the `--age-plugin=state_machine` flag is provided as an
/// argument when starting the plugin.
pub fn run_state_machine<R: recipient::RecipientPluginV1, I: identity::IdentityPluginV1>(
state_machine: &str,
recipient_v1: Option<impl FnOnce() -> R>,
identity_v1: Option<impl FnOnce() -> I>,
) -> io::Result<()> {
pub fn run_state_machine(state_machine: &str, handler: impl PluginHandler) -> io::Result<()> {
use age_core::plugin::{IDENTITY_V1, RECIPIENT_V1};
match state_machine {
RECIPIENT_V1 => {
if let Some(plugin) = recipient_v1 {
recipient::run_v1(plugin())
} else {
Err(io::Error::new(
io::ErrorKind::InvalidInput,
"plugin doesn't support recipient-v1 state machine",
))
}
}
IDENTITY_V1 => {
if let Some(plugin) = identity_v1 {
identity::run_v1(plugin())
} else {
Err(io::Error::new(
io::ErrorKind::InvalidInput,
"plugin doesn't support identity-v1 state machine",
))
}
}
RECIPIENT_V1 => recipient::run_v1(handler.recipient_v1()?),
IDENTITY_V1 => identity::run_v1(handler.identity_v1()?),
_ => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"unknown plugin state machine",
@ -244,6 +237,63 @@ pub fn run_state_machine<R: recipient::RecipientPluginV1, I: identity::IdentityP
}
}
/// The interfaces that age implementations will use to interact with an age plugin.
///
/// This trait exists to encapsulate the set of arguments to [`run_state_machine`] that
/// different plugins may want to provide.
///
/// # How to implement this trait
///
/// ## Full plugins
///
/// - Set all associated types to your plugin's implementations.
/// - Override all default methods of the trait.
///
/// ## Recipient-only plugins
///
/// - Set [`PluginHandler::RecipientV1`] to your plugin's implementation.
/// - Override [`PluginHandler::recipient_v1`] to return an instance of your type.
/// - Set [`PluginHandler::IdentityV1`] to [`std::convert::Infallible`].
/// - Don't override [`PluginHandler::identity_v1`].
///
/// ## Identity-only plugins
///
/// - Set [`PluginHandler::RecipientV1`] to [`std::convert::Infallible`].
/// - Don't override [`PluginHandler::recipient_v1`].
/// - Set [`PluginHandler::IdentityV1`] to your plugin's implementation.
/// - Override [`PluginHandler::identity_v1`] to return an instance of your type.
pub trait PluginHandler: Sized {
/// The plugin's [`recipient-v1`] implementation.
///
/// [`recipient-v1`]: https://c2sp.org/age-plugin#wrapping-with-recipient-v1
type RecipientV1: recipient::RecipientPluginV1;
/// The plugin's [`identity-v1`] implementation.
///
/// [`identity-v1`]: https://c2sp.org/age-plugin#unwrapping-with-identity-v1
type IdentityV1: identity::IdentityPluginV1;
/// Returns an instance of the plugin's [`recipient-v1`] implementation.
///
/// [`recipient-v1`]: https://c2sp.org/age-plugin#wrapping-with-recipient-v1
fn recipient_v1(self) -> io::Result<Self::RecipientV1> {
Err(io::Error::new(
io::ErrorKind::InvalidInput,
"plugin doesn't support recipient-v1 state machine",
))
}
/// Returns an instance of the plugin's [`identity-v1`] implementation.
///
/// [`identity-v1`]: https://c2sp.org/age-plugin#unwrapping-with-identity-v1
fn identity_v1(self) -> io::Result<Self::IdentityV1> {
Err(io::Error::new(
io::ErrorKind::InvalidInput,
"plugin doesn't support identity-v1 state machine",
))
}
}
/// The interface that age plugins can use to interact with an age implementation.
pub trait Callbacks<E> {
/// Shows a message to the user.

View file

@ -1,12 +1,15 @@
//! Recipient plugin helpers.
use age_core::{
format::{FileKey, Stanza, FILE_KEY_BYTES},
format::{is_arbitrary_string, FileKey, Stanza},
plugin::{self, BidirSend, Connection},
secrecy::SecretString,
};
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
use bech32::FromBase32;
use std::collections::HashSet;
use std::convert::Infallible;
use std::io;
use crate::{Callbacks, PLUGIN_IDENTITY_PREFIX, PLUGIN_RECIPIENT_PREFIX};
@ -14,9 +17,21 @@ use crate::{Callbacks, PLUGIN_IDENTITY_PREFIX, PLUGIN_RECIPIENT_PREFIX};
const ADD_RECIPIENT: &str = "add-recipient";
const ADD_IDENTITY: &str = "add-identity";
const WRAP_FILE_KEY: &str = "wrap-file-key";
const EXTENSION_LABELS: &str = "extension-labels";
const RECIPIENT_STANZA: &str = "recipient-stanza";
const LABELS: &str = "labels";
/// The interface that age implementations will use to interact with an age plugin.
///
/// Implementations of this trait will be used within the [`recipient-v1`] state machine.
///
/// The trait methods are always called in this order:
/// - [`Self::add_recipient`] / [`Self::add_identity`] (in any order, including
/// potentially interleaved).
/// - [`Self::labels`] (once all recipients and identities have been added).
/// - [`Self::wrap_file_keys`]
///
/// [`recipient-v1`]: https://c2sp.org/age-plugin#wrapping-with-recipient-v1
pub trait RecipientPluginV1 {
/// Stores a recipient that the user would like to encrypt age files to.
///
@ -33,11 +48,45 @@ pub trait RecipientPluginV1 {
/// Returns an error if the identity is unknown or invalid.
fn add_identity(&mut self, index: usize, plugin_name: &str, bytes: &[u8]) -> Result<(), Error>;
/// Returns labels that constrain how the stanzas produced by [`Self::wrap_file_keys`]
/// may be combined with those from other recipients.
///
/// Encryption will succeed only if every recipient returns the same set of labels.
/// Subsets or partial overlapping sets are not allowed; all sets must be identical.
/// Labels are compared exactly, and are case-sensitive.
///
/// Label sets can be used to ensure a recipient is only encrypted to alongside other
/// recipients with equivalent properties, or to ensure a recipient is always used
/// alone. A recipient with no particular properties to enforce should return an empty
/// label set.
///
/// Labels can have any value that is a valid arbitrary string (`1*VCHAR` in ABNF),
/// but usually take one of several forms:
/// - *Common public label* - used by multiple recipients to permit their stanzas to
/// be used only together. Examples include:
/// - `postquantum` - indicates that the recipient stanzas being generated are
/// postquantum-secure, and that they can only be combined with other stanzas
/// that are also postquantum-secure.
/// - *Common private label* - used by recipients created by the same private entity
/// to permit their recipient stanzas to be used only together. For example,
/// private recipients used in a corporate environment could all send the same
/// private label in order to prevent compliant age clients from simultaneously
/// wrapping file keys with other recipients.
/// - *Random label* - used by recipients that want to ensure their stanzas are not
/// used with any other recipient stanzas. This can be used to produce a file key
/// that is only encrypted to a single recipient stanza, for example to preserve
/// its authentication properties.
fn labels(&mut self) -> HashSet<String>;
/// Wraps each `file_key` to all recipients and identities previously added via
/// `add_recipient` and `add_identity`.
///
/// Returns either one stanza per recipient and identity for each file key, or any
/// errors if one or more recipients or identities could not be wrapped to.
/// Returns a set of stanzas per file key that wrap it to each recipient and identity.
/// Plugins may return more than one stanza per "actual recipient", e.g. to support
/// multiple formats, to build group aliases, or to act as a proxy.
///
/// If one or more recipients or identities could not be wrapped to, no stanzas are
/// returned for any of the file keys.
///
/// `callbacks` can be used to interact with the user, to have them take some physical
/// action or request a secret value.
@ -48,6 +97,32 @@ pub trait RecipientPluginV1 {
) -> io::Result<Result<Vec<Vec<Stanza>>, Vec<Error>>>;
}
impl RecipientPluginV1 for Infallible {
fn add_recipient(&mut self, _: usize, _: &str, _: &[u8]) -> Result<(), Error> {
// This is never executed.
Ok(())
}
fn add_identity(&mut self, _: usize, _: &str, _: &[u8]) -> Result<(), Error> {
// This is never executed.
Ok(())
}
fn labels(&mut self) -> HashSet<String> {
// This is never executed.
HashSet::new()
}
fn wrap_file_keys(
&mut self,
_: Vec<FileKey>,
_: impl Callbacks<Error>,
) -> io::Result<Result<Vec<Vec<Stanza>>, Vec<Error>>> {
// This is never executed.
Ok(Ok(vec![]))
}
}
/// The interface that age plugins can use to interact with an age implementation.
struct BidirCallbacks<'a, 'b, R: io::Read, W: io::Write>(&'b mut BidirSend<'a, R, W>);
@ -112,7 +187,7 @@ impl<'a, 'b, R: io::Read, W: io::Write> Callbacks<Error> for BidirCallbacks<'a,
.and_then(|res| match res {
Ok(s) => String::from_utf8(s.body)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "secret is not UTF-8"))
.map(|s| Ok(SecretString::new(s))),
.map(|s| Ok(SecretString::from(s))),
Err(e) => Ok(Err(e)),
})
}
@ -188,8 +263,8 @@ pub(crate) fn run_v1<P: RecipientPluginV1>(mut plugin: P) -> io::Result<()> {
let mut conn = Connection::accept();
// Phase 1: collect recipients, and file keys to be wrapped
let ((recipients, identities), file_keys) = {
let (recipients, identities, file_keys) = conn.unidir_receive(
let ((recipients, identities), file_keys, labels_supported) = {
let (recipients, identities, file_keys, labels_supported) = conn.unidir_receive(
(ADD_RECIPIENT, |s| match (&s.args[..], &s.body[..]) {
([recipient], []) => Ok(recipient.clone()),
_ => Err(Error::Internal {
@ -210,12 +285,18 @@ pub(crate) fn run_v1<P: RecipientPluginV1>(mut plugin: P) -> io::Result<()> {
}),
(Some(WRAP_FILE_KEY), |s| {
// TODO: Should we ignore file key commands with unexpected metadata args?
TryInto::<[u8; FILE_KEY_BYTES]>::try_into(&s.body[..])
.map_err(|_| Error::Internal {
message: "invalid file key length".to_owned(),
})
.map(FileKey::from)
FileKey::try_init_with_mut(|file_key| {
if s.body.len() == file_key.len() {
file_key.copy_from_slice(&s.body);
Ok(())
} else {
Err(Error::Internal {
message: "invalid file key length".to_owned(),
})
}
})
}),
(Some(EXTENSION_LABELS), |_| Ok(())),
)?;
(
match (recipients, identities) {
@ -236,6 +317,13 @@ pub(crate) fn run_v1<P: RecipientPluginV1>(mut plugin: P) -> io::Result<()> {
}]),
r => r,
},
match &labels_supported.unwrap() {
Ok(v) if v.is_empty() => Ok(false),
Ok(v) if v.len() == 1 => Ok(true),
_ => Err(vec![Error::Internal {
message: format!("Received more than one {} command", EXTENSION_LABELS),
}]),
},
)
};
@ -300,23 +388,61 @@ pub(crate) fn run_v1<P: RecipientPluginV1>(mut plugin: P) -> io::Result<()> {
|index, plugin_name, bytes| plugin.add_identity(index, plugin_name, &bytes),
);
let required_labels = plugin.labels();
let labels = match (labels_supported, required_labels.is_empty()) {
(Ok(true), _) | (Ok(false), true) => {
if required_labels.contains("") {
Err(vec![Error::Internal {
message: "Plugin tried to use the empty string as a label".into(),
}])
} else if required_labels.iter().all(is_arbitrary_string) {
Ok(required_labels)
} else {
Err(vec![Error::Internal {
message: "Plugin tried to use a label containing an invalid character".into(),
}])
}
}
(Ok(false), false) => Err(vec![Error::Internal {
message: "Plugin requires labels but client does not support them".into(),
}]),
(Err(errors), true) => Err(errors),
(Err(mut errors), false) => {
errors.push(Error::Internal {
message: "Plugin requires labels but client does not support them".into(),
});
Err(errors)
}
};
// Phase 2: wrap the file keys or return errors
conn.bidir_send(|mut phase| {
let (expected_stanzas, file_keys) = match (recipients, identities, file_keys) {
(Ok(recipients), Ok(identities), Ok(file_keys)) => (recipients + identities, file_keys),
(recipients, identities, file_keys) => {
for error in recipients
.err()
.into_iter()
.chain(identities.err())
.chain(file_keys.err())
.flatten()
{
error.send(&mut phase)?;
let (expected_stanzas, file_keys, labels) =
match (recipients, identities, file_keys, labels) {
(Ok(recipients), Ok(identities), Ok(file_keys), Ok(labels)) => {
(recipients + identities, file_keys, labels)
}
return Ok(());
}
};
(recipients, identities, file_keys, labels) => {
for error in recipients
.err()
.into_iter()
.chain(identities.err())
.chain(file_keys.err())
.chain(labels.err())
.flatten()
{
error.send(&mut phase)?;
}
return Ok(());
}
};
let labels = labels.iter().map(|s| s.as_str()).collect::<Vec<_>>();
// We confirmed above that if `labels` is non-empty, the client supports labels.
// So we can unconditionally send this, and will only get an `unsupported`
// response if `labels` is empty (where it does not matter).
let _ = phase.send(LABELS, &labels, &[])?;
match plugin.wrap_file_keys(file_keys, BidirCallbacks(&mut phase))? {
Ok(files) => {

View file

@ -10,13 +10,68 @@ to 1.0.0 are beta releases.
## [Unreleased]
## [0.6.1, 0.7.2, 0.8.2, 0.9.3, 0.10.1] - 2024-11-18
## [0.6.1, 0.7.2, 0.8.2, 0.9.3, 0.10.1, 0.11.1] - 2024-11-18
### Security
- The age plugin protocol previously allowed plugin names that could be
interpreted as file paths. Under certain conditions, this could lead to a
different binary being executed as an age plugin than intended. Plugin names
are now required to only contain alphanumeric characters or the four special
characters `+-._`.
- Fixed a security vulnerability that could allow an attacker to execute an
arbitrary binary under certain conditions. See GHSA-4fg7-vxc8-qx5w. Plugin
names are now required to only contain alphanumeric characters or the four
special characters `+-._`. Thanks to ⬡-49016 for reporting this issue.
## [0.11.0] - 2024-11-03
### Added
- New streamlined APIs for use with a single recipient or identity and a small
amount of data (that can fit entirely in memory):
- `age::encrypt`
- `age::encrypt_and_armor`
- `age::decrypt`
- `age::Decryptor::{decrypt, decrypt_async, is_scrypt}`
- `age::IdentityFile::to_recipients`
- `age::IdentityFile::with_callbacks`
- `age::IdentityFile::write_recipients_file`
- `age::IdentityFileConvertError`
- `age::NoCallbacks`
- `age::scrypt`, providing recipient and identity types for passphrase-based
encryption.
- Partial French translation!
### Changed
- Migrated to `i18n-embed 0.15`, `secrecy 0.10`.
- `age::Encryptor::with_recipients` now takes recipients by reference instead of
by value. This aligns it with `age::Decryptor` (which takes identities by
reference), and also means that errors with recipients are reported earlier.
This causes the following changes to the API:
- `Encryptor::with_recipients` takes `impl Iterator<Item = &'a dyn Recipient>`
instead of `Vec<Box<dyn Recipient + Send>>`.
- Verification of recipients and generation of stanzas now happens in
`Encryptor::with_recipients` instead of `Encryptor::wrap_output` and
`Encryptor::wrap_async_output`.
- `Encryptor::with_recipients` returns `Result<Self, EncryptError>` instead of
`Option<Self>`, and `Encryptor::{wrap_output, wrap_async_output}` return
`io::Result<StreamWriter<W>>` instead of `Result<StreamWriter<W>, EncryptError>`.
- `age::EncryptError` has a new variant `MissingRecipients`, taking the place
of the `None` that `Encryptor::with_recipients` could previously return.
- `age::Decryptor` is now an opaque struct instead of an enum with `Recipients`
and `Passphrase` variants.
- `age::IdentityFile` now has a `C: Callbacks` generic parameter, which defaults
to `NoCallbacks`.
- `age::IdentityFile::into_identities` now returns
`Result<Vec<Box<dyn crate::Identity>>, DecryptError>` instead of
`Vec<IdentityFileEntry>`.
- `age::Recipient::wrap_file_key` now returns `(Vec<Stanza>, HashSet<String>)`:
a tuple of the stanzas to be placed in an age file header, and labels that
constrain how the stanzas may be combined with those from other recipients.
- `age::plugin::RecipientPluginV1` now supports the labels extension.
### Fixed
- `age::cli_common::read_identities` once again correctly parses identity files
that are a single line without a trailing newline. This broke in 0.10.0 due to
an unrelated refactor.
### Removed
- `age::decryptor::PassphraseDecryptor` (use `age::Decryptor` with
`age::scrypt::Identity` instead).
- `age::decryptor::RecipientsDecryptor` (use `age::Decryptor` instead).
- `age::IdentityFileEntry`
## [0.10.0] - 2024-02-04
### Added

View file

@ -1,7 +1,7 @@
[package]
name = "age"
description = "[BETA] A simple, secure, and modern encryption library."
version = "0.10.1"
version = "0.11.1"
authors.workspace = true
repository.workspace = true
readme = "README.md"
@ -37,7 +37,7 @@ futures = { version = "0.3", optional = true }
pin-project = "1"
# Common CLI dependencies
pinentry = { version = "0.5", optional = true }
pinentry = { workspace = true, optional = true }
# Dependencies used internally:
# (Breaking upgrades to these are usually backwards-compatible, but check MSRVs.)

View file

@ -12,8 +12,8 @@ encryption and decryption of files or streams (e.g. in shell scripts), as well
as additional features such as mounting an encrypted archive.
The format specification is at [age-encryption.org/v1](https://age-encryption.org/v1).
The age format was designed by [@Benjojo12](https://twitter.com/Benjojo12) and
[@FiloSottile](https://twitter.com/FiloSottile).
The age format was designed by [@Benjojo](https://benjojo.co.uk/) and
[@FiloSottile](https://bsky.app/profile/did:plc:x2nsupeeo52oznrmplwapppl).
The reference interoperable Go implementation is available at
[filippo.io/age](https://filippo.io/age).
@ -23,7 +23,7 @@ The reference interoperable Go implementation is available at
Add this line to your `Cargo.toml`:
```
age = "0.10"
age = "0.11"
```
See the [documentation](https://docs.rs/age) for examples.

View file

@ -1,4 +1,4 @@
use age::{x25519, Decryptor, Encryptor, Recipient};
use age::{x25519, Decryptor, Encryptor};
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
#[cfg(unix)]
@ -8,7 +8,7 @@ use std::io::Write;
fn bench(c: &mut Criterion) {
let recipients: Vec<_> = (0..10)
.map(|_| Box::new(x25519::Identity::generate().to_public()))
.map(|_| x25519::Identity::generate().to_public())
.collect();
let mut group = c.benchmark_group("header");
@ -16,17 +16,11 @@ fn bench(c: &mut Criterion) {
group.throughput(Throughput::Elements(count as u64));
group.bench_function(BenchmarkId::new("parse", count), |b| {
let mut encrypted = vec![];
let mut output = Encryptor::with_recipients(
recipients
.iter()
.take(count)
.cloned()
.map(|r| r as Box<dyn Recipient + Send>)
.collect(),
)
.unwrap()
.wrap_output(&mut encrypted)
.unwrap();
let mut output =
Encryptor::with_recipients(recipients.iter().take(count).map(|r| r as _))
.unwrap()
.wrap_output(&mut encrypted)
.unwrap();
output.write_all(&[]).unwrap();
output.finish().unwrap();

View file

@ -52,7 +52,7 @@ fn bench(c: &mut Criterion_) {
group.bench_function(BenchmarkId::new("encrypt", size), |b| {
b.iter(|| {
let mut output = Encryptor::with_recipients(vec![Box::new(recipient.clone())])
let mut output = Encryptor::with_recipients(iter::once(&recipient as _))
.unwrap()
.wrap_output(io::sink())
.unwrap();
@ -62,7 +62,7 @@ fn bench(c: &mut Criterion_) {
});
group.bench_function(BenchmarkId::new("decrypt", size), |b| {
let mut output = Encryptor::with_recipients(vec![Box::new(recipient.clone())])
let mut output = Encryptor::with_recipients(iter::once(&recipient as _))
.unwrap()
.wrap_output(&mut ct_buf)
.unwrap();
@ -70,10 +70,7 @@ fn bench(c: &mut Criterion_) {
output.finish().unwrap();
b.iter(|| {
let decryptor = match Decryptor::new_buffered(&ct_buf[..]).unwrap() {
Decryptor::Recipients(decryptor) => decryptor,
_ => panic!(),
};
let decryptor = Decryptor::new_buffered(&ct_buf[..]).unwrap();
let mut input = decryptor
.decrypt(iter::once(&identity as &dyn age::Identity))
.unwrap();

View file

@ -13,6 +13,8 @@
-age = age
-rage = rage
-scrypt-recipient = scrypt::Recipient
-openssh = OpenSSH
-ssh-keygen = ssh-keygen
-ssh-rsa = ssh-rsa
@ -44,6 +46,20 @@ rec-deny-binary-output = Did you mean to use {-flag-armor}? {rec-detected-binary
err-deny-overwrite-file = refusing to overwrite existing file '{$filename}'.
err-invalid-filename = invalid filename '{$filename}'.
err-missing-directory = directory '{$path}' does not exist.
## Identity file errors
err-failed-to-write-output = Failed to write to output: {$err}
err-identity-file-contains-plugin = Identity file '{$filename}' contains identities for '{-age-plugin-}{$plugin_name}'.
rec-identity-file-contains-plugin = Try using '{-age-plugin-}{$plugin_name}' to convert this identity to a recipient.
err-no-identities-in-file = No identities found in file '{$filename}'.
err-no-identities-in-stdin = No identities found in standard input.
## Errors
err-decryption-failed = Decryption failed
@ -55,8 +71,17 @@ err-header-invalid = Header is invalid
err-header-mac-invalid = Header MAC is invalid
err-incompatible-recipients-oneway = Cannot encrypt to a recipient with labels '{$labels}' alongside a recipient with no labels
err-incompatible-recipients-twoway = Cannot encrypt to a recipient with labels '{$left}' alongside a recipient with labels '{$right}'
err-invalid-recipient-labels = The first recipient requires one or more invalid labels: '{$labels}'
err-key-decryption = Failed to decrypt an encrypted key
err-missing-recipients = Missing recipients.
err-mixed-recipient-passphrase = {-scrypt-recipient} can't be used with other recipients.
err-no-matching-keys = No matching keys found
err-unknown-format = Unknown {-age} format.

View file

@ -13,6 +13,8 @@
-age = age
-rage = rage
-scrypt-recipient = scrypt::Recipient
-openssh = OpenSSH
-ssh-keygen = ssh-keygen
-ssh-rsa = ssh-rsa
@ -55,6 +57,8 @@ err-header-mac-invalid = MAC de encabezado inválido.
err-key-decryption = No se pudo desencriptar una clave encriptada.
err-missing-recipients = No se encontraron destinatarios.
err-no-matching-keys = No se encontraron claves coincidentes.
err-unknown-format = Formato {-age} desconocido.

185
age/i18n/fr/age.ftl Normal file
View file

@ -0,0 +1,185 @@
# Copyright 2020 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 the age library crate
## Terms (not to be localized)
-age = age
-rage = rage
-scrypt-recipient = scrypt::Recipient
-openssh = OpenSSH
-ssh-keygen = ssh-keygen
-ssh-rsa = ssh-rsa
-ssh-ed25519 = ssh-ed25519
-fido-u2f = FIDO/U2F
-yubikeys = YubiKeys
-piv = PIV
## CLI helpers
cli-secret-input-required = Entrée requise
cli-secret-input-mismatch = Les entrées ne correspondent pas
cli-passphrase-desc = Tapez votre phrase secrète (laissez vide pour en générer une très sure automatiquement)
cli-passphrase-prompt = Phrase secrète
cli-passphrase-confirm = Confirmez votre phrase secrète
-flag-armor = -a/--armor
-flag-output = -o/--output
-output-stdout = -o -
cli-truncated-tty = tronqué; utilisez un pipe, une redirection ou {-flag-output} pour déchiffrer l'entièreté du fichier
err-detected-binary = données non impressibles détectées; par précaution, pas d'impression dans le terminal.
rec-detected-binary = Forcez l'impression avec '{-output-stdout}'.
err-deny-binary-output = refus d'impression de valeurs binaires dans le terminal.
rec-deny-binary-output = Est-ce que vous vouliez utiliser {-flag-armor}? {rec-detected-binary}
err-deny-overwrite-file = refus d'écraser le fichier existant '{$filename}'.
## Identity file errors
err-failed-to-write-output = Echec d'écriture vers la sortie: {$err}
err-identity-file-contains-plugin = Le ficher d'identité '{$filename}' contient des identités pour '{-age-plugin-}{$plugin_name}'.
rec-identity-file-contains-plugin = Essayez d'utiliser {-age-plugin-}{$plugin_name}' pour convertir cette identité en un destinataire.
err-no-identities-in-file = Aucune identité trouvée dans le fichier '{$filename}'.
err-no-identities-in-stdin = Aucune identité trouvée dans l'entrée standard (stdin).
## Errors
err-decryption-failed = Echec du déchiffrement
err-excessive-work = Facteur d'effort trop grand pour la phrase secrète.
rec-excessive-work = Le déchiffrement prendrait environ {$duration} seconds.
err-header-invalid = En-tête non valable
err-header-mac-invalid = Le MAC de l'en-tête est invalide
err-key-decryption = Echec du déchiffrement d'une clef chiffrée
err-missing-recipients = Destinataires manquants.
err-no-matching-keys = Aucune clef correspondante n'a été trouvée
err-unknown-format = Format {-age} inconnu.
rec-unknown-format = Avez-vous tenté de mettre jour vers la dernière version ?
err-missing-plugin = Impossible de trouver '{$plugin_name}' dans le PATH.
rec-missing-plugin = Avez-vous installé le plugin ?
err-plugin-identity = '{$plugin_name}' n'a pas pu utiliser une identité: {$message}
err-plugin-recipient = '{$plugin_name}' n'a pas pu utiliser le destinataire {$recipient}: {$message}
err-plugin-died = '{$plugin_name}' est mort de manière inopinée.
rec-plugin-died-1 = Si vous développez un plugin, utilisez {$env_var} pour plus d'informations.
rec-plugin-died-2 = Attention: ceci imprime des informations de clef privées sur la sortie d'erreur standard.
err-plugin-multiple = Le plugin a retourné de multiples erreurs:
err-read-identity-encrypted-without-passphrase =
Le fichier d'identité '{$filename}' est chiffré avec {-age} mais pas avec une phrase secrète.
err-read-identity-not-found = Fichier d'identité introuvable: {$filename}
err-read-invalid-recipient = Destinataire invalide: '{$recipient}'.
err-read-invalid-recipients-file =
Le fichier de destinataires '{$filename}' contient des données autres que des destinataires à la ligne {$line_number}.
err-read-missing-recipients-file = Fichier de destinataires introuvable: {$filename}
err-read-multiple-stdin = L'entrée standard (stdin) ne peut pas être utilisée pour plus d'une chose.
err-read-rsa-modulus-too-large =
Module RSA Trop Grand
---------------------
{-openssh} supporte de nombreuses tailles de modules RSA, mais {-rage} ne supporte que des clefs
publiques d'au plus {$max_size} bits, pour éviter les risques de déni de service (DoS) lors du
chiffrement vers des clefs publiques inconnues.
err-read-rsa-modulus-too-small = Taille de clef RSA trop petite.
err-stream-last-chunk-empty = Le dernier morceau du STREAM est vide. chunk is empty. S'il vous plait, faites un bug report, et/ou essayez avec une version plus ancienne de {-rage}.
## Encrypted identities
encrypted-passphrase-prompt = Type passphrase for encrypted identity '{$filename}'
encrypted-warn-no-match = Warning: encrypted identity file '{$filename}' didn't match file's recipients
## Plugin identities
plugin-waiting-on-binary = Waiting for {$binary_name}...
## SSH identities
ssh-passphrase-prompt = Type passphrase for {-openssh} key '{$filename}'
ssh-unsupported-key = Unsupported SSH key: {$name}
ssh-insecure-key-format =
Insecure Encrypted Key Format
-----------------------------
Prior to {-openssh} version 7.8, if a password was set when generating a new
DSA, ECDSA, or RSA key, {-ssh-keygen} would encrypt the key using the encrypted
PEM format. This encryption format is insecure and should no longer be used.
You can migrate your key to the encrypted SSH private key format (which has
been supported by {-openssh} since version 6.5, released in January 2014) by
changing its passphrase with the following command:
{" "}{$change_passphrase}
If you are using an {-openssh} version between 6.5 and 7.7 (such as the default
{-openssh} provided on Ubuntu 18.04 LTS), you can use the following command to
force keys to be generated using the new format:
{" "}{$gen_new}
ssh-unsupported-cipher =
Unsupported Cipher for Encrypted SSH Key
----------------------------------------
{-openssh} internally supports several different ciphers for encrypted keys,
but it has only ever directly generated a few of them. {-rage} supports all
ciphers that {-ssh-keygen} might generate, and is being updated on a
case-by-case basis with support for non-standard ciphers. Your key uses a
currently-unsupported cipher ({$cipher}).
If you would like support for this key type, please open an issue here:
{$new_issue}
ssh-unsupported-key-type =
Unsupported SSH Key Type
------------------------
{-openssh} supports various different key types, but {-rage} only supports a
subset of these for backwards compatibility, specifically the '{-ssh-rsa}'
and '{-ssh-ed25519}' key types. This SSH key uses the unsupported key type
'{$key_type}'.
ssh-unsupported-security-key =
Authenficateur physique SSH non supporté
--------------------------------------
{-openssh} version 8.2p1 a ajouté le support pour les authentificateurs physique {-fido-u2f}
y compris les clefs de sécurité physiques telles que {-yubikeys}. {-rage} ne fonctionne pas
avec ce type de clef SSH, parcque leur protocole ne supporte pas le chiffrement.
Cette clef SSH est du type '{$key_type}' qui n'est pas compatible.
Si vous avez une clef de sécurité physique, vous devriez utiliser ce plugin:
{$age_plugin_yubikey_url}
Une clef de sécurité utilisée avec à la fois {-openssh} et ce plugin aura
une clef SSH publique différente de sa clef destinataire {-age}, car ce plugin
implémente le protocol {-piv}.

View file

@ -13,6 +13,8 @@
-age = age
-rage = rage
-scrypt-recipient = scrypt::Recipient
-openssh = OpenSSH
-ssh-keygen = ssh-keygen
-ssh-rsa = ssh-rsa
@ -44,6 +46,16 @@ rec-deny-binary-output = Intendevi usare {-flag-armor}? {rec-detected-binary}
err-deny-overwrite-file = rifiuto di sovrascrivere il file esistente '{$filename}'.
## Identity file errors
err-failed-to-write-output = Impossibile scrivere sull'output: {$err}
err-identity-file-contains-plugin = Il file '{$filename}' contiene identità per '{-age-plugin-}{$plugin_name}'.
rec-identity-file-contains-plugin = Prova a usare '{-age-plugin-}{$plugin_name}' per convertire questa identità in destinatario.
err-no-identities-in-file = Nessuna identità trovata nel file '{$filename}'.
err-no-identities-in-stdin = Nessuna identità trovata tramite standard input.
## Errors
err-decryption-failed = Decifrazione fallita
@ -57,6 +69,8 @@ err-header-mac-invalid = Il MAC dell'header è invalido
err-key-decryption = La decifrazione di una chiave crittografata è fallita
err-missing-recipients = Destinatari mancanti.
err-no-matching-keys = Nessuna chiave corrispondente trovata
err-unknown-format = Formato {-age} sconosciuto.

View file

@ -13,6 +13,8 @@
-age = age
-rage = rage
-scrypt-recipient = scrypt::Recipient
-openssh = OpenSSH
-ssh-keygen = ssh-keygen
-ssh-rsa = ssh-rsa
@ -44,6 +46,16 @@ rec-deny-binary-output = Возможно, вы хотели использов
err-deny-overwrite-file = отказ от перезаписи существующего файла '{$filename}'.
## Identity file errors
err-failed-to-write-output = Не удалось записать в выходной файл: {$err}
err-identity-file-contains-plugin = Файл идентификации '{$filename}' содержит идентификаторы для '{-age-plugin-}{$plugin_name}'.
rec-identity-file-contains-plugin = Попробуйте использовать '{-age-plugin-}{$plugin_name}' для преобразования этого идентификатора в получателя.
err-no-identities-in-file = Идентификаторы в файле '{$filename}' не найдены.
err-no-identities-in-stdin = Идентификаторы в стандартном вводе не найдены.
## Errors
err-decryption-failed = Ошибка дешифрования
@ -57,6 +69,8 @@ err-header-mac-invalid = Недействительный MAC заголовка
err-key-decryption = Не удалось расшифровать зашифрованный ключ
err-missing-recipients = Отсутствуют получатели.
err-no-matching-keys = Не найдены подходящие ключи
err-unknown-format = Неизвестный формат {-age}.

View file

@ -13,6 +13,8 @@
-age = age
-rage = rage
-scrypt-recipient = scrypt::Recipient
-openssh = OpenSSH
-ssh-keygen = ssh-keygen
-ssh-rsa = ssh-rsa
@ -55,6 +57,8 @@ err-header-mac-invalid = 标头消息认证码 (MAC) 无效
err-key-decryption = 未能解密加密密钥
err-missing-recipients = 缺少接收方。
err-no-matching-keys = 未搜索到匹配的密钥
err-unknown-format = 未知的 {-age} 格式。

View file

@ -13,6 +13,8 @@
-age = age
-rage = rage
-scrypt-recipient = scrypt::Recipient
-openssh = OpenSSH
-ssh-keygen = ssh-keygen
-ssh-rsa = ssh-rsa
@ -55,6 +57,8 @@ err-header-mac-invalid = 標頭消息認證碼 (MAC) 無效
err-key-decryption = 未能解密加密密鑰
err-missing-recipients = 缺少接收方。
err-no-matching-keys = 未搜索到匹配的密鑰
err-unknown-format = 未知的 {-age} 格式。

View file

@ -125,10 +125,10 @@ pub fn read_secret(
input.interact()
} else {
// Fall back to CLI interface.
let passphrase = prompt_password(format!("{}: ", description)).map(SecretString::new)?;
let passphrase = prompt_password(format!("{}: ", description)).map(SecretString::from)?;
if let Some(confirm_prompt) = confirm {
let confirm_passphrase =
prompt_password(format!("{}: ", confirm_prompt)).map(SecretString::new)?;
prompt_password(format!("{}: ", confirm_prompt)).map(SecretString::from)?;
if !bool::from(
passphrase
@ -199,7 +199,7 @@ impl Passphrase {
acc + "-" + s
}
});
Passphrase::Generated(SecretString::new(new_passphrase))
Passphrase::Generated(SecretString::from(new_passphrase))
}
}

View file

@ -1,7 +1,10 @@
use std::fmt;
use std::io;
use crate::{wfl, wlnfl, DecryptError};
use crate::{wfl, DecryptError};
#[cfg(feature = "plugin")]
use crate::wlnfl;
/// Errors that can occur while reading recipients or identities.
#[derive(Debug)]

View file

@ -16,39 +16,37 @@ use crate::{fl, util::LINE_ENDING, wfl, wlnfl};
const SHORT_OUTPUT_LENGTH: usize = 20 * 80;
#[derive(Debug)]
struct DenyBinaryOutputError;
enum FileError {
DenyBinaryOutput,
DenyOverwriteFile(String),
DetectedBinaryOutput,
InvalidFilename(String),
MissingDirectory(String),
}
impl fmt::Display for DenyBinaryOutputError {
impl fmt::Display for FileError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
wlnfl!(f, "err-deny-binary-output")?;
wfl!(f, "rec-deny-binary-output")
match self {
Self::DenyBinaryOutput => {
wlnfl!(f, "err-deny-binary-output")?;
wfl!(f, "rec-deny-binary-output")
}
Self::DenyOverwriteFile(filename) => {
wfl!(f, "err-deny-overwrite-file", filename = filename.as_str())
}
Self::DetectedBinaryOutput => {
wlnfl!(f, "err-detected-binary")?;
wfl!(f, "rec-detected-binary")
}
Self::InvalidFilename(filename) => {
wfl!(f, "err-invalid-filename", filename = filename.as_str())
}
Self::MissingDirectory(path) => wfl!(f, "err-missing-directory", path = path.as_str()),
}
}
}
impl std::error::Error for DenyBinaryOutputError {}
#[derive(Debug)]
struct DetectedBinaryOutputError;
impl fmt::Display for DetectedBinaryOutputError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
wlnfl!(f, "err-detected-binary")?;
wfl!(f, "rec-detected-binary")
}
}
impl std::error::Error for DetectedBinaryOutputError {}
#[derive(Debug)]
struct DenyOverwriteFileError(String);
impl fmt::Display for DenyOverwriteFileError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
wfl!(f, "err-deny-overwrite-file", filename = self.0.as_str())
}
}
impl std::error::Error for DenyOverwriteFileError {}
impl std::error::Error for FileError {}
/// Wrapper around a [`File`].
pub struct FileReader {
@ -211,7 +209,7 @@ impl Write for StdoutWriter {
if std::str::from_utf8(data).is_err() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
DetectedBinaryOutputError,
FileError::DetectedBinaryOutput,
));
}
}
@ -353,13 +351,32 @@ impl OutputWriter {
// Respect the Unix convention that "-" as an output filename
// parameter is an explicit request to use standard output.
if filename != "-" {
let file_path = Path::new(&filename);
// Provide a better error if the filename is invalid, or the directory
// containing the file does not exist (we don't automatically create
// directories).
if let Some(dir_path) = file_path.parent() {
if !(dir_path == Path::new("") || dir_path.exists()) {
return Err(io::Error::new(
io::ErrorKind::NotFound,
FileError::MissingDirectory(dir_path.display().to_string()),
));
}
} else {
return Err(io::Error::new(
io::ErrorKind::NotFound,
FileError::InvalidFilename(filename),
));
}
// We open the file lazily, but as we don't want the caller to assume
// this, we eagerly confirm that the file does not exist if we can't
// overwrite it.
if !allow_overwrite && Path::new(&filename).exists() {
if !allow_overwrite && file_path.exists() {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
DenyOverwriteFileError(filename),
FileError::DenyOverwriteFile(filename),
));
}
@ -378,7 +395,10 @@ impl OutputWriter {
} else if is_tty {
if let OutputFormat::Binary = format {
// If output == Some("-") then this error is skipped.
return Err(io::Error::new(io::ErrorKind::Other, DenyBinaryOutputError));
return Err(io::Error::new(
io::ErrorKind::Other,
FileError::DenyBinaryOutput,
));
}
}

View file

@ -1,10 +1,10 @@
use std::io::{self, BufReader};
use super::{file_io::InputReader, ReadError, StdinGuard, UiCallbacks};
use super::{ReadError, StdinGuard, UiCallbacks};
use crate::{identity::IdentityFile, Identity};
#[cfg(feature = "armor")]
use crate::armor::ArmoredReader;
use crate::{armor::ArmoredReader, cli_common::file_io::InputReader};
/// Reads identities from the provided files.
///
@ -23,19 +23,21 @@ pub fn read_identities(
max_work_factor,
stdin_guard,
&mut identities,
#[cfg(feature = "armor")]
|identities, identity| {
identities.push(Box::new(identity));
Ok(())
},
#[cfg(feature = "ssh")]
|identities, _, identity| {
identities.push(Box::new(identity.with_callbacks(UiCallbacks)));
Ok(())
},
|identities, entry| {
let entry = entry.into_identity(UiCallbacks);
|identities, identity_file| {
let new_identities = identity_file.into_identities();
#[cfg(feature = "plugin")]
let entry = entry.map_err(|e| match e {
let new_identities = new_identities.map_err(|e| match e {
#[cfg(feature = "plugin")]
crate::DecryptError::MissingPlugin { binary_name } => {
ReadError::MissingPlugin { binary_name }
@ -48,9 +50,9 @@ pub fn read_identities(
// IdentityFileEntry::into_identity will never return a MissingPlugin error
// when plugin feature is not enabled.
#[cfg(not(feature = "plugin"))]
let entry = entry.unwrap();
let new_identities = new_identities.unwrap();
identities.push(entry);
identities.extend(new_identities);
Ok(())
},
@ -62,7 +64,7 @@ pub fn read_identities(
/// Parses the provided identity files.
pub(super) fn parse_identity_files<Ctx, E: From<ReadError> + From<io::Error>>(
filenames: Vec<String>,
max_work_factor: Option<u8>,
_max_work_factor: Option<u8>,
stdin_guard: &mut StdinGuard,
ctx: &mut Ctx,
#[cfg(feature = "armor")] encrypted_identity: impl Fn(
@ -70,17 +72,21 @@ pub(super) fn parse_identity_files<Ctx, E: From<ReadError> + From<io::Error>>(
crate::encrypted::Identity<ArmoredReader<BufReader<InputReader>>, UiCallbacks>,
) -> Result<(), E>,
#[cfg(feature = "ssh")] ssh_identity: impl Fn(&mut Ctx, &str, crate::ssh::Identity) -> Result<(), E>,
identity_file_entry: impl Fn(&mut Ctx, crate::IdentityFileEntry) -> Result<(), E>,
identity_file: impl Fn(&mut Ctx, crate::IdentityFile<UiCallbacks>) -> Result<(), E>,
) -> Result<(), E> {
for filename in filenames {
let mut reader = PeekableReader::new(BufReader::new(
stdin_guard.open(filename.clone()).map_err(|e| match e {
#[cfg_attr(not(any(feature = "armor", feature = "ssh")), allow(unused_mut))]
let mut reader =
PeekableReader::new(stdin_guard.open(filename.clone()).map_err(|e| match e {
ReadError::Io(e) if matches!(e.kind(), io::ErrorKind::NotFound) => {
ReadError::IdentityNotFound(filename.clone())
}
_ => e,
})?,
));
})?);
// Note to future self: the order in which we try parsing formats here is critical
// to the correct behaviour of `PeekableReader::fill_buf`. See the comments in
// that method.
#[cfg(feature = "armor")]
// Try parsing as an encrypted age identity.
@ -88,7 +94,7 @@ pub(super) fn parse_identity_files<Ctx, E: From<ReadError> + From<io::Error>>(
ArmoredReader::new_buffered(&mut reader),
Some(filename.clone()),
UiCallbacks,
max_work_factor,
_max_work_factor,
)
.is_ok()
{
@ -101,7 +107,7 @@ pub(super) fn parse_identity_files<Ctx, E: From<ReadError> + From<io::Error>>(
ArmoredReader::new_buffered(reader.inner),
Some(filename.clone()),
UiCallbacks,
max_work_factor,
_max_work_factor,
)
.expect("already parsed the age ciphertext header");
@ -132,34 +138,42 @@ pub(super) fn parse_identity_files<Ctx, E: From<ReadError> + From<io::Error>>(
reader.reset()?;
// Try parsing as multiple single-line age identities.
let identity_file = IdentityFile::from_buffer(reader)?;
for entry in identity_file.into_identities() {
identity_file_entry(ctx, entry)?;
}
identity_file(
ctx,
IdentityFile::from_buffer(reader)?.with_callbacks(UiCallbacks),
)?;
}
Ok(())
}
/// Same as default buffer size for `BufReader`, but hard-coded so we know exactly what
/// the buffer size is, and therefore can detect if the entire file fits into a single
/// buffer.
///
/// This must be at least 71 bytes to ensure the correct behaviour of
/// `PeekableReader::fill_buf`. See the comments in that method.
const PEEKABLE_SIZE: usize = 8 * 1024;
enum PeekState {
Peeking { consumed: usize },
Reading,
}
struct PeekableReader<R: io::BufRead> {
inner: R,
struct PeekableReader<R: io::Read> {
inner: BufReader<R>,
state: PeekState,
}
impl<R: io::BufRead> PeekableReader<R> {
impl<R: io::Read> PeekableReader<R> {
fn new(inner: R) -> Self {
Self {
inner,
inner: BufReader::with_capacity(PEEKABLE_SIZE, inner),
state: PeekState::Peeking { consumed: 0 },
}
}
#[cfg(any(feature = "armor", feature = "ssh"))]
fn reset(&mut self) -> io::Result<()> {
match &mut self.state {
PeekState::Peeking { consumed } => {
@ -174,7 +188,7 @@ impl<R: io::BufRead> PeekableReader<R> {
}
}
impl<R: io::BufRead> io::Read for PeekableReader<R> {
impl<R: io::Read> io::Read for PeekableReader<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
match self.state {
PeekState::Peeking { .. } => {
@ -192,7 +206,7 @@ impl<R: io::BufRead> io::Read for PeekableReader<R> {
}
}
impl<R: io::BufRead> io::BufRead for PeekableReader<R> {
impl<R: io::Read> io::BufRead for PeekableReader<R> {
fn fill_buf(&mut self) -> io::Result<&[u8]> {
match self.state {
PeekState::Peeking { consumed } => {
@ -208,6 +222,28 @@ impl<R: io::BufRead> io::BufRead for PeekableReader<R> {
// on `self.inner` to outside the conditional, which would prevent us
// from performing other mutable operations on the other side.
Ok(&self.inner.fill_buf()?[consumed..])
} else if inner_len < PEEKABLE_SIZE {
// We have read the entire file into a single buffer and consumed all
// of it. Don't fall through to change the state to `Reading`, because
// we can always reset a single-buffer stream.
//
// Note that we cannot distinguish between the file being the exact
// same size as our buffer, and the file being larger than it. But
// this only becomes relevant if we cannot distinguish between the
// kinds of identity files we support parsing, within a single buffer.
// We should always be able to distinguish before then, because we
// parse in the following order:
//
// - Encrypted identities, which are parsed incrementally as age
// ciphertexts with optional armor, and can be detected in at most
// 70 bytes.
// - SSH identities, which are parsed as a PEM encoding and can be
// detected in at most 36 bytes.
// - Identity files, which have one identity per line and therefore
// can have arbitrarily long lines. We intentionally try this format
// last.
assert_eq!(consumed, inner_len);
Ok(&[])
} else {
// We're done peeking.
self.inner.consume(consumed);

View file

@ -1,15 +1,22 @@
use std::io::{self, BufReader};
use super::StdinGuard;
use super::{identities::parse_identity_files, ReadError, UiCallbacks};
use crate::{x25519, EncryptError, IdentityFileEntry, Recipient};
use super::{identities::parse_identity_files, ReadError};
use crate::identity::RecipientsAccumulator;
use crate::{x25519, Recipient};
#[cfg(feature = "plugin")]
use crate::plugin;
use crate::{cli_common::UiCallbacks, plugin};
#[cfg(not(feature = "plugin"))]
use std::convert::Infallible;
#[cfg(feature = "ssh")]
use crate::ssh;
#[cfg(any(feature = "armor", feature = "plugin"))]
use crate::EncryptError;
/// Handles error mapping for the given SSH recipient parser.
///
/// Returns `Ok(None)` if the parser finds a parseable value that should be ignored. This
@ -44,25 +51,34 @@ where
/// Parses a recipient from a string.
fn parse_recipient(
filename: &str,
_filename: &str,
s: String,
recipients: &mut Vec<Box<dyn Recipient + Send>>,
plugin_recipients: &mut Vec<plugin::Recipient>,
recipients: &mut RecipientsAccumulator,
) -> Result<(), ReadError> {
if let Ok(pk) = s.parse::<x25519::Recipient>() {
recipients.push(Box::new(pk));
} else if let Some(pk) = {
#[cfg(feature = "ssh")]
{
parse_ssh_recipient(|| s.parse::<ssh::Recipient>(), || Ok(None), filename)?
parse_ssh_recipient(|| s.parse::<ssh::Recipient>(), || Ok(None), _filename)?
}
#[cfg(not(feature = "ssh"))]
None
} {
recipients.push(pk);
} else if let Ok(recipient) = s.parse::<plugin::Recipient>() {
plugin_recipients.push(recipient);
} else if let Some(_recipient) = {
#[cfg(feature = "plugin")]
{
// TODO Do something with the error?
s.parse::<plugin::Recipient>().ok()
}
#[cfg(not(feature = "plugin"))]
None::<Infallible>
} {
#[cfg(feature = "plugin")]
recipients.push_plugin(_recipient);
} else {
return Err(ReadError::InvalidRecipient(s));
}
@ -74,8 +90,7 @@ fn parse_recipient(
fn read_recipients_list<R: io::BufRead>(
filename: &str,
buf: R,
recipients: &mut Vec<Box<dyn Recipient + Send>>,
plugin_recipients: &mut Vec<plugin::Recipient>,
recipients: &mut RecipientsAccumulator,
) -> Result<(), ReadError> {
for (line_number, line) in buf.lines().enumerate() {
let line = line?;
@ -83,13 +98,13 @@ fn read_recipients_list<R: io::BufRead>(
// Skip empty lines and comments
if line.is_empty() || line.find('#') == Some(0) {
continue;
} else if let Err(e) = parse_recipient(filename, line, recipients, plugin_recipients) {
} else if let Err(_e) = parse_recipient(filename, line, recipients) {
#[cfg(feature = "ssh")]
match e {
match _e {
ReadError::RsaModulusTooLarge
| ReadError::RsaModulusTooSmall
| ReadError::UnsupportedKey(_, _) => {
return Err(io::Error::new(io::ErrorKind::InvalidData, e.to_string()).into());
return Err(io::Error::new(io::ErrorKind::InvalidData, _e.to_string()).into());
}
_ => (),
}
@ -118,12 +133,10 @@ pub fn read_recipients(
max_work_factor: Option<u8>,
stdin_guard: &mut StdinGuard,
) -> Result<Vec<Box<dyn Recipient + Send>>, ReadError> {
let mut recipients: Vec<Box<dyn Recipient + Send>> = vec![];
let mut plugin_recipients: Vec<plugin::Recipient> = vec![];
let mut plugin_identities: Vec<plugin::Identity> = vec![];
let mut recipients = RecipientsAccumulator::new();
for arg in recipient_strings {
parse_recipient("", arg, &mut recipients, &mut plugin_recipients)?;
parse_recipient("", arg, &mut recipients)?;
}
for arg in recipients_file_strings {
@ -134,15 +147,16 @@ pub fn read_recipients(
_ => e,
})?;
let buf = BufReader::new(f);
read_recipients_list(&arg, buf, &mut recipients, &mut plugin_recipients)?;
read_recipients_list(&arg, buf, &mut recipients)?;
}
parse_identity_files::<_, ReadError>(
identity_strings,
max_work_factor,
stdin_guard,
&mut (&mut recipients, &mut plugin_identities),
|(recipients, _), identity| {
&mut recipients,
#[cfg(feature = "armor")]
|recipients, identity| {
recipients.extend(identity.recipients().map_err(|e| {
// Only one error can occur here.
if let EncryptError::EncryptedIdentities(e) = e {
@ -153,7 +167,8 @@ pub fn read_recipients(
})?);
Ok(())
},
|(recipients, _), filename, identity| {
#[cfg(feature = "ssh")]
|recipients, filename, identity| {
let recipient = parse_ssh_recipient(
|| ssh::Recipient::try_from(identity),
|| Err(ReadError::InvalidRecipient(filename.to_owned())),
@ -163,43 +178,29 @@ pub fn read_recipients(
recipients.push(recipient);
Ok(())
},
|(recipients, plugin_identities), entry| {
match entry {
IdentityFileEntry::Native(i) => recipients.push(Box::new(i.to_public())),
IdentityFileEntry::Plugin(i) => plugin_identities.push(i),
}
|recipients, identity_file| {
recipients.with_identities(identity_file);
Ok(())
},
)?;
// Collect the names of the required plugins.
let mut plugin_names = plugin_recipients
.iter()
.map(|r| r.plugin())
.chain(plugin_identities.iter().map(|i| i.plugin()))
.collect::<Vec<_>>();
plugin_names.sort_unstable();
plugin_names.dedup();
// Find the required plugins.
for plugin_name in plugin_names {
recipients.push(Box::new(
plugin::RecipientPluginV1::new(
plugin_name,
&plugin_recipients,
&plugin_identities,
UiCallbacks,
)
.map_err(|e| {
// Only one error can occur here.
if let EncryptError::MissingPlugin { binary_name } = e {
recipients
.build(
#[cfg(feature = "plugin")]
UiCallbacks,
)
.map_err(|_e| {
// Only one error can occur here.
#[cfg(feature = "plugin")]
{
if let EncryptError::MissingPlugin { binary_name } = _e {
ReadError::MissingPlugin { binary_name }
} else {
unreachable!()
}
})?,
))
}
}
Ok(recipients)
#[cfg(not(feature = "plugin"))]
unreachable!()
})
}

View file

@ -2,18 +2,16 @@
use std::{cell::Cell, io};
use crate::{
decryptor::PassphraseDecryptor, fl, Callbacks, DecryptError, Decryptor, EncryptError,
IdentityFile, IdentityFileEntry,
};
use crate::{fl, scrypt, Callbacks, DecryptError, Decryptor, EncryptError, IdentityFile};
/// The state of the encrypted age identity.
enum IdentityState<R: io::Read> {
enum IdentityState<R: io::Read, C: Callbacks> {
Encrypted {
decryptor: PassphraseDecryptor<R>,
decryptor: Decryptor<R>,
max_work_factor: Option<u8>,
callbacks: C,
},
Decrypted(Vec<IdentityFileEntry>),
Decrypted(IdentityFile<C>),
/// The file was not correctly encrypted, or did not contain age identities. We cache
/// this error in case the caller tries to use this identity again. The `Option` is to
@ -22,26 +20,23 @@ enum IdentityState<R: io::Read> {
Poisoned(Option<DecryptError>),
}
impl<R: io::Read> Default for IdentityState<R> {
impl<R: io::Read, C: Callbacks> Default for IdentityState<R, C> {
fn default() -> Self {
Self::Poisoned(None)
}
}
impl<R: io::Read> IdentityState<R> {
impl<R: io::Read, C: Callbacks> IdentityState<R, C> {
/// Decrypts this encrypted identity if necessary.
///
/// Returns the (possibly cached) identities, and a boolean marking if the identities
/// were not cached (and we just asked the user for a passphrase).
fn decrypt<C: Callbacks>(
self,
filename: Option<&str>,
callbacks: C,
) -> Result<(Vec<IdentityFileEntry>, bool), DecryptError> {
fn decrypt(self, filename: Option<&str>) -> Result<(IdentityFile<C>, bool), DecryptError> {
match self {
Self::Encrypted {
decryptor,
max_work_factor,
callbacks,
} => {
let passphrase = match callbacks.request_passphrase(&fl!(
"encrypted-passphrase-prompt",
@ -51,8 +46,13 @@ impl<R: io::Read> IdentityState<R> {
None => todo!(),
};
let mut identity = scrypt::Identity::new(passphrase);
if let Some(max_work_factor) = max_work_factor {
identity.set_max_work_factor(max_work_factor);
}
decryptor
.decrypt(&passphrase, max_work_factor)
.decrypt(Some(&identity as _).into_iter())
.map_err(|e| {
if matches!(e, DecryptError::DecryptionFailed) {
DecryptError::KeyDecryptionFailed
@ -61,11 +61,12 @@ impl<R: io::Read> IdentityState<R> {
}
})
.and_then(|stream| {
let file = IdentityFile::from_buffer(io::BufReader::new(stream))?;
Ok((file.into_identities(), true))
let file = IdentityFile::from_buffer(io::BufReader::new(stream))?
.with_callbacks(callbacks);
Ok((file, true))
})
}
Self::Decrypted(identities) => Ok((identities, false)),
Self::Decrypted(identity_file) => Ok((identity_file, false)),
// `IdentityState::decrypt` is only ever called with `Some`.
Self::Poisoned(e) => Err(e.unwrap()),
}
@ -74,9 +75,8 @@ impl<R: io::Read> IdentityState<R> {
/// An encrypted age identity file.
pub struct Identity<R: io::Read, C: Callbacks> {
state: Cell<IdentityState<R>>,
state: Cell<IdentityState<R, C>>,
filename: Option<String>,
callbacks: C,
}
impl<R: io::Read, C: Callbacks> Identity<R, C> {
@ -92,17 +92,15 @@ impl<R: io::Read, C: Callbacks> Identity<R, C> {
callbacks: C,
max_work_factor: Option<u8>,
) -> Result<Option<Self>, DecryptError> {
match Decryptor::new(data)? {
Decryptor::Recipients(_) => Ok(None),
Decryptor::Passphrase(decryptor) => Ok(Some(Identity {
state: Cell::new(IdentityState::Encrypted {
decryptor,
max_work_factor,
}),
filename,
let decryptor = Decryptor::new(data)?;
Ok(decryptor.is_scrypt().then_some(Identity {
state: Cell::new(IdentityState::Encrypted {
decryptor,
max_work_factor,
callbacks,
})),
}
}),
filename,
}))
}
/// Returns the recipients contained within this encrypted identity.
@ -110,18 +108,10 @@ impl<R: io::Read, C: Callbacks> Identity<R, C> {
/// If this encrypted identity has not been decrypted yet, calling this method will
/// trigger a passphrase request.
pub fn recipients(&self) -> Result<Vec<Box<dyn crate::Recipient + Send>>, EncryptError> {
match self
.state
.take()
.decrypt(self.filename.as_deref(), self.callbacks.clone())
{
Ok((identities, _)) => {
let recipients = identities
.iter()
.map(|entry| entry.to_recipient(self.callbacks.clone()))
.collect::<Result<Vec<_>, _>>();
self.state.set(IdentityState::Decrypted(identities));
match self.state.take().decrypt(self.filename.as_deref()) {
Ok((identity_file, _)) => {
let recipients = identity_file.to_recipients();
self.state.set(IdentityState::Decrypted(identity_file));
recipients
}
Err(e) => {
@ -151,27 +141,20 @@ impl<R: io::Read, C: Callbacks> Identity<R, C> {
Result<Box<dyn crate::Identity>, DecryptError>,
) -> Option<Result<age_core::format::FileKey, DecryptError>>,
{
match self
.state
.take()
.decrypt(self.filename.as_deref(), self.callbacks.clone())
{
Ok((identities, requested_passphrase)) => {
let result = identities
.iter()
.map(|entry| entry.clone().into_identity(self.callbacks.clone()))
.find_map(filter);
match self.state.take().decrypt(self.filename.as_deref()) {
Ok((identity_file, requested_passphrase)) => {
let result = identity_file.to_identities().find_map(filter);
// If we requested a passphrase to decrypt, and none of the identities
// matched, warn the user.
if requested_passphrase && result.is_none() {
self.callbacks.display_message(&fl!(
identity_file.callbacks.display_message(&fl!(
"encrypted-warn-no-match",
filename = self.filename.as_deref().unwrap_or_default()
));
}
self.state.set(IdentityState::Decrypted(identities));
self.state.set(IdentityState::Decrypted(identity_file));
result
}
Err(e) => {
@ -256,7 +239,7 @@ fOrxrKTj7xCdNS3+OrCdnBC8Z9cKDxjCGWW3fkjLsYha0Jo=
/// This intentionally panics if called twice.
fn request_passphrase(&self, _: &str) -> Option<SecretString> {
Some(SecretString::new(
Some(SecretString::from(
self.0.lock().unwrap().take().unwrap().to_owned(),
))
}
@ -265,9 +248,12 @@ fOrxrKTj7xCdNS3+OrCdnBC8Z9cKDxjCGWW3fkjLsYha0Jo=
#[test]
#[cfg(feature = "armor")]
fn round_trip() {
use age_core::format::FileKey;
let pk: x25519::Recipient = TEST_RECIPIENT.parse().unwrap();
let file_key = [12; 16].into();
let wrapped = pk.wrap_file_key(&file_key).unwrap();
let file_key = FileKey::new(Box::new([12; 16]));
let (wrapped, labels) = pk.wrap_file_key(&file_key).unwrap();
assert!(labels.is_empty());
// Unwrapping with the wrong passphrase fails.
{

View file

@ -1,5 +1,6 @@
//! Error type.
use std::collections::HashSet;
use std::fmt;
use std::io;
@ -8,6 +9,72 @@ use crate::{wfl, wlnfl};
#[cfg(feature = "plugin")]
use age_core::format::Stanza;
/// Errors returned when converting an identity file to a recipients file.
#[derive(Debug)]
pub enum IdentityFileConvertError {
/// An I/O error occurred while writing out a recipient corresponding to an identity
/// in this file.
FailedToWriteOutput(io::Error),
/// The identity file contains a plugin identity, which can be converted to a
/// recipient for encryption purposes, but not for writing a recipients file.
#[cfg(feature = "plugin")]
#[cfg_attr(docsrs, doc(cfg(feature = "plugin")))]
IdentityFileContainsPlugin {
/// The given identity file.
filename: Option<String>,
/// The name of the plugin.
plugin_name: String,
},
/// The identity file contains no identities, and thus cannot be used to produce a
/// recipients file.
NoIdentities {
/// The given identity file.
filename: Option<String>,
},
}
impl fmt::Display for IdentityFileConvertError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
IdentityFileConvertError::FailedToWriteOutput(e) => {
wfl!(f, "err-failed-to-write-output", err = e.to_string())
}
#[cfg(feature = "plugin")]
IdentityFileConvertError::IdentityFileContainsPlugin {
filename,
plugin_name,
} => {
wlnfl!(
f,
"err-identity-file-contains-plugin",
filename = filename.as_deref().unwrap_or_default(),
plugin_name = plugin_name.as_str(),
)?;
wfl!(
f,
"rec-identity-file-contains-plugin",
plugin_name = plugin_name.as_str(),
)
}
IdentityFileConvertError::NoIdentities { filename } => match filename {
Some(filename) => {
wfl!(f, "err-no-identities-in-file", filename = filename.as_str())
}
None => wfl!(f, "err-no-identities-in-stdin"),
},
}
}
}
impl std::error::Error for IdentityFileConvertError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
IdentityFileConvertError::FailedToWriteOutput(e) => Some(e),
_ => None,
}
}
}
/// Errors returned by a plugin.
#[cfg(feature = "plugin")]
#[cfg_attr(docsrs, doc(cfg(feature = "plugin")))]
@ -101,6 +168,18 @@ impl fmt::Display for PluginError {
pub enum EncryptError {
/// An error occured while decrypting passphrase-encrypted identities.
EncryptedIdentities(DecryptError),
/// The encryptor was given recipients that declare themselves incompatible.
IncompatibleRecipients {
/// The set of labels from the first recipient provided to the encryptor.
l_labels: HashSet<String>,
/// The set of labels from the first non-matching recipient.
r_labels: HashSet<String>,
},
/// One or more of the labels from the first recipient provided to the encryptor are
/// invalid.
///
/// Labels must be valid age "arbitrary string"s (`1*VCHAR` in ABNF).
InvalidRecipientLabels(HashSet<String>),
/// An I/O error occurred during encryption.
Io(io::Error),
/// A required plugin could not be found.
@ -110,6 +189,12 @@ pub enum EncryptError {
/// The plugin's binary name.
binary_name: String,
},
/// The encryptor was not given any recipients.
MissingRecipients,
/// [`scrypt::Recipient`] was mixed with other recipient types.
///
/// [`scrypt::Recipient`]: crate::scrypt::Recipient
MixedRecipientAndPassphrase,
/// Errors from a plugin.
#[cfg(feature = "plugin")]
#[cfg_attr(docsrs, doc(cfg(feature = "plugin")))]
@ -126,27 +211,79 @@ impl Clone for EncryptError {
fn clone(&self) -> Self {
match self {
Self::EncryptedIdentities(e) => Self::EncryptedIdentities(e.clone()),
Self::IncompatibleRecipients { l_labels, r_labels } => Self::IncompatibleRecipients {
l_labels: l_labels.clone(),
r_labels: r_labels.clone(),
},
Self::InvalidRecipientLabels(labels) => Self::InvalidRecipientLabels(labels.clone()),
Self::Io(e) => Self::Io(io::Error::new(e.kind(), e.to_string())),
#[cfg(feature = "plugin")]
Self::MissingPlugin { binary_name } => Self::MissingPlugin {
binary_name: binary_name.clone(),
},
Self::MissingRecipients => Self::MissingRecipients,
Self::MixedRecipientAndPassphrase => Self::MixedRecipientAndPassphrase,
#[cfg(feature = "plugin")]
Self::Plugin(e) => Self::Plugin(e.clone()),
}
}
}
fn print_labels(labels: &HashSet<String>) -> String {
let mut s = String::new();
for (i, label) in labels.iter().enumerate() {
s.push_str(label);
if i != 0 {
s.push_str(", ");
}
}
s
}
impl fmt::Display for EncryptError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
EncryptError::EncryptedIdentities(e) => e.fmt(f),
EncryptError::IncompatibleRecipients { l_labels, r_labels } => {
match (l_labels.is_empty(), r_labels.is_empty()) {
(true, true) => unreachable!("labels are compatible"),
(false, true) => {
wfl!(
f,
"err-incompatible-recipients-oneway",
labels = print_labels(l_labels),
)
}
(true, false) => {
wfl!(
f,
"err-incompatible-recipients-oneway",
labels = print_labels(r_labels),
)
}
(false, false) => wfl!(
f,
"err-incompatible-recipients-twoway",
left = print_labels(l_labels),
right = print_labels(r_labels),
),
}
}
EncryptError::InvalidRecipientLabels(labels) => wfl!(
f,
"err-invalid-recipient-labels",
labels = print_labels(labels),
),
EncryptError::Io(e) => e.fmt(f),
#[cfg(feature = "plugin")]
EncryptError::MissingPlugin { binary_name } => {
wlnfl!(f, "err-missing-plugin", plugin_name = binary_name.as_str())?;
wfl!(f, "rec-missing-plugin")
}
EncryptError::MissingRecipients => wfl!(f, "err-missing-recipients"),
EncryptError::MixedRecipientAndPassphrase => {
wfl!(f, "err-mixed-recipient-passphrase")
}
#[cfg(feature = "plugin")]
EncryptError::Plugin(errors) => match &errors[..] {
[] => unreachable!(),
@ -168,7 +305,6 @@ impl std::error::Error for EncryptError {
match self {
EncryptError::EncryptedIdentities(inner) => Some(inner),
EncryptError::Io(inner) => Some(inner),
#[cfg(feature = "plugin")]
_ => None,
}
}

View file

@ -1,11 +1,13 @@
//! The age file format.
use age_core::format::Stanza;
use std::io::{self, BufRead, Read, Write};
use age_core::format::{grease_the_joint, Stanza};
use crate::{
error::DecryptError,
primitives::{HmacKey, HmacWriter},
scrypt, EncryptError,
};
#[cfg(feature = "async")]
@ -32,13 +34,22 @@ pub(crate) struct HeaderV1 {
}
impl HeaderV1 {
pub(crate) fn new(recipients: Vec<Stanza>, mac_key: HmacKey) -> Self {
pub(crate) fn new(recipients: Vec<Stanza>, mac_key: HmacKey) -> Result<Self, EncryptError> {
let mut header = HeaderV1 {
recipients,
mac: [0; 32],
encoded_bytes: None,
};
if header.no_scrypt() {
// Keep the joint well oiled!
header.recipients.push(grease_the_joint());
}
if !header.is_valid() {
return Err(EncryptError::MixedRecipientAndPassphrase);
}
let mut mac = HmacWriter::new(mac_key);
cookie_factory::gen(write::header_v1_minus_mac(&header), &mut mac)
.expect("can serialize Header into HmacWriter");
@ -46,7 +57,7 @@ impl HeaderV1 {
.mac
.copy_from_slice(mac.finalize().into_bytes().as_slice());
header
Ok(header)
}
pub(crate) fn verify_mac(&self, mac_key: HmacKey) -> Result<(), hmac::digest::MacError> {
@ -61,6 +72,33 @@ impl HeaderV1 {
}
mac.verify(&self.mac)
}
fn any_scrypt(&self) -> bool {
self.recipients
.iter()
.any(|r| r.tag == scrypt::SCRYPT_RECIPIENT_TAG)
}
/// Checks whether the header contains a single recipient of type `scrypt`.
///
/// This can be used along with [`Self::no_scrypt`] to enforce the structural
/// requirements on the v1 header.
pub(crate) fn valid_scrypt(&self) -> bool {
self.any_scrypt() && self.recipients.len() == 1
}
/// Checks whether the header contains no `scrypt` recipients.
///
/// This can be used along with [`Self::valid_scrypt`] to enforce the structural
/// requirements on the v1 header.
pub(crate) fn no_scrypt(&self) -> bool {
!self.any_scrypt()
}
/// Enforces structural requirements on the v1 header.
pub(crate) fn is_valid(&self) -> bool {
self.valid_scrypt() || self.no_scrypt()
}
}
impl Header {

View file

@ -16,7 +16,7 @@ lazy_static! {
// Ensure that the fallback language is always loaded, even if the library user
// doesn't call `localizer().select(languages)`.
let fallback: LanguageIdentifier = "en-US".parse().unwrap();
language_loader.load_languages(&Localizations, &[&fallback]).unwrap();
language_loader.load_languages(&Localizations, &[fallback]).unwrap();
language_loader
};
}

View file

@ -1,7 +1,7 @@
use std::fs::File;
use std::io;
use crate::{x25519, Callbacks, DecryptError, EncryptError};
use crate::{x25519, Callbacks, DecryptError, EncryptError, IdentityFileConvertError, NoCallbacks};
#[cfg(feature = "cli-common")]
use crate::cli_common::file_io::InputReader;
@ -11,7 +11,7 @@ use crate::plugin;
/// The supported kinds of identities within an [`IdentityFile`].
#[derive(Clone)]
pub enum IdentityFileEntry {
enum IdentityFileEntry {
/// The standard age identity type.
Native(x25519::Identity),
/// A plugin-compatible identity.
@ -29,38 +29,25 @@ impl IdentityFileEntry {
match self {
IdentityFileEntry::Native(i) => Ok(Box::new(i)),
#[cfg(feature = "plugin")]
IdentityFileEntry::Plugin(i) => Ok(Box::new(crate::plugin::IdentityPluginV1::new(
i.plugin(),
&[i.clone()],
callbacks,
)?)),
}
}
#[allow(unused_variables)]
pub(crate) fn to_recipient(
&self,
callbacks: impl Callbacks,
) -> Result<Box<dyn crate::Recipient + Send>, EncryptError> {
match self {
IdentityFileEntry::Native(i) => Ok(Box::new(i.to_public())),
#[cfg(feature = "plugin")]
IdentityFileEntry::Plugin(i) => Ok(Box::new(crate::plugin::RecipientPluginV1::new(
i.plugin(),
&[],
&[i.clone()],
callbacks,
)?)),
IdentityFileEntry::Plugin(i) => Ok(Box::new(
crate::plugin::Plugin::new(i.plugin())
.map_err(|binary_name| DecryptError::MissingPlugin { binary_name })
.map(|plugin| {
crate::plugin::IdentityPluginV1::from_parts(plugin, vec![i], callbacks)
})?,
)),
}
}
}
/// A list of identities that has been parsed from some input file.
pub struct IdentityFile {
pub struct IdentityFile<C: Callbacks> {
filename: Option<String>,
identities: Vec<IdentityFileEntry>,
pub(crate) callbacks: C,
}
impl IdentityFile {
impl IdentityFile<NoCallbacks> {
/// Parses one or more identities from a file containing valid UTF-8.
pub fn from_file(filename: String) -> io::Result<Self> {
File::open(&filename)
@ -129,12 +116,177 @@ impl IdentityFile {
}
}
Ok(IdentityFile { identities })
Ok(IdentityFile {
filename,
identities,
callbacks: NoCallbacks,
})
}
}
impl<C: Callbacks> IdentityFile<C> {
/// Sets the provided callbacks on this identity file, so that if this is an encrypted
/// identity, it can potentially be decrypted.
pub fn with_callbacks<D: Callbacks>(self, callbacks: D) -> IdentityFile<D> {
IdentityFile {
filename: self.filename,
identities: self.identities,
callbacks,
}
}
/// Writes a recipients file containing the recipients corresponding to the identities
/// in this file.
///
/// Returns an error if this file is empty, or if it contains plugin identities (which
/// can only be converted by the plugin binary itself).
pub fn write_recipients_file<W: io::Write>(
&self,
mut output: W,
) -> Result<(), IdentityFileConvertError> {
if self.identities.is_empty() {
return Err(IdentityFileConvertError::NoIdentities {
filename: self.filename.clone(),
});
}
for identity in &self.identities {
match identity {
IdentityFileEntry::Native(sk) => writeln!(output, "{}", sk.to_public())
.map_err(IdentityFileConvertError::FailedToWriteOutput)?,
#[cfg(feature = "plugin")]
IdentityFileEntry::Plugin(id) => {
return Err(IdentityFileConvertError::IdentityFileContainsPlugin {
filename: self.filename.clone(),
plugin_name: id.plugin().to_string(),
});
}
}
}
Ok(())
}
/// Returns recipients for the identities in this file.
///
/// Plugin identities will be merged into one [`Recipient`] per unique plugin.
///
/// [`Recipient`]: crate::Recipient
pub fn to_recipients(&self) -> Result<Vec<Box<dyn crate::Recipient + Send>>, EncryptError> {
let mut recipients = RecipientsAccumulator::new();
recipients.with_identities_ref(self);
recipients.build(
#[cfg(feature = "plugin")]
self.callbacks.clone(),
)
}
/// Returns the identities in this file.
pub fn into_identities(self) -> Vec<IdentityFileEntry> {
pub(crate) fn to_identities(
&self,
) -> impl Iterator<Item = Result<Box<dyn crate::Identity>, DecryptError>> + '_ {
self.identities
.iter()
.map(|entry| entry.clone().into_identity(self.callbacks.clone()))
}
/// Returns the identities in this file.
pub fn into_identities(self) -> Result<Vec<Box<dyn crate::Identity>>, DecryptError> {
self.identities
.into_iter()
.map(|entry| entry.into_identity(self.callbacks.clone()))
.collect()
}
}
pub(crate) struct RecipientsAccumulator {
recipients: Vec<Box<dyn crate::Recipient + Send>>,
#[cfg(feature = "plugin")]
plugin_recipients: Vec<plugin::Recipient>,
#[cfg(feature = "plugin")]
plugin_identities: Vec<plugin::Identity>,
}
impl RecipientsAccumulator {
pub(crate) fn new() -> Self {
Self {
recipients: vec![],
#[cfg(feature = "plugin")]
plugin_recipients: vec![],
#[cfg(feature = "plugin")]
plugin_identities: vec![],
}
}
#[cfg(feature = "cli-common")]
pub(crate) fn push(&mut self, recipient: Box<dyn crate::Recipient + Send>) {
self.recipients.push(recipient);
}
#[cfg(feature = "plugin")]
pub(crate) fn push_plugin(&mut self, recipient: plugin::Recipient) {
self.plugin_recipients.push(recipient);
}
#[cfg(feature = "armor")]
pub(crate) fn extend(
&mut self,
iter: impl IntoIterator<Item = Box<dyn crate::Recipient + Send>>,
) {
self.recipients.extend(iter);
}
#[cfg(feature = "cli-common")]
pub(crate) fn with_identities<C: Callbacks>(&mut self, identity_file: IdentityFile<C>) {
for entry in identity_file.identities {
match entry {
IdentityFileEntry::Native(i) => self.recipients.push(Box::new(i.to_public())),
#[cfg(feature = "plugin")]
IdentityFileEntry::Plugin(i) => self.plugin_identities.push(i),
}
}
}
pub(crate) fn with_identities_ref<C: Callbacks>(&mut self, identity_file: &IdentityFile<C>) {
for entry in &identity_file.identities {
match entry {
IdentityFileEntry::Native(i) => self.recipients.push(Box::new(i.to_public())),
#[cfg(feature = "plugin")]
IdentityFileEntry::Plugin(i) => self.plugin_identities.push(i.clone()),
}
}
}
#[cfg_attr(not(feature = "plugin"), allow(unused_mut))]
pub(crate) fn build(
mut self,
#[cfg(feature = "plugin")] callbacks: impl Callbacks,
) -> Result<Vec<Box<dyn crate::Recipient + Send>>, EncryptError> {
#[cfg(feature = "plugin")]
{
// Collect the names of the required plugins.
let mut plugin_names = self
.plugin_recipients
.iter()
.map(|r| r.plugin())
.chain(self.plugin_identities.iter().map(|i| i.plugin()))
.collect::<Vec<_>>();
plugin_names.sort_unstable();
plugin_names.dedup();
// Find the required plugins.
for plugin_name in plugin_names {
self.recipients
.push(Box::new(plugin::RecipientPluginV1::new(
plugin_name,
&self.plugin_recipients,
&self.plugin_identities,
callbacks.clone(),
)?))
}
}
Ok(self.recipients)
}
}

View file

@ -3,7 +3,7 @@
use age_core::{
format::FileKey,
primitives::hkdf,
secrecy::{ExposeSecret, Secret},
secrecy::{ExposeSecret, SecretBox},
};
use rand::{rngs::OsRng, RngCore};
@ -18,17 +18,15 @@ const HEADER_KEY_LABEL: &[u8] = b"header";
const PAYLOAD_KEY_LABEL: &[u8] = b"payload";
pub(crate) fn new_file_key() -> FileKey {
let mut file_key = [0; 16];
OsRng.fill_bytes(&mut file_key);
file_key.into()
FileKey::init_with_mut(|file_key| OsRng.fill_bytes(file_key))
}
pub(crate) fn mac_key(file_key: &FileKey) -> HmacKey {
HmacKey(Secret::new(hkdf(
HmacKey(SecretBox::new(Box::new(hkdf(
&[],
HEADER_KEY_LABEL,
file_key.expose_secret(),
)))
))))
}
pub(crate) fn v1_payload_key(

View file

@ -8,8 +8,10 @@
//! There are several ways to use these:
//! - For most cases (including programmatic usage), use [`Encryptor::with_recipients`]
//! with [`x25519::Recipient`], and [`Decryptor`] with [`x25519::Identity`].
//! - APIs are available for passphrase-based encryption and decryption. These should
//! only be used with passphrases that were provided by (or generated for) a human.
//! - For passphrase-based encryption and decryption, use [`scrypt::Recipient`] and
//! [`scrypt::Identity`], or the helper method [`Encryptor::with_user_passphrase`].
//! These should only be used with passphrases that were provided by (or generated for)
//! a human.
//! - For compatibility with existing SSH keys, enable the `ssh` feature flag, and use
//! [`ssh::Recipient`] and [`ssh::Identity`].
//!
@ -25,7 +27,76 @@
//!
//! # Examples
//!
//! ## Recipient-based encryption
//! ## Streamlined APIs
//!
//! These are useful when you only need to encrypt to a single recipient, and the data is
//! small enough to fit in memory.
//!
//! ### Recipient-based encryption
//!
//! ```
//! # fn run_main() -> Result<(), ()> {
//! let key = age::x25519::Identity::generate();
//! let pubkey = key.to_public();
//!
//! let plaintext = b"Hello world!";
//!
//! # fn encrypt(pubkey: age::x25519::Recipient, plaintext: &[u8]) -> Result<Vec<u8>, age::EncryptError> {
//! let encrypted = age::encrypt(&pubkey, plaintext)?;
//! # Ok(encrypted)
//! # }
//! # fn decrypt(key: age::x25519::Identity, encrypted: Vec<u8>) -> Result<Vec<u8>, age::DecryptError> {
//! let decrypted = age::decrypt(&key, &encrypted)?;
//! # Ok(decrypted)
//! # }
//! # let decrypted = decrypt(
//! # key,
//! # encrypt(pubkey, &plaintext[..]).map_err(|_| ())?
//! # ).map_err(|_| ())?;
//!
//! assert_eq!(decrypted, plaintext);
//! # Ok(())
//! # }
//! # run_main().unwrap();
//! ```
//!
//! ## Passphrase-based encryption
//!
//! ```
//! use age::secrecy::SecretString;
//!
//! # fn run_main() -> Result<(), ()> {
//! let passphrase = SecretString::from("this is not a good passphrase".to_owned());
//! let recipient = age::scrypt::Recipient::new(passphrase.clone());
//! let identity = age::scrypt::Identity::new(passphrase);
//!
//! let plaintext = b"Hello world!";
//!
//! # fn encrypt(recipient: age::scrypt::Recipient, plaintext: &[u8]) -> Result<Vec<u8>, age::EncryptError> {
//! let encrypted = age::encrypt(&recipient, plaintext)?;
//! # Ok(encrypted)
//! # }
//! # fn decrypt(identity: age::scrypt::Identity, encrypted: Vec<u8>) -> Result<Vec<u8>, age::DecryptError> {
//! let decrypted = age::decrypt(&identity, &encrypted)?;
//! # Ok(decrypted)
//! # }
//! # let decrypted = decrypt(
//! # identity,
//! # encrypt(recipient, &plaintext[..]).map_err(|_| ())?
//! # ).map_err(|_| ())?;
//!
//! assert_eq!(decrypted, plaintext);
//! # Ok(())
//! # }
//! # run_main().unwrap();
//! ```
//!
//! ## Full APIs
//!
//! The full APIs support encrypting to multiple recipients, streaming the data, and have
//! async I/O options.
//!
//! ### Recipient-based encryption
//!
//! ```
//! use std::io::{Read, Write};
@ -40,7 +111,7 @@
//! // Encrypt the plaintext to a ciphertext...
//! # fn encrypt(pubkey: age::x25519::Recipient, plaintext: &[u8]) -> Result<Vec<u8>, age::EncryptError> {
//! let encrypted = {
//! let encryptor = age::Encryptor::with_recipients(vec![Box::new(pubkey)])
//! let encryptor = age::Encryptor::with_recipients(iter::once(&pubkey as _))
//! .expect("we provided a recipient");
//!
//! let mut encrypted = vec![];
@ -56,10 +127,7 @@
//! // ... and decrypt the obtained ciphertext to the plaintext again.
//! # fn decrypt(key: age::x25519::Identity, encrypted: Vec<u8>) -> Result<Vec<u8>, age::DecryptError> {
//! let decrypted = {
//! let decryptor = match age::Decryptor::new(&encrypted[..])? {
//! age::Decryptor::Recipients(d) => d,
//! _ => unreachable!(),
//! };
//! let decryptor = age::Decryptor::new(&encrypted[..])?;
//!
//! let mut decrypted = vec![];
//! let mut reader = decryptor.decrypt(iter::once(&key as &dyn age::Identity))?;
@ -84,17 +152,18 @@
//! ## Passphrase-based encryption
//!
//! ```
//! use age::secrecy::Secret;
//! use age::secrecy::SecretString;
//! use std::io::{Read, Write};
//! use std::iter;
//!
//! # fn run_main() -> Result<(), ()> {
//! let plaintext = b"Hello world!";
//! let passphrase = "this is not a good passphrase";
//! let passphrase = SecretString::from("this is not a good passphrase".to_owned());
//!
//! // Encrypt the plaintext to a ciphertext using the passphrase...
//! # fn encrypt(passphrase: &str, plaintext: &[u8]) -> Result<Vec<u8>, age::EncryptError> {
//! # fn encrypt(passphrase: SecretString, plaintext: &[u8]) -> Result<Vec<u8>, age::EncryptError> {
//! let encrypted = {
//! let encryptor = age::Encryptor::with_user_passphrase(Secret::new(passphrase.to_owned()));
//! let encryptor = age::Encryptor::with_user_passphrase(passphrase.clone());
//!
//! let mut encrypted = vec![];
//! let mut writer = encryptor.wrap_output(&mut encrypted)?;
@ -107,15 +176,12 @@
//! # }
//!
//! // ... and decrypt the ciphertext to the plaintext again using the same passphrase.
//! # fn decrypt(passphrase: &str, encrypted: Vec<u8>) -> Result<Vec<u8>, age::DecryptError> {
//! # fn decrypt(passphrase: SecretString, encrypted: Vec<u8>) -> Result<Vec<u8>, age::DecryptError> {
//! let decrypted = {
//! let decryptor = match age::Decryptor::new(&encrypted[..])? {
//! age::Decryptor::Passphrase(d) => d,
//! _ => unreachable!(),
//! };
//! let decryptor = age::Decryptor::new(&encrypted[..])?;
//!
//! let mut decrypted = vec![];
//! let mut reader = decryptor.decrypt(&Secret::new(passphrase.to_owned()), None)?;
//! let mut reader = decryptor.decrypt(iter::once(&age::scrypt::Identity::new(passphrase) as _))?;
//! reader.read_to_end(&mut decrypted);
//!
//! decrypted
@ -123,7 +189,7 @@
//! # Ok(decrypted)
//! # }
//! # let decrypted = decrypt(
//! # passphrase,
//! # passphrase.clone(),
//! # encrypt(passphrase, &plaintext[..]).map_err(|_| ())?
//! # ).map_err(|_| ())?;
//!
@ -139,6 +205,8 @@
#![deny(rustdoc::broken_intra_doc_links)]
#![deny(missing_docs)]
use std::collections::HashSet;
// Re-export crates that are used in our public API.
pub use age_core::secrecy;
@ -150,12 +218,13 @@ mod primitives;
mod protocol;
mod util;
pub use error::{DecryptError, EncryptError};
pub use identity::{IdentityFile, IdentityFileEntry};
pub use error::{DecryptError, EncryptError, IdentityFileConvertError};
pub use identity::IdentityFile;
pub use primitives::stream;
pub use protocol::{decryptor, Decryptor, Encryptor};
pub use protocol::{Decryptor, Encryptor};
#[cfg(feature = "armor")]
#[cfg_attr(docsrs, doc(cfg(feature = "armor")))]
pub use primitives::armor;
#[cfg(feature = "cli-common")]
@ -165,12 +234,23 @@ pub mod cli_common;
mod i18n;
pub use i18n::localizer;
//
// Simple interface
//
mod simple;
pub use simple::{decrypt, encrypt};
#[cfg(feature = "armor")]
#[cfg_attr(docsrs, doc(cfg(feature = "armor")))]
pub use simple::encrypt_and_armor;
//
// Identity types
//
pub mod encrypted;
mod scrypt;
pub mod scrypt;
pub mod x25519;
#[cfg(feature = "plugin")]
@ -181,6 +261,10 @@ pub mod plugin;
#[cfg_attr(docsrs, doc(cfg(feature = "ssh")))]
pub mod ssh;
//
// Core traits
//
use age_core::{
format::{FileKey, Stanza},
secrecy::SecretString,
@ -188,12 +272,25 @@ use age_core::{
/// A private key or other value that can unwrap an opaque file key from a recipient
/// stanza.
///
/// # Implementation notes
///
/// The canonical entry point for this trait is [`Identity::unwrap_stanzas`]. The default
/// implementation of that method is:
/// ```ignore
/// stanzas.iter().find_map(|stanza| self.unwrap_stanza(stanza))
/// ```
///
/// The `age` crate otherwise does not call [`Identity::unwrap_stanza`] directly. As such,
/// if you want to add file-level stanza checks, override [`Identity::unwrap_stanzas`].
pub trait Identity {
/// Attempts to unwrap the given stanza with this identity.
///
/// This method is part of the `Identity` trait to expose age's [one joint] for
/// external implementations. You should not need to call this directly; instead, pass
/// identities to [`RecipientsDecryptor::decrypt`].
/// identities to [`Decryptor::decrypt`].
///
/// The `age` crate only calls this method via [`Identity::unwrap_stanzas`].
///
/// Returns:
/// - `Some(Ok(file_key))` on success.
@ -201,7 +298,6 @@ pub trait Identity {
/// - `None` if the recipient stanza does not match this key.
///
/// [one joint]: https://www.imperialviolet.org/2016/05/16/agility.html
/// [`RecipientsDecryptor::decrypt`]: protocol::decryptor::RecipientsDecryptor::decrypt
fn unwrap_stanza(&self, stanza: &Stanza) -> Option<Result<FileKey, DecryptError>>;
/// Attempts to unwrap any of the given stanzas, which are assumed to come from the
@ -209,7 +305,7 @@ pub trait Identity {
///
/// This method is part of the `Identity` trait to expose age's [one joint] for
/// external implementations. You should not need to call this directly; instead, pass
/// identities to [`RecipientsDecryptor::decrypt`].
/// identities to [`Decryptor::decrypt`].
///
/// Returns:
/// - `Some(Ok(file_key))` on success.
@ -217,7 +313,6 @@ pub trait Identity {
/// - `None` if none of the recipient stanzas match this identity.
///
/// [one joint]: https://www.imperialviolet.org/2016/05/16/agility.html
/// [`RecipientsDecryptor::decrypt`]: protocol::decryptor::RecipientsDecryptor::decrypt
fn unwrap_stanzas(&self, stanzas: &[Stanza]) -> Option<Result<FileKey, DecryptError>> {
stanzas.iter().find_map(|stanza| self.unwrap_stanza(stanza))
}
@ -227,16 +322,50 @@ pub trait Identity {
///
/// Implementations of this trait might represent more than one recipient.
pub trait Recipient {
/// Wraps the given file key, returning stanzas to be placed in an age file header.
/// Wraps the given file key, returning stanzas to be placed in an age file header,
/// and labels that constrain how the stanzas may be combined with those from other
/// recipients.
///
/// Implementations MUST NOT return more than one stanza per "actual recipient".
/// Implementations may return more than one stanza per "actual recipient", e.g. to
/// support multiple formats, to build group aliases, or to act as a proxy.
///
/// This method is part of the `Recipient` trait to expose age's [one joint] for
/// external implementations. You should not need to call this directly; instead, pass
/// recipients to [`Encryptor::with_recipients`].
///
/// [one joint]: https://www.imperialviolet.org/2016/05/16/agility.html
fn wrap_file_key(&self, file_key: &FileKey) -> Result<Vec<Stanza>, EncryptError>;
///
/// # Labels
///
/// [`Encryptor`] will succeed at encrypting only if every recipient returns the same
/// set of labels. Subsets or partial overlapping sets are not allowed; all sets must
/// be identical. Labels are compared exactly, and are case-sensitive.
///
/// Label sets can be used to ensure a recipient is only encrypted to alongside other
/// recipients with equivalent properties, or to ensure a recipient is always used
/// alone. A recipient with no particular properties to enforce should return an empty
/// label set.
///
/// Labels can have any value that is a valid arbitrary string (`1*VCHAR` in ABNF),
/// but usually take one of several forms:
/// - *Common public label* - used by multiple recipients to permit their stanzas to
/// be used only together. Examples include:
/// - `postquantum` - indicates that the recipient stanzas being generated are
/// postquantum-secure, and that they can only be combined with other stanzas
/// that are also postquantum-secure.
/// - *Common private label* - used by recipients created by the same private entity
/// to permit their recipient stanzas to be used only together. For example,
/// private recipients used in a corporate environment could all send the same
/// private label in order to prevent compliant age clients from simultaneously
/// wrapping file keys with other recipients.
/// - *Random label* - used by recipients that want to ensure their stanzas are not
/// used with any other recipient stanzas. This can be used to produce a file key
/// that is only encrypted to a single recipient stanza, for example to preserve
/// its authentication properties.
fn wrap_file_key(
&self,
file_key: &FileKey,
) -> Result<(Vec<Stanza>, HashSet<String>), EncryptError>;
}
/// Callbacks that might be triggered during encryption or decryption.
@ -248,6 +377,9 @@ pub trait Callbacks: Clone + Send + Sync + 'static {
///
/// This can be used to prompt the user to take some physical action, such as
/// inserting a hardware key.
///
/// No guarantee is provided that the user sees this message (for example, if there is
/// no UI for displaying messages).
fn display_message(&self, message: &str);
/// Requests that the user provides confirmation for some action.
@ -270,12 +402,49 @@ pub trait Callbacks: Clone + Send + Sync + 'static {
/// Requests non-private input from the user.
///
/// To request private inputs, use [`Callbacks::request_passphrase`].
///
/// Returns:
/// - `Some(input)` with the user-provided input.
/// - `None` if no input could be requested from the user (for example, if there is no
/// UI for displaying messages or typing inputs).
fn request_public_string(&self, description: &str) -> Option<String>;
/// Requests a passphrase to decrypt a key.
///
/// Returns:
/// - `Some(passphrase)` with the user-provided passphrase.
/// - `None` if no passphrase could be requested from the user (for example, if there
/// is no UI for displaying messages or typing inputs).
fn request_passphrase(&self, description: &str) -> Option<SecretString>;
}
/// An implementation of [`Callbacks`] that does not allow callbacks.
///
/// No user interaction will occur; [`Recipient`] or [`Identity`] implementations will
/// receive `None` from the callbacks that return responses, and will act accordingly.
#[derive(Clone, Copy, Debug)]
pub struct NoCallbacks;
impl Callbacks for NoCallbacks {
fn display_message(&self, _: &str) {}
fn confirm(&self, _: &str, _: &str, _: Option<&str>) -> Option<bool> {
None
}
fn request_public_string(&self, _: &str) -> Option<String> {
None
}
fn request_passphrase(&self, _: &str) -> Option<SecretString> {
None
}
}
//
// Fuzzing APIs
//
/// Helper for fuzzing the Header parser and serializer.
#[cfg(fuzzing)]
pub fn fuzz_header(data: &[u8]) {

View file

@ -10,6 +10,7 @@ use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
use bech32::Variant;
use std::borrow::Borrow;
use std::collections::HashSet;
use std::fmt;
use std::io;
use std::iter;
@ -32,6 +33,7 @@ const PLUGIN_IDENTITY_PREFIX: &str = "age-plugin-";
const CMD_ERROR: &str = "error";
const CMD_RECIPIENT_STANZA: &str = "recipient-stanza";
const CMD_LABELS: &str = "labels";
const CMD_MSG: &str = "msg";
const CMD_CONFIRM: &str = "confirm";
const CMD_REQUEST_PUBLIC: &str = "request-public";
@ -215,7 +217,7 @@ impl Identity {
}
/// An age plugin.
struct Plugin {
pub(crate) struct Plugin {
binary_name: String,
path: PathBuf,
}
@ -224,7 +226,7 @@ impl Plugin {
/// Finds the age plugin with the given name in `$PATH`.
///
/// On error, returns the binary name that could not be located.
fn new(name: &str) -> Result<Self, String> {
pub(crate) fn new(name: &str) -> Result<Self, String> {
let binary_name = binary_name(name);
match which::which(&binary_name).or_else(|e| {
// If we are running in WSL, try appending `.exe`; plugins installed in
@ -410,7 +412,10 @@ impl<C: Callbacks> RecipientPluginV1<C> {
}
impl<C: Callbacks> crate::Recipient for RecipientPluginV1<C> {
fn wrap_file_key(&self, file_key: &FileKey) -> Result<Vec<Stanza>, EncryptError> {
fn wrap_file_key(
&self,
file_key: &FileKey,
) -> Result<(Vec<Stanza>, HashSet<String>), EncryptError> {
// Open connection
let mut conn = self.plugin.connect(RECIPIENT_V1)?;
@ -424,11 +429,13 @@ impl<C: Callbacks> crate::Recipient for RecipientPluginV1<C> {
for identity in &self.identities {
phase.send("add-identity", &[&identity.identity], &[])?;
}
phase.send("extension-labels", &[], &[])?;
phase.send("wrap-file-key", &[], file_key.expose_secret())
})?;
// Phase 2: collect either stanzas or errors
let mut stanzas = vec![];
let mut labels = None;
let mut errors = vec![];
if let Err(e) = conn.bidir_receive(
&[
@ -437,6 +444,7 @@ impl<C: Callbacks> crate::Recipient for RecipientPluginV1<C> {
CMD_REQUEST_PUBLIC,
CMD_REQUEST_SECRET,
CMD_RECIPIENT_STANZA,
CMD_LABELS,
CMD_ERROR,
],
|mut command, reply| match command.tag.as_str() {
@ -492,6 +500,34 @@ impl<C: Callbacks> crate::Recipient for RecipientPluginV1<C> {
}
reply.ok(None)
}
CMD_LABELS => {
if labels.is_none() {
let labels_count = command.args.len();
let label_set = command.args.into_iter().collect::<HashSet<_>>();
if label_set.len() == labels_count {
labels = Some(label_set);
} else {
errors.push(PluginError::Other {
kind: "internal".to_owned(),
metadata: vec![],
message: format!(
"{} command must not contain duplicate labels",
CMD_LABELS
),
});
}
} else {
errors.push(PluginError::Other {
kind: "internal".to_owned(),
metadata: vec![],
message: format!(
"{} command must not be sent more than once",
CMD_LABELS
),
});
}
reply.ok(None)
}
CMD_ERROR => {
if command.args.len() == 2 && command.args[0] == "recipient" {
let index: usize = command.args[1].parse().unwrap();
@ -517,7 +553,7 @@ impl<C: Callbacks> crate::Recipient for RecipientPluginV1<C> {
return Err(e.into());
};
match (stanzas.is_empty(), errors.is_empty()) {
(false, true) => Ok(stanzas),
(false, true) => Ok((stanzas, labels.unwrap_or_default())),
(a, b) => {
if a & b {
errors.push(PluginError::Other {
@ -563,14 +599,13 @@ impl<C: Callbacks> IdentityPluginV1<C> {
if valid_plugin_name(plugin_name) {
Plugin::new(plugin_name)
.map_err(|binary_name| DecryptError::MissingPlugin { binary_name })
.map(|plugin| IdentityPluginV1 {
plugin,
identities: identities
.map(|plugin| {
let identities = identities
.iter()
.filter(|r| r.name == plugin_name)
.cloned()
.collect(),
callbacks,
.collect();
Self::from_parts(plugin, identities, callbacks)
})
} else {
Err(DecryptError::MissingPlugin {
@ -579,6 +614,14 @@ impl<C: Callbacks> IdentityPluginV1<C> {
}
}
pub(crate) fn from_parts(plugin: Plugin, identities: Vec<Identity>, callbacks: C) -> Self {
IdentityPluginV1 {
plugin,
identities,
callbacks,
}
}
fn unwrap_stanzas<'a>(
&self,
stanzas: impl Iterator<Item = &'a Stanza>,
@ -645,11 +688,14 @@ impl<C: Callbacks> IdentityPluginV1<C> {
// We only support a single file.
assert!(command.args[0] == "0");
assert!(file_key.is_none());
file_key = Some(
TryInto::<[u8; 16]>::try_into(&command.body[..])
.map_err(|_| DecryptError::DecryptionFailed)
.map(FileKey::from),
);
file_key = Some(FileKey::try_init_with_mut(|file_key| {
if command.body.len() == file_key.len() {
file_key.copy_from_slice(&command.body);
Ok(())
} else {
Err(DecryptError::DecryptionFailed)
}
}));
reply.ok(None)
}
CMD_ERROR => {
@ -690,7 +736,7 @@ impl<C: Callbacks> crate::Identity for IdentityPluginV1<C> {
#[cfg(test)]
mod tests {
use crate::{Callbacks, DecryptError, EncryptError};
use crate::{DecryptError, EncryptError, NoCallbacks};
use super::{
Identity, IdentityPluginV1, Recipient, RecipientPluginV1, PLUGIN_IDENTITY_PREFIX,
@ -699,21 +745,6 @@ mod tests {
const INVALID_PLUGIN_NAME: &str = "foobar/../../../../../../../usr/bin/echo";
#[derive(Clone)]
struct NoCallbacks;
impl Callbacks for NoCallbacks {
fn display_message(&self, _: &str) {}
fn confirm(&self, _: &str, _: &str, _: Option<&str>) -> Option<bool> {
None
}
fn request_public_string(&self, _: &str) -> Option<String> {
None
}
fn request_passphrase(&self, _: &str) -> Option<crate::secrecy::SecretString> {
None
}
}
#[test]
fn default_for_plugin() {
assert_eq!(

View file

@ -1,6 +1,6 @@
//! Primitive cryptographic operations used by `age`.
use age_core::secrecy::{ExposeSecret, Secret};
use age_core::secrecy::{ExposeSecret, SecretBox};
use hmac::{
digest::{CtOutput, MacError},
Hmac, Mac,
@ -15,7 +15,7 @@ pub mod armor;
pub mod stream;
pub(crate) struct HmacKey(pub(crate) Secret<[u8; 32]>);
pub(crate) struct HmacKey(pub(crate) SecretBox<[u8; 32]>);
/// `HMAC[key](message)`
///

View file

@ -291,7 +291,7 @@ enum ArmorIs<W> {
/// ```
/// # use std::io::Read;
/// use std::io::Write;
/// # use std::iter;
/// use std::iter;
///
/// # fn run_main() -> Result<(), ()> {
/// # let identity = age::x25519::Identity::generate();
@ -301,7 +301,7 @@ enum ArmorIs<W> {
///
/// # fn encrypt(recipient: age::x25519::Recipient, plaintext: &[u8]) -> Result<Vec<u8>, age::EncryptError> {
/// let encrypted = {
/// let encryptor = age::Encryptor::with_recipients(vec![Box::new(recipient)])
/// let encryptor = age::Encryptor::with_recipients(iter::once(&recipient as _))
/// .expect("we provided a recipient");
///
/// let mut encrypted = vec![];
@ -321,12 +321,7 @@ enum ArmorIs<W> {
/// # }
/// # fn decrypt(identity: age::x25519::Identity, encrypted: Vec<u8>) -> Result<Vec<u8>, age::DecryptError> {
/// # let decrypted = {
/// # let decryptor = match age::Decryptor::new(
/// # age::armor::ArmoredReader::new(&encrypted[..])
/// # )? {
/// # age::Decryptor::Recipients(d) => d,
/// # _ => unreachable!(),
/// # };
/// # let decryptor = age::Decryptor::new(age::armor::ArmoredReader::new(&encrypted[..]))?;
/// # let mut decrypted = vec![];
/// # let mut reader = decryptor.decrypt(iter::once(&identity as &dyn age::Identity))?;
/// # reader.read_to_end(&mut decrypted);
@ -669,7 +664,7 @@ enum StartPos {
/// # fn run_main() -> Result<(), ()> {
/// # fn encrypt(recipient: age::x25519::Recipient, plaintext: &[u8]) -> Result<Vec<u8>, age::EncryptError> {
/// # let encrypted = {
/// # let encryptor = age::Encryptor::with_recipients(vec![Box::new(recipient)])
/// # let encryptor = age::Encryptor::with_recipients(iter::once(&recipient as _))
/// # .expect("we provided a recipient");
/// # let mut encrypted = vec![];
/// # let mut writer = encryptor.wrap_output(
@ -693,12 +688,7 @@ enum StartPos {
///
/// # fn decrypt(identity: age::x25519::Identity, encrypted: Vec<u8>) -> Result<Vec<u8>, age::DecryptError> {
/// let decrypted = {
/// let decryptor = match age::Decryptor::new(
/// age::armor::ArmoredReader::new(&encrypted[..])
/// )? {
/// age::Decryptor::Recipients(d) => d,
/// _ => unreachable!(),
/// };
/// let decryptor = age::Decryptor::new(age::armor::ArmoredReader::new(&encrypted[..]))?;
///
/// let mut decrypted = vec![];
/// let mut reader = decryptor.decrypt(iter::once(&identity as &dyn age::Identity))?;

View file

@ -1,6 +1,6 @@
//! I/O helper structs for age file encryption and decryption.
use age_core::secrecy::{ExposeSecret, SecretVec};
use age_core::secrecy::{ExposeSecret, SecretSlice};
use chacha20poly1305::{
aead::{generic_array::GenericArray, Aead, KeyInit, KeySizeUser},
ChaCha20Poly1305,
@ -194,7 +194,7 @@ impl Stream {
Ok(encrypted)
}
fn decrypt_chunk(&mut self, chunk: &[u8], last: bool) -> io::Result<SecretVec<u8>> {
fn decrypt_chunk(&mut self, chunk: &[u8], last: bool) -> io::Result<SecretSlice<u8>> {
assert!(chunk.len() <= ENCRYPTED_CHUNK_SIZE);
self.nonce.set_last(last).map_err(|_| {
@ -204,7 +204,7 @@ impl Stream {
let decrypted = self
.aead
.decrypt(&self.nonce.to_bytes().into(), chunk)
.map(SecretVec::new)
.map(SecretSlice::from)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "decryption error"))?;
self.nonce.increment_counter();
@ -407,7 +407,7 @@ pub struct StreamReader<R> {
start: StartPos,
plaintext_len: Option<u64>,
cur_plaintext_pos: u64,
chunk: Option<SecretVec<u8>>,
chunk: Option<SecretSlice<u8>>,
}
impl<R> StreamReader<R> {

View file

@ -1,22 +1,22 @@
//! Encryption and decryption routines for age.
use age_core::{format::grease_the_joint, secrecy::SecretString};
use age_core::{format::is_arbitrary_string, secrecy::SecretString};
use rand::{rngs::OsRng, RngCore};
use std::io::{self, BufRead, Read, Write};
use std::iter;
use crate::{
error::{DecryptError, EncryptError},
format::{Header, HeaderV1},
keys::{mac_key, new_file_key, v1_payload_key},
primitives::stream::{PayloadKey, Stream, StreamWriter},
scrypt, Recipient,
primitives::stream::{PayloadKey, Stream, StreamReader, StreamWriter},
scrypt, Identity, Recipient,
};
#[cfg(feature = "async")]
use futures::io::{AsyncBufRead, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
pub mod decryptor;
pub(crate) struct Nonce([u8; 16]);
impl AsRef<[u8]> for Nonce {
@ -47,26 +47,14 @@ impl Nonce {
}
}
/// Handles the various types of age encryption.
enum EncryptorType {
/// Encryption to a list of recipients identified by keys.
Keys(Vec<Box<dyn Recipient + Send>>),
/// Encryption to a passphrase.
Passphrase(SecretString),
/// Encryptor for creating an age file.
pub struct Encryptor {
header: Header,
nonce: Nonce,
payload_key: PayloadKey,
}
/// Encryptor for creating an age file.
pub struct Encryptor(EncryptorType);
impl Encryptor {
/// Constructs an `Encryptor` that will create an age file encrypted to a list of
/// recipients.
///
/// Returns `None` if no recipients were provided.
pub fn with_recipients(recipients: Vec<Box<dyn Recipient + Send>>) -> Option<Self> {
(!recipients.is_empty()).then_some(Encryptor(EncryptorType::Keys(recipients)))
}
/// Returns an `Encryptor` that will create an age file encrypted with a passphrase.
/// Anyone with the passphrase can decrypt the file.
///
@ -76,33 +64,64 @@ impl Encryptor {
///
/// [`x25519::Identity`]: crate::x25519::Identity
pub fn with_user_passphrase(passphrase: SecretString) -> Self {
Encryptor(EncryptorType::Passphrase(passphrase))
Self::with_recipients(iter::once(&scrypt::Recipient::new(passphrase) as _))
.expect("no errors can occur with this recipient set")
}
/// Creates the header for this age file.
fn prepare_header(self) -> Result<(Header, Nonce, PayloadKey), EncryptError> {
/// Constructs an `Encryptor` that will create an age file encrypted to a list of
/// recipients.
pub fn with_recipients<'a>(
recipients: impl Iterator<Item = &'a dyn Recipient>,
) -> Result<Self, EncryptError> {
let file_key = new_file_key();
let recipients = match self.0 {
EncryptorType::Keys(recipients) => {
let mut stanzas = Vec::with_capacity(recipients.len() + 1);
for recipient in recipients {
stanzas.append(&mut recipient.wrap_file_key(&file_key)?);
let recipients = {
let mut control = None;
let mut stanzas = vec![];
let mut have_recipients = false;
for recipient in recipients {
have_recipients = true;
let (mut r_stanzas, r_labels) = recipient.wrap_file_key(&file_key)?;
if let Some(l_labels) = control.take() {
if l_labels != r_labels {
// Improve error message.
let err = if stanzas
.iter()
.chain(&r_stanzas)
.any(|stanza| stanza.tag == crate::scrypt::SCRYPT_RECIPIENT_TAG)
{
EncryptError::MixedRecipientAndPassphrase
} else {
EncryptError::IncompatibleRecipients { l_labels, r_labels }
};
return Err(err);
}
control = Some(l_labels);
} else if r_labels.iter().all(is_arbitrary_string) {
control = Some(r_labels);
} else {
return Err(EncryptError::InvalidRecipientLabels(r_labels));
}
// Keep the joint well oiled!
stanzas.push(grease_the_joint());
stanzas
stanzas.append(&mut r_stanzas);
}
EncryptorType::Passphrase(passphrase) => {
scrypt::Recipient { passphrase }.wrap_file_key(&file_key)?
if !have_recipients {
return Err(EncryptError::MissingRecipients);
}
stanzas
};
let header = HeaderV1::new(recipients, mac_key(&file_key));
let header = HeaderV1::new(recipients, mac_key(&file_key))?;
let nonce = Nonce::random();
let payload_key = v1_payload_key(&file_key, &header, &nonce).expect("MAC is correct");
Ok((Header::V1(header), nonce, payload_key))
Ok(Self {
header: Header::V1(header),
nonce,
payload_key,
})
}
/// Creates a wrapper around a writer that will encrypt its input.
@ -112,8 +131,12 @@ impl Encryptor {
/// You **MUST** call [`StreamWriter::finish`] when you are done writing, in order to
/// finish the encryption process. Failing to call [`StreamWriter::finish`] will
/// result in a truncated file that will fail to decrypt.
pub fn wrap_output<W: Write>(self, mut output: W) -> Result<StreamWriter<W>, EncryptError> {
let (header, nonce, payload_key) = self.prepare_header()?;
pub fn wrap_output<W: Write>(self, mut output: W) -> io::Result<StreamWriter<W>> {
let Self {
header,
nonce,
payload_key,
} = self;
header.write(&mut output)?;
output.write_all(nonce.as_ref())?;
Ok(Stream::encrypt(payload_key, output))
@ -131,8 +154,12 @@ impl Encryptor {
pub async fn wrap_async_output<W: AsyncWrite + Unpin>(
self,
mut output: W,
) -> Result<StreamWriter<W>, EncryptError> {
let (header, nonce, payload_key) = self.prepare_header()?;
) -> io::Result<StreamWriter<W>> {
let Self {
header,
nonce,
payload_key,
} = self;
header.write_async(&mut output).await?;
output.write_all(nonce.as_ref()).await?;
Ok(Stream::encrypt_async(payload_key, output))
@ -140,41 +167,49 @@ impl Encryptor {
}
/// Decryptor for an age file.
pub enum Decryptor<R> {
/// Decryption with a list of identities.
Recipients(decryptor::RecipientsDecryptor<R>),
/// Decryption with a passphrase.
Passphrase(decryptor::PassphraseDecryptor<R>),
}
impl<R> From<decryptor::RecipientsDecryptor<R>> for Decryptor<R> {
fn from(decryptor: decryptor::RecipientsDecryptor<R>) -> Self {
Decryptor::Recipients(decryptor)
}
}
impl<R> From<decryptor::PassphraseDecryptor<R>> for Decryptor<R> {
fn from(decryptor: decryptor::PassphraseDecryptor<R>) -> Self {
Decryptor::Passphrase(decryptor)
}
pub struct Decryptor<R> {
/// The age file.
input: R,
/// The age file's header.
header: Header,
/// The age file's AEAD nonce
nonce: Nonce,
}
impl<R> Decryptor<R> {
fn from_v1_header(input: R, header: HeaderV1, nonce: Nonce) -> Result<Self, DecryptError> {
// Enforce structural requirements on the v1 header.
let any_scrypt = header
.recipients
.iter()
.any(|r| r.tag == scrypt::SCRYPT_RECIPIENT_TAG);
if any_scrypt && header.recipients.len() == 1 {
Ok(decryptor::PassphraseDecryptor::new(input, Header::V1(header), nonce).into())
} else if !any_scrypt {
Ok(decryptor::RecipientsDecryptor::new(input, Header::V1(header), nonce).into())
if header.is_valid() {
Ok(Self {
input,
header: Header::V1(header),
nonce,
})
} else {
Err(DecryptError::InvalidHeader)
}
}
/// Returns `true` if the age file is encrypted to a passphrase.
pub fn is_scrypt(&self) -> bool {
match &self.header {
Header::V1(header) => header.valid_scrypt(),
Header::Unknown(_) => false,
}
}
fn obtain_payload_key<'a>(
&self,
mut identities: impl Iterator<Item = &'a dyn Identity>,
) -> Result<PayloadKey, DecryptError> {
match &self.header {
Header::V1(header) => identities
.find_map(|key| key.unwrap_stanzas(&header.recipients))
.unwrap_or(Err(DecryptError::NoMatchingKeys))
.and_then(|file_key| v1_payload_key(&file_key, header, &self.nonce)),
Header::Unknown(_) => unreachable!(),
}
}
}
impl<R: Read> Decryptor<R> {
@ -199,6 +234,17 @@ impl<R: Read> Decryptor<R> {
Header::Unknown(_) => Err(DecryptError::UnknownFormat),
}
}
/// Attempts to decrypt the age file.
///
/// If successful, returns a reader that will provide the plaintext.
pub fn decrypt<'a>(
self,
identities: impl Iterator<Item = &'a dyn Identity>,
) -> Result<StreamReader<R>, DecryptError> {
self.obtain_payload_key(identities)
.map(|payload_key| Stream::decrypt(payload_key, self.input))
}
}
impl<R: BufRead> Decryptor<R> {
@ -247,6 +293,17 @@ impl<R: AsyncRead + Unpin> Decryptor<R> {
Header::Unknown(_) => Err(DecryptError::UnknownFormat),
}
}
/// Attempts to decrypt the age file.
///
/// If successful, returns a reader that will provide the plaintext.
pub fn decrypt_async<'a>(
self,
identities: impl Iterator<Item = &'a dyn Identity>,
) -> Result<StreamReader<R>, DecryptError> {
self.obtain_payload_key(identities)
.map(|payload_key| Stream::decrypt_async(payload_key, self.input))
}
}
#[cfg(feature = "async")]
@ -275,17 +332,16 @@ impl<R: AsyncBufRead + Unpin> Decryptor<R> {
#[cfg(test)]
mod tests {
use age_core::secrecy::SecretString;
use std::collections::HashSet;
use std::io::{BufReader, Read, Write};
use age_core::secrecy::SecretString;
#[cfg(feature = "ssh")]
use std::iter;
use super::{Decryptor, Encryptor};
use crate::{
identity::{IdentityFile, IdentityFileEntry},
x25519, Identity, Recipient,
};
use crate::{identity::IdentityFile, scrypt, x25519, EncryptError, Identity, Recipient};
#[cfg(feature = "async")]
use futures::{
@ -298,7 +354,7 @@ mod tests {
use futures_test::task::noop_context;
fn recipient_round_trip<'a>(
recipients: Vec<Box<dyn Recipient + Send>>,
recipients: impl Iterator<Item = &'a dyn Recipient>,
identities: impl Iterator<Item = &'a dyn Identity>,
) {
let test_msg = b"This is a test message. For testing.";
@ -311,10 +367,7 @@ mod tests {
w.finish().unwrap();
}
let d = match Decryptor::new(&encrypted[..]) {
Ok(Decryptor::Recipients(d)) => d,
_ => panic!(),
};
let d = Decryptor::new(&encrypted[..]).unwrap();
let mut r = d.decrypt(identities).unwrap();
let mut decrypted = vec![];
r.read_to_end(&mut decrypted).unwrap();
@ -324,7 +377,7 @@ mod tests {
#[cfg(feature = "async")]
fn recipient_async_round_trip<'a>(
recipients: Vec<Box<dyn Recipient + Send>>,
recipients: impl Iterator<Item = &'a dyn Recipient>,
identities: impl Iterator<Item = &'a dyn Identity>,
) {
let test_msg = b"This is a test message. For testing.";
@ -365,7 +418,7 @@ mod tests {
}
}
let d = match {
let d = {
let f = Decryptor::new_async(&encrypted[..]);
pin_mut!(f);
@ -376,9 +429,6 @@ mod tests {
Poll::Pending => panic!("Unexpected Pending"),
}
}
} {
Decryptor::Recipients(d) => d,
_ => panic!(),
};
let decrypted = {
@ -406,12 +456,8 @@ mod tests {
let f = IdentityFile::from_buffer(buf).unwrap();
let pk: x25519::Recipient = crate::x25519::tests::TEST_PK.parse().unwrap();
recipient_round_trip(
vec![Box::new(pk)],
f.into_identities().iter().map(|sk| match sk {
IdentityFileEntry::Native(sk) => sk as &dyn Identity,
#[cfg(feature = "plugin")]
IdentityFileEntry::Plugin(_) => unreachable!(),
}),
iter::once(&pk as _),
f.into_identities().unwrap().iter().map(|i| i.as_ref()),
);
}
@ -422,12 +468,8 @@ mod tests {
let f = IdentityFile::from_buffer(buf).unwrap();
let pk: x25519::Recipient = crate::x25519::tests::TEST_PK.parse().unwrap();
recipient_async_round_trip(
vec![Box::new(pk)],
f.into_identities().iter().map(|sk| match sk {
IdentityFileEntry::Native(sk) => sk as &dyn Identity,
#[cfg(feature = "plugin")]
IdentityFileEntry::Plugin(_) => unreachable!(),
}),
iter::once(&pk as _),
f.into_identities().unwrap().iter().map(|i| i.as_ref()),
);
}
@ -435,20 +477,24 @@ mod tests {
fn scrypt_round_trip() {
let test_msg = b"This is a test message. For testing.";
let mut recipient = scrypt::Recipient::new(SecretString::from("passphrase".to_string()));
// Override to something very fast for testing.
recipient.set_work_factor(2);
let mut encrypted = vec![];
let e = Encryptor::with_user_passphrase(SecretString::new("passphrase".to_string()));
let e = Encryptor::with_recipients(iter::once(&recipient as _)).unwrap();
{
let mut w = e.wrap_output(&mut encrypted).unwrap();
w.write_all(test_msg).unwrap();
w.finish().unwrap();
}
let d = match Decryptor::new(&encrypted[..]) {
Ok(Decryptor::Passphrase(d)) => d,
_ => panic!(),
};
let d = Decryptor::new(&encrypted[..]).unwrap();
let mut r = d
.decrypt(&SecretString::new("passphrase".to_string()), None)
.decrypt(
Some(&scrypt::Identity::new(SecretString::from("passphrase".to_string())) as _)
.into_iter(),
)
.unwrap();
let mut decrypted = vec![];
r.read_to_end(&mut decrypted).unwrap();
@ -464,7 +510,7 @@ mod tests {
let pk: crate::ssh::Recipient = crate::ssh::recipient::tests::TEST_SSH_RSA_PK
.parse()
.unwrap();
recipient_round_trip(vec![Box::new(pk)], iter::once(&sk as &dyn Identity));
recipient_round_trip(iter::once(&pk as _), iter::once(&sk as &dyn Identity));
}
#[cfg(all(feature = "ssh", feature = "async"))]
@ -475,7 +521,7 @@ mod tests {
let pk: crate::ssh::Recipient = crate::ssh::recipient::tests::TEST_SSH_RSA_PK
.parse()
.unwrap();
recipient_async_round_trip(vec![Box::new(pk)], iter::once(&sk as &dyn Identity));
recipient_async_round_trip(iter::once(&pk as _), iter::once(&sk as &dyn Identity));
}
#[cfg(feature = "ssh")]
@ -486,7 +532,7 @@ mod tests {
let pk: crate::ssh::Recipient = crate::ssh::recipient::tests::TEST_SSH_ED25519_PK
.parse()
.unwrap();
recipient_round_trip(vec![Box::new(pk)], iter::once(&sk as &dyn Identity));
recipient_round_trip(iter::once(&pk as _), iter::once(&sk as &dyn Identity));
}
#[cfg(all(feature = "ssh", feature = "async"))]
@ -497,6 +543,47 @@ mod tests {
let pk: crate::ssh::Recipient = crate::ssh::recipient::tests::TEST_SSH_ED25519_PK
.parse()
.unwrap();
recipient_async_round_trip(vec![Box::new(pk)], iter::once(&sk as &dyn Identity));
recipient_async_round_trip(iter::once(&pk as _), iter::once(&sk as &dyn Identity));
}
#[test]
fn mixed_recipient_and_passphrase() {
let pk: x25519::Recipient = crate::x25519::tests::TEST_PK.parse().unwrap();
let passphrase =
crate::scrypt::Recipient::new(SecretString::from("passphrase".to_string()));
let recipients = [&pk as &dyn Recipient, &passphrase as _];
assert!(matches!(
Encryptor::with_recipients(recipients.into_iter()),
Err(EncryptError::MixedRecipientAndPassphrase),
));
}
struct IncompatibleRecipient(crate::x25519::Recipient);
impl Recipient for IncompatibleRecipient {
fn wrap_file_key(
&self,
file_key: &age_core::format::FileKey,
) -> Result<(Vec<age_core::format::Stanza>, HashSet<String>), EncryptError> {
self.0.wrap_file_key(file_key).map(|(stanzas, mut labels)| {
labels.insert("incompatible".into());
(stanzas, labels)
})
}
}
#[test]
fn incompatible_recipients() {
let pk: x25519::Recipient = crate::x25519::tests::TEST_PK.parse().unwrap();
let incompatible = IncompatibleRecipient(pk.clone());
let recipients = [&pk as &dyn Recipient, &incompatible as _];
assert!(matches!(
Encryptor::with_recipients(recipients.into_iter()),
Err(EncryptError::IncompatibleRecipients { .. }),
));
}
}

View file

@ -1,153 +0,0 @@
//! Decryptors for age.
use age_core::{
format::{FileKey, Stanza},
secrecy::SecretString,
};
use std::io::Read;
use super::Nonce;
use crate::{
error::DecryptError,
format::Header,
keys::v1_payload_key,
primitives::stream::{PayloadKey, Stream, StreamReader},
scrypt, Identity,
};
#[cfg(feature = "async")]
use futures::io::AsyncRead;
struct BaseDecryptor<R> {
/// The age file.
input: R,
/// The age file's header.
header: Header,
/// The age file's AEAD nonce
nonce: Nonce,
}
impl<R> BaseDecryptor<R> {
fn obtain_payload_key<F>(&self, mut filter: F) -> Result<PayloadKey, DecryptError>
where
F: FnMut(&[Stanza]) -> Option<Result<FileKey, DecryptError>>,
{
match &self.header {
Header::V1(header) => filter(&header.recipients)
.unwrap_or(Err(DecryptError::NoMatchingKeys))
.and_then(|file_key| v1_payload_key(&file_key, header, &self.nonce)),
Header::Unknown(_) => unreachable!(),
}
}
}
/// Decryptor for an age file encrypted to a list of recipients.
pub struct RecipientsDecryptor<R>(BaseDecryptor<R>);
impl<R> RecipientsDecryptor<R> {
pub(super) fn new(input: R, header: Header, nonce: Nonce) -> Self {
RecipientsDecryptor(BaseDecryptor {
input,
header,
nonce,
})
}
fn obtain_payload_key<'a>(
&self,
mut identities: impl Iterator<Item = &'a dyn Identity>,
) -> Result<PayloadKey, DecryptError> {
self.0
.obtain_payload_key(|r| identities.find_map(|key| key.unwrap_stanzas(r)))
}
}
impl<R: Read> RecipientsDecryptor<R> {
/// Attempts to decrypt the age file.
///
/// If successful, returns a reader that will provide the plaintext.
pub fn decrypt<'a>(
self,
identities: impl Iterator<Item = &'a dyn Identity>,
) -> Result<StreamReader<R>, DecryptError> {
self.obtain_payload_key(identities)
.map(|payload_key| Stream::decrypt(payload_key, self.0.input))
}
}
#[cfg(feature = "async")]
#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
impl<R: AsyncRead + Unpin> RecipientsDecryptor<R> {
/// Attempts to decrypt the age file.
///
/// If successful, returns a reader that will provide the plaintext.
pub fn decrypt_async<'a>(
self,
identities: impl Iterator<Item = &'a dyn Identity>,
) -> Result<StreamReader<R>, DecryptError> {
self.obtain_payload_key(identities)
.map(|payload_key| Stream::decrypt_async(payload_key, self.0.input))
}
}
/// Decryptor for an age file encrypted with a passphrase.
pub struct PassphraseDecryptor<R>(BaseDecryptor<R>);
impl<R> PassphraseDecryptor<R> {
pub(super) fn new(input: R, header: Header, nonce: Nonce) -> Self {
PassphraseDecryptor(BaseDecryptor {
input,
header,
nonce,
})
}
fn obtain_payload_key(
&self,
passphrase: &SecretString,
max_work_factor: Option<u8>,
) -> Result<PayloadKey, DecryptError> {
let identity = scrypt::Identity {
passphrase,
max_work_factor,
};
self.0.obtain_payload_key(|r| identity.unwrap_stanzas(r))
}
}
impl<R: Read> PassphraseDecryptor<R> {
/// Attempts to decrypt the age file.
///
/// `max_work_factor` is the maximum accepted work factor. If `None`, the default
/// maximum is adjusted to around 16 seconds of work.
///
/// If successful, returns a reader that will provide the plaintext.
pub fn decrypt(
self,
passphrase: &SecretString,
max_work_factor: Option<u8>,
) -> Result<StreamReader<R>, DecryptError> {
self.obtain_payload_key(passphrase, max_work_factor)
.map(|payload_key| Stream::decrypt(payload_key, self.0.input))
}
}
#[cfg(feature = "async")]
#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
impl<R: AsyncRead + Unpin> PassphraseDecryptor<R> {
/// Attempts to decrypt the age file.
///
/// `max_work_factor` is the maximum accepted work factor. If `None`, the default
/// maximum is adjusted to around 16 seconds of work.
///
/// If successful, returns a reader that will provide the plaintext.
pub fn decrypt_async(
self,
passphrase: &SecretString,
max_work_factor: Option<u8>,
) -> Result<StreamReader<R>, DecryptError> {
self.obtain_payload_key(passphrase, max_work_factor)
.map(|payload_key| Stream::decrypt_async(payload_key, self.0.input))
}
}

View file

@ -1,11 +1,20 @@
//! The "scrypt" passphrase-based recipient type, native to age.
use std::collections::HashSet;
use std::iter;
use std::time::Duration;
use age_core::{
format::{FileKey, Stanza, FILE_KEY_BYTES},
primitives::{aead_decrypt, aead_encrypt},
secrecy::{ExposeSecret, SecretString},
};
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
use rand::{rngs::OsRng, RngCore};
use std::time::Duration;
use rand::{
distributions::{Alphanumeric, DistString},
rngs::OsRng,
RngCore,
};
use zeroize::Zeroize;
use crate::{
@ -83,41 +92,121 @@ fn target_scrypt_work_factor() -> u8 {
})
}
pub(crate) struct Recipient {
pub(crate) passphrase: SecretString,
/// A passphrase-based recipient. Anyone with the passphrase can decrypt the file.
///
/// If an `scrypt::Recipient` is used, it must be the only recipient for the file: it
/// can't be mixed with other recipient types and can't be used multiple times for the
/// same file.
///
/// This API should only be used with a passphrase that was provided by (or generated
/// for) a human. For programmatic use cases, instead generate an [`x25519::Identity`].
///
/// [`x25519::Identity`]: crate::x25519::Identity
pub struct Recipient {
passphrase: SecretString,
log_n: u8,
}
impl Recipient {
/// Constructs a new `Recipient` with the given passphrase.
///
/// The scrypt work factor is picked to target about 1 second for encryption or
/// decryption on this device. Override it with [`Self::set_work_factor`].
pub fn new(passphrase: SecretString) -> Self {
Self {
passphrase,
log_n: target_scrypt_work_factor(),
}
}
/// Sets the scrypt work factor to `N = 2^log_n`.
///
/// This method must be called before [`Self::wrap_file_key`] to have an effect.
///
/// [`Self::wrap_file_key`]: crate::Recipient::wrap_file_key
///
/// # Panics
///
/// Panics if `log_n == 0` or `log_n >= 64`.
pub fn set_work_factor(&mut self, log_n: u8) {
assert!(0 < log_n && log_n < 64);
self.log_n = log_n;
}
}
impl crate::Recipient for Recipient {
fn wrap_file_key(&self, file_key: &FileKey) -> Result<Vec<Stanza>, EncryptError> {
fn wrap_file_key(
&self,
file_key: &FileKey,
) -> Result<(Vec<Stanza>, HashSet<String>), EncryptError> {
let mut rng = OsRng;
let mut salt = [0; SALT_LEN];
OsRng.fill_bytes(&mut salt);
rng.fill_bytes(&mut salt);
let mut inner_salt = [0; SCRYPT_SALT_LABEL.len() + SALT_LEN];
inner_salt[..SCRYPT_SALT_LABEL.len()].copy_from_slice(SCRYPT_SALT_LABEL);
inner_salt[SCRYPT_SALT_LABEL.len()..].copy_from_slice(&salt);
let log_n = target_scrypt_work_factor();
let enc_key =
scrypt(&inner_salt, log_n, self.passphrase.expose_secret()).expect("log_n < 64");
scrypt(&inner_salt, self.log_n, self.passphrase.expose_secret()).expect("log_n < 64");
let encrypted_file_key = aead_encrypt(&enc_key, file_key.expose_secret());
let encoded_salt = BASE64_STANDARD_NO_PAD.encode(salt);
Ok(vec![Stanza {
tag: SCRYPT_RECIPIENT_TAG.to_owned(),
args: vec![encoded_salt, format!("{}", log_n)],
body: encrypted_file_key,
}])
let label = Alphanumeric.sample_string(&mut rng, 32);
Ok((
vec![Stanza {
tag: SCRYPT_RECIPIENT_TAG.to_owned(),
args: vec![encoded_salt, format!("{}", self.log_n)],
body: encrypted_file_key,
}],
iter::once(label).collect(),
))
}
}
pub(crate) struct Identity<'a> {
pub(crate) passphrase: &'a SecretString,
pub(crate) max_work_factor: Option<u8>,
/// A passphrase-based identity. Anyone with the passphrase can decrypt the file.
///
/// The identity caps the amount of work that the [`Decryptor`] might have to do to
/// process received files. A fairly high default is used (targeting roughly 16 seconds of
/// work per stanza on the current machine), which might not be suitable for systems
/// processing untrusted files.
///
/// [`Decryptor`]: crate::Decryptor
pub struct Identity {
passphrase: SecretString,
target_work_factor: u8,
max_work_factor: u8,
}
impl<'a> crate::Identity for Identity<'a> {
impl Identity {
/// Constructs a new `Identity` with the given passphrase.
pub fn new(passphrase: SecretString) -> Self {
let target_work_factor = target_scrypt_work_factor();
// Place bounds on the work factor we will accept (roughly 16 seconds).
let max_work_factor = target_work_factor + 4;
Self {
passphrase,
target_work_factor,
max_work_factor,
}
}
/// Sets the maximum accepted scrypt work factor to `N = 2^max_log_n`.
///
/// This method must be called before [`Self::unwrap_stanza`] to have an effect.
///
/// [`Self::unwrap_stanza`]: crate::Identity::unwrap_stanza
pub fn set_max_work_factor(&mut self, max_log_n: u8) {
self.max_work_factor = max_log_n;
}
}
impl crate::Identity for Identity {
fn unwrap_stanza(&self, stanza: &Stanza) -> Option<Result<FileKey, DecryptError>> {
if stanza.tag != SCRYPT_RECIPIENT_TAG {
return None;
@ -139,12 +228,10 @@ impl<'a> crate::Identity for Identity<'a> {
return Some(Err(DecryptError::InvalidHeader));
}
// Place bounds on the work factor we will accept (roughly 16 seconds).
let target = target_scrypt_work_factor();
if log_n > self.max_work_factor.unwrap_or(target + 4) {
if log_n > self.max_work_factor {
return Some(Err(DecryptError::ExcessiveWork {
required: log_n,
target,
target: self.target_work_factor,
}));
}
@ -157,7 +244,7 @@ impl<'a> crate::Identity for Identity<'a> {
Err(_) => {
return Some(Err(DecryptError::ExcessiveWork {
required: log_n,
target,
target: self.target_work_factor,
}));
}
};
@ -173,9 +260,10 @@ impl<'a> crate::Identity for Identity<'a> {
aead_decrypt(&enc_key, FILE_KEY_BYTES, &stanza.body)
.map(|mut pt| {
// It's ours!
let file_key: [u8; FILE_KEY_BYTES] = pt[..].try_into().unwrap();
pt.zeroize();
file_key.into()
FileKey::init_with_mut(|file_key| {
file_key.copy_from_slice(&pt);
pt.zeroize();
})
})
.map_err(DecryptError::from),
)

107
age/src/simple.rs Normal file
View file

@ -0,0 +1,107 @@
use std::io::{Read, Write};
use std::iter;
use crate::{
error::{DecryptError, EncryptError},
Decryptor, Encryptor, Identity, Recipient,
};
#[cfg(feature = "armor")]
use crate::armor::{ArmoredReader, ArmoredWriter, Format};
/// Encrypts the given plaintext to the given recipient.
///
/// To encrypt to more than one recipient, use [`Encryptor::with_recipients`].
///
/// This function returns binary ciphertext. To obtain an ASCII-armored text string, use
/// [`encrypt_and_armor`].
pub fn encrypt(recipient: &impl Recipient, plaintext: &[u8]) -> Result<Vec<u8>, EncryptError> {
let encryptor =
Encryptor::with_recipients(iter::once(recipient as _)).expect("we provided a recipient");
let mut ciphertext = Vec::with_capacity(plaintext.len());
let mut writer = encryptor.wrap_output(&mut ciphertext)?;
writer.write_all(plaintext)?;
writer.finish()?;
Ok(ciphertext)
}
/// Encrypts the given plaintext to the given recipient, and wraps the ciphertext in ASCII
/// armor.
///
/// To encrypt to more than one recipient, use [`Encryptor::with_recipients`] along with
/// [`ArmoredWriter`].
#[cfg(feature = "armor")]
#[cfg_attr(docsrs, doc(cfg(feature = "armor")))]
pub fn encrypt_and_armor(
recipient: &impl Recipient,
plaintext: &[u8],
) -> Result<String, EncryptError> {
let encryptor =
Encryptor::with_recipients(iter::once(recipient as _)).expect("we provided a recipient");
let mut ciphertext = Vec::with_capacity(plaintext.len());
let mut writer = encryptor.wrap_output(ArmoredWriter::wrap_output(
&mut ciphertext,
Format::AsciiArmor,
)?)?;
writer.write_all(plaintext)?;
writer.finish()?.finish()?;
Ok(String::from_utf8(ciphertext).expect("is armored"))
}
/// Decrypts the given ciphertext with the given identity.
///
/// If the `armor` feature flag is enabled, this will also handle armored age ciphertexts.
///
/// To attempt decryption with more than one identity, use [`Decryptor`] (as well as
/// [`ArmoredReader`] if the `armor` feature flag is enabled).
pub fn decrypt(identity: &impl Identity, ciphertext: &[u8]) -> Result<Vec<u8>, DecryptError> {
#[cfg(feature = "armor")]
let decryptor = Decryptor::new_buffered(ArmoredReader::new(ciphertext))?;
#[cfg(not(feature = "armor"))]
let decryptor = Decryptor::new_buffered(ciphertext)?;
let mut plaintext = vec![];
let mut reader = decryptor.decrypt(iter::once(identity as _))?;
reader.read_to_end(&mut plaintext)?;
Ok(plaintext)
}
#[cfg(test)]
mod tests {
use super::{decrypt, encrypt};
use crate::x25519;
#[cfg(feature = "armor")]
use super::encrypt_and_armor;
#[test]
fn x25519_round_trip() {
let sk: x25519::Identity = crate::x25519::tests::TEST_SK.parse().unwrap();
let pk: x25519::Recipient = crate::x25519::tests::TEST_PK.parse().unwrap();
let test_msg = b"This is a test message. For testing.";
let encrypted = encrypt(&pk, test_msg).unwrap();
let decrypted = decrypt(&sk, &encrypted).unwrap();
assert_eq!(&decrypted[..], &test_msg[..]);
}
#[cfg(feature = "armor")]
#[test]
fn x25519_round_trip_armor() {
let sk: x25519::Identity = crate::x25519::tests::TEST_SK.parse().unwrap();
let pk: x25519::Recipient = crate::x25519::tests::TEST_PK.parse().unwrap();
let test_msg = b"This is a test message. For testing.";
let encrypted = encrypt_and_armor(&pk, test_msg).unwrap();
assert!(encrypted.starts_with("-----BEGIN AGE ENCRYPTED FILE-----"));
let decrypted = decrypt(&sk, encrypted.as_bytes()).unwrap();
assert_eq!(&decrypted[..], &test_msg[..]);
}
}

View file

@ -194,7 +194,7 @@ mod decrypt {
}
mod read_ssh {
use age_core::secrecy::Secret;
use age_core::secrecy::SecretBox;
use curve25519_dalek::edwards::{CompressedEdwardsY, EdwardsPoint};
use nom::{
branch::alt,
@ -349,14 +349,14 @@ mod read_ssh {
/// Internal OpenSSH encoding of an Ed25519 private key.
///
/// - [OpenSSH serialization code](https://github.com/openssh/openssh-portable/blob/4103a3ec7c68493dbc4f0994a229507e943a86d3/sshkey.c#L3277-L3283)
fn openssh_ed25519_privkey(input: &[u8]) -> IResult<&[u8], Secret<[u8; 64]>> {
fn openssh_ed25519_privkey(input: &[u8]) -> IResult<&[u8], SecretBox<[u8; 64]>> {
delimited(
string_tag(SSH_ED25519_KEY_PREFIX),
map_opt(tuple((string, string)), |(pubkey_bytes, privkey_bytes)| {
if privkey_bytes.len() == 64 && pubkey_bytes == &privkey_bytes[32..64] {
let mut privkey = [0; 64];
let mut privkey = Box::new([0; 64]);
privkey.copy_from_slice(privkey_bytes);
Some(Secret::new(privkey))
Some(SecretBox::new(privkey))
} else {
None
}

View file

@ -1,7 +1,7 @@
use age_core::{
format::{FileKey, Stanza, FILE_KEY_BYTES},
primitives::{aead_decrypt, hkdf},
secrecy::{ExposeSecret, Secret},
secrecy::{ExposeSecret, SecretBox},
};
use base64::prelude::BASE64_STANDARD;
use nom::{
@ -32,12 +32,27 @@ use crate::{
};
/// An SSH private key for decrypting an age file.
#[derive(Clone)]
pub enum UnencryptedKey {
/// An ssh-rsa private key.
SshRsa(Vec<u8>, Box<rsa::RsaPrivateKey>),
/// An ssh-ed25519 key pair.
SshEd25519(Vec<u8>, Secret<[u8; 64]>),
SshEd25519(Vec<u8>, SecretBox<[u8; 64]>),
}
impl Clone for UnencryptedKey {
fn clone(&self) -> Self {
match self {
Self::SshRsa(ssh_key, sk) => Self::SshRsa(ssh_key.clone(), sk.clone()),
Self::SshEd25519(ssh_key, privkey) => Self::SshEd25519(
ssh_key.clone(),
SecretBox::new({
let mut cloned_privkey = Box::new([0; 64]);
cloned_privkey.copy_from_slice(privkey.expose_secret());
cloned_privkey
}),
),
}
}
}
impl UnencryptedKey {
@ -64,11 +79,18 @@ impl UnencryptedKey {
&stanza.body,
)
.map_err(DecryptError::from)
.map(|mut pt| {
.and_then(|mut pt| {
// It's ours!
let file_key: [u8; 16] = pt[..].try_into().unwrap();
pt.zeroize();
file_key.into()
FileKey::try_init_with_mut(|file_key| {
let ret = if pt.len() == file_key.len() {
file_key.copy_from_slice(&pt);
Ok(())
} else {
Err(DecryptError::DecryptionFailed)
};
pt.zeroize();
ret
})
}),
)
}
@ -115,9 +137,10 @@ impl UnencryptedKey {
.map_err(DecryptError::from)
.map(|mut pt| {
// It's ours!
let file_key: [u8; FILE_KEY_BYTES] = pt[..].try_into().unwrap();
pt.zeroize();
file_key.into()
FileKey::init_with_mut(|file_key| {
file_key.copy_from_slice(&pt);
pt.zeroize();
})
}),
)
}
@ -354,7 +377,10 @@ pub(crate) fn ssh_identity(input: &str) -> IResult<&str, Identity> {
#[cfg(test)]
pub(crate) mod tests {
use age_core::secrecy::{ExposeSecret, SecretString};
use age_core::{
format::FileKey,
secrecy::{ExposeSecret, SecretString},
};
use std::io::BufReader;
use super::{Identity, UnsupportedKey};
@ -491,7 +517,7 @@ AwQFBg==
}
fn request_passphrase(&self, _: &str) -> Option<SecretString> {
Some(SecretString::new(self.0.to_owned()))
Some(SecretString::from(self.0.to_owned()))
}
}
@ -505,9 +531,10 @@ AwQFBg==
};
let pk: Recipient = TEST_SSH_RSA_PK.parse().unwrap();
let file_key = [12; 16].into();
let file_key = FileKey::new(Box::new([12; 16]));
let wrapped = pk.wrap_file_key(&file_key).unwrap();
let (wrapped, labels) = pk.wrap_file_key(&file_key).unwrap();
assert!(labels.is_empty());
let unwrapped = identity.unwrap_stanzas(&wrapped);
assert_eq!(
unwrapped.unwrap().unwrap().expose_secret(),
@ -531,9 +558,10 @@ AwQFBg==
let identity = identity.with_callbacks(TestPassphrase("passphrase"));
let pk: Recipient = TEST_SSH_ED25519_PK.parse().unwrap();
let file_key = [12; 16].into();
let file_key = FileKey::new(Box::new([12; 16]));
let wrapped = pk.wrap_file_key(&file_key).unwrap();
let (wrapped, labels) = pk.wrap_file_key(&file_key).unwrap();
assert!(labels.is_empty());
let unwrapped = identity.unwrap_stanzas(&wrapped);
assert_eq!(
unwrapped.unwrap().unwrap().expose_secret(),

View file

@ -1,3 +1,6 @@
use std::collections::HashSet;
use std::fmt;
use age_core::{
format::{FileKey, Stanza},
primitives::{aead_encrypt, hkdf},
@ -18,7 +21,6 @@ use nom::{
use rand::rngs::OsRng;
use rsa::{traits::PublicKeyParts, Oaep};
use sha2::Sha256;
use std::fmt;
use x25519_dalek::{EphemeralSecret, PublicKey as X25519PublicKey, StaticSecret};
use super::{
@ -144,10 +146,13 @@ impl TryFrom<Identity> for Recipient {
}
impl crate::Recipient for Recipient {
fn wrap_file_key(&self, file_key: &FileKey) -> Result<Vec<Stanza>, EncryptError> {
fn wrap_file_key(
&self,
file_key: &FileKey,
) -> Result<(Vec<Stanza>, HashSet<String>), EncryptError> {
let mut rng = OsRng;
match self {
let stanzas = match self {
Recipient::SshRsa(ssh_key, pk) => {
let encrypted_file_key = pk
.encrypt(
@ -159,11 +164,11 @@ impl crate::Recipient for Recipient {
let encoded_tag = BASE64_STANDARD_NO_PAD.encode(ssh_tag(ssh_key));
Ok(vec![Stanza {
vec![Stanza {
tag: SSH_RSA_RECIPIENT_TAG.to_owned(),
args: vec![encoded_tag],
body: encrypted_file_key,
}])
}]
}
Recipient::SshEd25519(ssh_key, ed25519_pk) => {
let pk: X25519PublicKey = ed25519_pk.to_montgomery().to_bytes().into();
@ -190,13 +195,15 @@ impl crate::Recipient for Recipient {
let encoded_tag = BASE64_STANDARD_NO_PAD.encode(ssh_tag(ssh_key));
let encoded_epk = BASE64_STANDARD_NO_PAD.encode(epk.as_bytes());
Ok(vec![Stanza {
vec![Stanza {
tag: SSH_ED25519_RECIPIENT_TAG.to_owned(),
args: vec![encoded_tag, encoded_epk],
body: encrypted_file_key,
}])
}]
}
}
};
Ok((stanzas, HashSet::new()))
}
}

View file

@ -1,5 +1,8 @@
//! The "x25519" recipient type, native to age.
use std::collections::HashSet;
use std::fmt;
use age_core::{
format::{FileKey, Stanza, FILE_KEY_BYTES},
primitives::{aead_decrypt, aead_encrypt, hkdf},
@ -8,7 +11,6 @@ use age_core::{
use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
use bech32::{ToBase32, Variant};
use rand::rngs::OsRng;
use std::fmt;
use subtle::ConstantTimeEq;
use x25519_dalek::{EphemeralSecret, PublicKey, StaticSecret};
use zeroize::Zeroize;
@ -66,7 +68,7 @@ impl Identity {
let sk_base32 = sk_bytes.to_base32();
let mut encoded =
bech32::encode(SECRET_KEY_PREFIX, sk_base32, Variant::Bech32).expect("HRP is valid");
let ret = SecretString::new(encoded.to_uppercase());
let ret = SecretString::from(encoded.to_uppercase());
// Clear intermediates
sk_bytes.zeroize();
@ -134,9 +136,10 @@ impl crate::Identity for Identity {
.ok()
.map(|mut pt| {
// It's ours!
let file_key: [u8; FILE_KEY_BYTES] = pt[..].try_into().unwrap();
pt.zeroize();
Ok(file_key.into())
Ok(FileKey::init_with_mut(|file_key| {
file_key.copy_from_slice(&pt);
pt.zeroize();
}))
})
}
}
@ -191,7 +194,10 @@ impl fmt::Debug for Recipient {
}
impl crate::Recipient for Recipient {
fn wrap_file_key(&self, file_key: &FileKey) -> Result<Vec<Stanza>, EncryptError> {
fn wrap_file_key(
&self,
file_key: &FileKey,
) -> Result<(Vec<Stanza>, HashSet<String>), EncryptError> {
let rng = OsRng;
let esk = EphemeralSecret::random_from_rng(rng);
let epk: PublicKey = (&esk).into();
@ -220,17 +226,20 @@ impl crate::Recipient for Recipient {
let encoded_epk = BASE64_STANDARD_NO_PAD.encode(epk.as_bytes());
Ok(vec![Stanza {
tag: X25519_RECIPIENT_TAG.to_owned(),
args: vec![encoded_epk],
body: encrypted_file_key,
}])
Ok((
vec![Stanza {
tag: X25519_RECIPIENT_TAG.to_owned(),
args: vec![encoded_epk],
body: encrypted_file_key,
}],
HashSet::new(),
))
}
}
#[cfg(test)]
pub(crate) mod tests {
use age_core::secrecy::ExposeSecret;
use age_core::{format::FileKey, secrecy::ExposeSecret};
use proptest::prelude::*;
use x25519_dalek::{PublicKey, StaticSecret};
@ -257,18 +266,20 @@ pub(crate) mod tests {
proptest! {
#[test]
fn wrap_and_unwrap(sk_bytes in proptest::collection::vec(any::<u8>(), ..=32)) {
let file_key = [7; 16].into();
let file_key = FileKey::new(Box::new([7; 16]));
let sk = {
let mut tmp = [0; 32];
tmp[..sk_bytes.len()].copy_from_slice(&sk_bytes);
StaticSecret::from(tmp)
};
let stanzas = Recipient(PublicKey::from(&sk))
let res = Recipient(PublicKey::from(&sk))
.wrap_file_key(&file_key);
prop_assert!(stanzas.is_ok());
prop_assert!(res.is_ok());
let (stanzas, labels) = res.unwrap();
prop_assert!(labels.is_empty());
let res = Identity(sk).unwrap_stanzas(&stanzas.unwrap());
let res = Identity(sk).unwrap_stanzas(&stanzas);
prop_assert!(res.is_some());
let res = res.unwrap();
prop_assert!(res.is_ok());

View file

@ -1,7 +1,9 @@
use age_core::secrecy::SecretString;
use std::fs;
use std::io::Read;
use age::scrypt;
use age_core::secrecy::SecretString;
#[test]
#[cfg(feature = "cli-common")]
fn age_test_vectors() -> Result<(), Box<dyn std::error::Error>> {
@ -22,30 +24,29 @@ fn age_test_vectors() -> Result<(), Box<dyn std::error::Error>> {
let name = path.file_stem().unwrap().to_str().unwrap();
let expect_failure = name.starts_with("fail_");
let res = match age::Decryptor::new(fs::File::open(&path)?)? {
age::Decryptor::Recipients(d) => {
let identities = age::cli_common::read_identities(
vec![format!(
"{}/{}_key.txt",
path.parent().unwrap().to_str().unwrap(),
name
)],
None,
&mut StdinGuard::new(false),
)?;
d.decrypt(identities.iter().map(|i| i.as_ref() as &dyn age::Identity))
}
age::Decryptor::Passphrase(d) => {
let mut passphrase = String::new();
fs::File::open(format!(
"{}/{}_password.txt",
let d = age::Decryptor::new(fs::File::open(&path)?)?;
let res = if !d.is_scrypt() {
let identities = age::cli_common::read_identities(
vec![format!(
"{}/{}_key.txt",
path.parent().unwrap().to_str().unwrap(),
name
))?
.read_to_string(&mut passphrase)?;
let passphrase = SecretString::new(passphrase);
d.decrypt(&passphrase, None)
}
)],
None,
&mut StdinGuard::new(false),
)?;
d.decrypt(identities.iter().map(|i| i.as_ref() as &dyn age::Identity))
} else {
let mut passphrase = String::new();
fs::File::open(format!(
"{}/{}_password.txt",
path.parent().unwrap().to_str().unwrap(),
name
))?
.read_to_string(&mut passphrase)?;
let passphrase = SecretString::from(passphrase);
let identity = scrypt::Identity::new(passphrase);
d.decrypt(Some(&identity as _).into_iter())
};
match (res, expect_failure) {

View file

@ -6,6 +6,7 @@ use std::{
use age::{
armor::{ArmoredReadError, ArmoredReader},
scrypt,
secrecy::SecretString,
x25519, DecryptError, Decryptor, Identity,
};
@ -131,14 +132,15 @@ fn testkit(filename: &str) {
let testfile = TestFile::parse(filename);
let comment = format_testkit_comment(&testfile);
match Decryptor::new(ArmoredReader::new(&testfile.age_file[..])).and_then(|d| match d {
Decryptor::Recipients(d) => {
match Decryptor::new(ArmoredReader::new(&testfile.age_file[..])).and_then(|d| {
if !d.is_scrypt() {
let identities = get_testkit_identities(filename, &testfile);
d.decrypt(identities.iter().map(|i| i as &dyn Identity))
}
Decryptor::Passphrase(d) => {
} else {
let passphrase = get_testkit_passphrase(&testfile, &comment);
d.decrypt(&passphrase, Some(16))
let mut identity = scrypt::Identity::new(passphrase);
identity.set_max_work_factor(16);
d.decrypt(Some(&identity as _).into_iter())
}
}) {
Ok(mut r) => {
@ -268,18 +270,17 @@ fn testkit_buffered(filename: &str) {
let testfile = TestFile::parse(filename);
let comment = format_testkit_comment(&testfile);
match Decryptor::new_buffered(ArmoredReader::new(&testfile.age_file[..])).and_then(
|d| match d {
Decryptor::Recipients(d) => {
let identities = get_testkit_identities(filename, &testfile);
d.decrypt(identities.iter().map(|i| i as &dyn Identity))
}
Decryptor::Passphrase(d) => {
let passphrase = get_testkit_passphrase(&testfile, &comment);
d.decrypt(&passphrase, Some(16))
}
},
) {
match Decryptor::new_buffered(ArmoredReader::new(&testfile.age_file[..])).and_then(|d| {
if !d.is_scrypt() {
let identities = get_testkit_identities(filename, &testfile);
d.decrypt(identities.iter().map(|i| i as &dyn Identity))
} else {
let passphrase = get_testkit_passphrase(&testfile, &comment);
let mut identity = scrypt::Identity::new(passphrase);
identity.set_max_work_factor(16);
d.decrypt(Some(&identity as _).into_iter())
}
}) {
Ok(mut r) => {
let mut payload = vec![];
let res = io::Read::read_to_end(&mut r, &mut payload);
@ -410,14 +411,15 @@ async fn testkit_async(filename: &str) {
match Decryptor::new_async(ArmoredReader::from_async_reader(&testfile.age_file[..]))
.await
.and_then(|d| match d {
Decryptor::Recipients(d) => {
.and_then(|d| {
if !d.is_scrypt() {
let identities = get_testkit_identities(filename, &testfile);
d.decrypt_async(identities.iter().map(|i| i as &dyn Identity))
}
Decryptor::Passphrase(d) => {
} else {
let passphrase = get_testkit_passphrase(&testfile, &comment);
d.decrypt_async(&passphrase, Some(16))
let mut identity = scrypt::Identity::new(passphrase);
identity.set_max_work_factor(16);
d.decrypt_async(Some(&identity as _).into_iter())
}
}) {
Ok(mut r) => {
@ -550,14 +552,15 @@ async fn testkit_async_buffered(filename: &str) {
match Decryptor::new_async_buffered(ArmoredReader::from_async_reader(&testfile.age_file[..]))
.await
.and_then(|d| match d {
Decryptor::Recipients(d) => {
.and_then(|d| {
if !d.is_scrypt() {
let identities = get_testkit_identities(filename, &testfile);
d.decrypt_async(identities.iter().map(|i| i as &dyn Identity))
}
Decryptor::Passphrase(d) => {
} else {
let passphrase = get_testkit_passphrase(&testfile, &comment);
d.decrypt_async(&passphrase, Some(16))
let mut identity = scrypt::Identity::new(passphrase);
identity.set_max_work_factor(16);
d.decrypt_async(Some(&identity as _).into_iter())
}
}) {
Ok(mut r) => {

753
fuzz-afl/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@ publish = false
edition = "2018"
[dependencies]
afl = "0.8"
afl = "0.15"
age = { path = "../age" }
# Prevent this from interfering with workspaces

628
fuzz/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -7,12 +7,6 @@ use age::Decryptor;
fuzz_target!(|data: &[u8]| {
if let Ok(decryptor) = Decryptor::new(data) {
match decryptor {
Decryptor::Recipients(d) => {
let _ = d.decrypt(iter::empty());
}
// Don't pay the cost of scrypt while fuzzing.
Decryptor::Passphrase(_) => (),
}
let _ = decryptor.decrypt(iter::empty());
}
});

View file

@ -7,12 +7,6 @@ use age::Decryptor;
fuzz_target!(|data: &[u8]| {
if let Ok(decryptor) = Decryptor::new_buffered(data) {
match decryptor {
Decryptor::Recipients(d) => {
let _ = d.decrypt(iter::empty());
}
// Don't pay the cost of scrypt while fuzzing.
Decryptor::Passphrase(_) => (),
}
let _ = decryptor.decrypt(iter::empty());
}
});

View file

@ -10,13 +10,20 @@ to 1.0.0 are beta releases.
## [Unreleased]
## [0.6.1, 0.7.2, 0.8.2, 0.9.3, 0.10.1] - 2024-11-18
## [0.6.1, 0.7.2, 0.8.2, 0.9.3, 0.10.1, 0.11.1] - 2024-12-18
### Security
- The age plugin protocol previously allowed plugin names that could be
interpreted as file paths. Under certain conditions, this could lead to a
different binary being executed as an age plugin than intended. Plugin names
are now required to only contain alphanumeric characters or the four special
characters `+-._`.
- Fixed a security vulnerability that could allow an attacker to execute an
arbitrary binary under certain conditions. See GHSA-4fg7-vxc8-qx5w. Plugin
names are now required to only contain alphanumeric characters or the four
special characters `+-._`. Thanks to ⬡-49016 for reporting this issue.
## [0.11.0] - 2024-11-03
### Added
- Partial French translation!
### Fixed
- [Unix] Files can now be encrypted with `rage --passphrase` when piped over
stdin, without requiring an explicit `-` argument as `INPUT`.
## [0.10.0] - 2024-02-04
### Added

View file

@ -1,7 +1,7 @@
[package]
name = "rage"
description = "[BETA] A simple, secure, and modern encryption tool."
version = "0.10.1"
version = "0.11.1"
authors.workspace = true
repository.workspace = true
readme = "../README.md"

View file

@ -146,14 +146,6 @@ err-ux-B = Tell us
# Put (len(A) - len(B) - 32) spaces here.
err-ux-C = {" "}
## Keygen errors
err-identity-file-contains-plugin = Identity file '{$filename}' contains identities for '{-age-plugin-}{$plugin_name}'.
rec-identity-file-contains-plugin = Try using '{-age-plugin-}{$plugin_name}' to convert this identity to a recipient.
err-no-identities-in-file = No identities found in file '{$filename}'.
err-no-identities-in-stdin = No identities found in standard input.
## Encryption errors
err-enc-broken-stdout = Could not write to stdout: {$err}
@ -161,7 +153,6 @@ rec-enc-broken-stdout = Are you piping to a program that isn't reading from stdi
err-enc-broken-file = Could not write to file: {$err}
err-enc-missing-recipients = Missing recipients.
rec-enc-missing-recipients = Did you forget to specify {-flag-recipient}?
err-enc-mixed-identity-passphrase = {-flag-identity} can't be used with {-flag-passphrase}.

View file

@ -120,7 +120,6 @@ rec-enc-broken-stdout = Estás enviando por pipe a un programa que no está leye
err-enc-broken-file = No se pudo escribir al archivo: {$err}
err-enc-missing-recipients = No se encontraron destinatarios.
rec-enc-missing-recipients = ¿Te olvidaste de especificar {-flag-recipient}?
err-enc-mixed-identity-passphrase = {-flag-identity} no puede ser usado con {-flag-passphrase}.

505
rage/i18n/fr/rage.ftl Normal file
View file

@ -0,0 +1,505 @@
# Copyright 2020 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 the rage CLI tools
## Terms (not to be localized)
-age = age
-age-plugin- = age-plugin-
-rage = rage
-rage-keygen = rage-keygen
-stdin = "-"
-recipient-prefix = age1
-identity-prefix = AGE-SECRET-KEY-1
-armor-pem-type = AGE ENCRYPTED FILE
-rage-mount = rage-mount
-ssh-rsa = ssh-rsa
-ssh-ed25519 = ssh-ed25519
-ssh-authorized-keys = authorized_keys
-dot-keys = .keys
-ssh = ssh(1)
-authorized-keys-file-format = AUTHORIZED_KEYS FILE FORMAT
-sshd = sshd(8)
-ssh-agent = ssh-agent(1)
-example = example
-example-r = age1example1
-example-i = AGE-PLUGIN-EXAMPLE-1
-yubikey = yubikey
## CLI flags (not to be localized)
-flag-armor = -a/--armor
-flag-decrypt = -d/--decrypt
-flag-encrypt = -e/--encrypt
-flag-identity = -i/--identity
-flag-output = -o/--output
-flag-recipient = -r/--recipient
-flag-recipients-file = -R/--recipients-file
-flag-passphrase = -p/--passphrase
-flag-plugin-name = -j
-flag-max-work-factor = --max-work-factor
-flag-unstable = --features unstable
-flag-convert = -y
-flag-mnt-types = -t/--types
## Helper variables, to be localized
# Used throughout to indicate that a flag X cannot be used with another flag Y
-cantuse = ne peut pas être utilisé avec
## Usage
usage-header = Utilisation
recipient = RECIPIENT
recipients-file = PATH
identity = IDENTITY
plugin-name = PLUGIN-NAME
input = INPUT
output = OUTPUT
args-header = Arguments
help-arg-input = Chemin vers un fichier à lire.
flags-header = Options
help-flag-help = Affiche ce message d'aide et quitte.
help-flag-version = Affiche les informations de version et quitte.
help-flag-encrypt = Chiffre l'input (l'option par défaut).
help-flag-decrypt = Déchiffre l'input.
help-flag-passphrase = Chiffre avec une phrase secrète au lieu de destinataires.
help-flag-max-work-factor = Facteur d'effort maximum à autoriser pour déchiffrer avec une phrase secrète.
help-flag-armor = Chiffre au format d'encodage PEM.
help-flag-recipient = Chiffre pour le {destinataire} spécifié. Peut être répété.
help-flag-recipients-file = Chiffre pour les destinataires listés dans le fichier {recipients-file}. Peut être répété.
help-flag-identity = Utilise le fichier d'identité {identity}. Peut être répété.
help-flag-plugin-name = Utilise {-age-plugin-}{plugin-name} dans son mode par défaut en tant qu'identité.
help-flag-output = Ecrit le résultat dans le fichier situé au chemin {output}.
rage-after-help-content =
{input} est par défaut l'entrée standard (stdin), tandis que {output} est par défaut la sortie standard (stdout).
Si {output} existe, il sera écrasé.
{recipient} peut être:
- Une clef publique {-age}, telle que générée par {$keygen_name} ({$example_age_pubkey}).
- Une clef publique SSH ({$example_ssh_pubkey}).
{recipients-file} est le chemin vers un fichier contenant des destinataires {-age}, un par ligne
(en ignorant les lignes vides et les commentaires préfixés par "#"). {-stdin} peut être utilisé
pour lire des destinataires depuis l'entrée standard.
{identity} est un chemin vers un fichier avec des identités {-age}, une par ligne
(en ignorant les lignes vides et les commentaires préfixés par "#"), ou vers un ficher de clef SSH.
Les fichiers d'identité {-age} protégé par phrase secrète peuvent être utilisé comme fichier d'identité.
Plusieurs identités peuvent être fournis, et les inutilisées seront ignorées.
{-stdin} peut être utilisé pour lire des identités depuis l'entrée standard.
rage-after-help-example =
Exemple:
{" "}{$example_a}
{" "}{tty-pubkey}: {$example_a_output}
{" "}{$example_b}
{" "}{$example_c}
keygen-help-flag-output = {help-flag-output} Par défaut, la sortie standard.
keygen-help-flag-convert = Convertit un fichier d'identité en un fichier de destinataires.
## Formatting
warning-msg = Attention: {$warning}
## Keygen messages
tty-pubkey = Clef publique
identity-file-created = créée
identity-file-pubkey = clef publique
## Encryption messages
autogenerated-passphrase = Utilisé une phrase secrète auto-générée:
type-passphrase = Ecrivez la phrase secrète
prompt-passphrase = Phrase secrète
warn-double-encrypting = Chiffrement d'un fichier déjà chiffré
## General errors
err-failed-to-open-input = Echec d'ouverture de l'entrée: {$err}
err-failed-to-open-output = Echec d'ouverture de la sortie: {$err}
err-failed-to-read-input = Echec de lecture de l'entrée: {$err}
err-failed-to-write-output = Echec d'écriture vers la sortie: {$err}
err-identity-ambiguous = {-flag-identity} nécessite {-flag-encrypt} ou {-flag-decrypt}.
err-mixed-encrypt-decrypt = {-flag-encrypt} {-cantuse} {-flag-decrypt}.
err-passphrase-timed-out = Délai dépassé lors de l'attente d'entrée de la phrase secrète.
err-same-input-and-output = L'entrée et la sortie sont le même fichier {$filename}'.
err-ux-A = Est-ce que {-rage} n'a pas fait ce que vous escomptiez ? Est-ce qu'une erreur serait plus utile ?
err-ux-B = Dites-le nous
# Put (len(A) - len(B) - 32) spaces here.
err-ux-C = {" "}
## Encryption errors
err-enc-broken-stdout = N'a pas pu écrire sur stdout: {$err}
rec-enc-broken-stdout = Etes-vous en train de piper vers programme qui ne lit pas depuis stdin ?
err-enc-broken-file = N'a pas pu écrire dans le fichier: {$err}
rec-enc-missing-recipients = Avez-vous oublié de spécifier {-flag-recipient} ?
err-enc-mixed-identity-passphrase = {-flag-identity} {-cantuse} {-flag-passphrase}.
err-enc-mixed-recipient-passphrase = {-flag-recipient} {-cantuse} {-flag-passphrase}.
err-enc-mixed-recipients-file-passphrase = {-flag-recipients-file} {-cantuse} {-flag-passphrase}.
err-enc-passphrase-without-file = Un fichier à chiffrer doit être passé en argument lors de l'utilisation de {-flag-passphrase}.
err-enc-plugin-name-flag = {-flag-plugin-name} {-cantuse} {-flag-encrypt}.
## Decryption errors
err-detected-powershell-corruption = Il semblerait que ce fichier ait été corrompu par une redirection PowerShell.
rec-detected-powershell-corruption = Essayez d'utiliser {-flag-output} ou {-flag-armor} pour chiffrer des fichiers dans PowerShell.
rec-dec-excessive-work = Pour déchiffrer, réessayez avec {-flag-max-work-factor} {$wf}
err-dec-armor-flag = {-flag-armor} {-cantuse} {-flag-decrypt}.
rec-dec-armor-flag = Note that armored files are detected automatically.
err-dec-missing-identities = Identités manquantes.
rec-dec-missing-identities = Avez-vous oublié de spécifier {-flag-identity} ?
rec-dec-missing-identities-stdin = Avez-vous oublié de fournir une identité via l'entrée standard ?
err-dec-mixed-identity-passphrase = {-flag-identity} {-cantuse} des fichiers chiffrés avec une phrase secrète.
err-mixed-identity-and-plugin-name = {-flag-identity} {-cantuse} {-flag-plugin-name}.
err-dec-passphrase-flag = {-flag-passphrase} {-cantuse} {-flag-decrypt}.
rec-dec-passphrase-flag = Notez que les fichiers chiffrés avec une phrase secrète sont détectés automatiquement.
err-dec-passphrase-without-file-win =
Ce fichier requière une phrase secrète, et, sur Windows,
le fichier à déchiffrer doit être passé en tant qu'argument
positionnel pour déchiffrer avec une phrase secrète.
err-dec-recipient-flag = {-flag-recipient} {-cantuse} {-flag-decrypt}.
err-dec-recipients-file-flag = {-flag-recipients-file} {-cantuse} {-flag-decrypt}.
rec-dec-recipient-flag = Vouliez-vous peut-être utiliser {-flag-identity} pour spécifier une clef privée ?
## rage-mount strings
mnt-filename = FILENAME
mnt-mountpoint = MOUNTPOINT
mnt-types = TYPES
help-arg-mnt-filename = Le système de fichier chiffré à monter.
help-arg-mnt-mountpoint = Le dossier vers lequel monter le système de fichier.
help-arg-mnt-types = Indique le type de système de fichier (parmis {$types}).
info-decrypting = Déchiffrement de {$filename}
info-mounting-as-fuse = Montage en tant que système de fichier FUSE
err-mnt-missing-filename = Il manque un nom de fichier.
err-mnt-missing-mountpoint = Il manque un point de montage.
err-mnt-missing-types = Il manque le fanion {-flag-mnt-types}.
err-mnt-unknown-type = Type de système de fichier inconnu "{$fs_type}"
## Unstable features
test-unstable = Pour tester cela, il faut compiler {-rage} avec {-flag-unstable}.
## rage manpage
recipients = RECIPIENTS
identities = IDENTITIES
man-rage-about = Un outil de chiffrement simple, sécurisé et moderne.
man-rage-description =
{-rage} encrypts or decrypts {input} to {output}. The {input} argument is
optional and defaults to standard input. Only a single {input} file may be
specified. If {-flag-output} is not specified, {output} defaults to standard
output.
If {-flag-passphrase} is specified, the file is encrypted with a passphrase
requested interactively. Otherwise, it's encrypted to one or more
{recipients} specified with {-flag-recipient} or
{-flag-recipients-file}. Every recipient can decrypt the file.
In {-flag-decrypt} mode, passphrase-encrypted files are detected automatically
and the passphrase is requested interactively. Otherwise, one or more
{identities} specified with {-flag-identity} are used to decrypt the file.
{-age} encrypted files are binary and not malleable, with around 200 bytes of
overhead per recipient, plus 16 bytes every 64KiB of plaintext.
man-rage-flag-output =
Write encrypted or decrypted file to {output} instead of standard output.
If {output} already exists it will be overwritten.
If encrypting without {-flag-armor}, {-rage} will refuse to output binary to a
TTY. This can be forced by specifying {-stdin} as {output}.
man-rage-encryption-options = Encryption options
man-rage-flag-encrypt =
Encrypt {input} to {output}. This is the default.
man-rage-flag-recipient =
Encrypt to the explicitly specified {recipient}. See the
{man-rage-recipients-and-identities-heading} section for possible recipient
formats.
This option can be repeated and combined with other recipient flags,
and the file can be decrypted by all provided recipients independently.
man-rage-flag-recipients-file =
Encrypt to the {recipients} listed in the file at {recipients-file}, one per
line. Empty lines and lines starting with "#" are ignored as comments.
If {recipients-file} is {-stdin}, the recipients are read from standard
input. In this case, the {input} argument must be specified.
This option can be repeated and combined with other recipient flags,
and the file can be decrypted by all provided recipients independently.
man-rage-flag-passphrase =
Encrypt with a passphrase, requested interactively from the terminal.
{-rage} will offer to auto-generate a secure passphrase.
Cette option ne peut pas être utilisée avec d'autre fanion (flag).
man-rage-flag-armor =
Encrypt to an ASCII-only "armored" encoding.
{-age} armor is a strict version of PEM with type "{-armor-pem-type}",
canonical "strict" Base64, no headers, and no support for leading and
trailing extra data.
Decryption transparently detects and decodes ASCII armoring.
man-rage-flag-identity-encrypt =
Encrypt to the {recipients} corresponding to the {identities} listed in the
file at {identity}. This is equivalent to converting the file at {identity}
to a recipients file with '{-rage-keygen} {-flag-convert}' and then passing that to
{-flag-recipients-file}.
For the format of {identity}, see the definition of {-flag-identity} in the
{man-rage-decryption-options} section.
{-flag-encrypt} must be explicitly specified when using {-flag-identity}
in encryption mode to avoid confusion.
man-rage-flag-plugin-encrypt =
Encrypt using the data-less plugin {plugin-name}.
This is equivalent to using {-flag-identity} with a file that contains a
single plugin {identity} that encodes no plugin-specific data.
{-flag-encrypt} must be explicitly specified when using {-flag-plugin-name}
in encryption mode to avoid confusion.
man-rage-decryption-options = Decryption options
man-rage-flag-decrypt =
Decrypt {input} to {output}.
If {input} is passphrase encrypted, it will be automatically detected
and the passphrase will be requested interactively. Otherwise, the
{identities} specified with {-flag-identity} are used.
ASCII armoring is transparently detected and decoded.
man-rage-flag-identity-decrypt =
Decrypt using the {identities} at {identity}.
{identity} may be one of the following:
a. A file listing {identities} one per line. Empty lines and lines starting
with "#" are ignored as comments.
b. A passphrase encrypted age file, containing {identities} one per
line like above. The passphrase is requested interactively. Note that
passphrase-protected identity files are not necessary for most use cases,
where access to the encrypted identity file implies access to the whole
system.
c. An SSH private key file, in PKCS#1, PKCS#8, or OpenSSH format.
If the private key is password-protected, the password is requested
interactively only if the SSH identity matches the file. See the
{man-rage-ssh-keys-heading} section for more information, including
supported key types.
d. {-stdin}, causing one of the options above to be read from standard input.
In this case, the {input} argument must be specified.
This option can be repeated. Identities are tried in the order in which are
provided, and the first one matching one of the file's recipients is used.
Unused identities are ignored, but it is an error if the {input} file is
passphrase-encrypted and {-flag-identity} is specified.
man-rage-flag-plugin-decrypt =
Decrypt using the data-less plugin {plugin-name}.
This is equivalent to using {-flag-identity} with a file that contains a
single plugin {identity} that encodes no plugin-specific data.
man-rage-recipients-and-identities-heading = RECIPIENTS AND IDENTITIES
man-rage-recipients-and-identities =
{recipients} are public values, like a public key, that a file can be encrypted
to. {identities} are private values, like a private key, that allow decrypting
a file encrypted to the corresponding {recipient}.
man-rage-native-x25519-keys-heading = Native X25519 keys
man-rage-native-x25519-keys =
Native {-age} key pairs are generated with {-rage-keygen}(1), and provide small
encodings and strong encryption based on X25519. They are the recommended
recipient type for most applications.
A {recipient} encoding begins with "{-recipient-prefix}" and looks like the
following:
{" "}{$example_age_recipient}
An {identity} encoding begins with "{-identity-prefix}" and looks like the
following:
{" "}{$example_age_identity}
An encrypted file can't be linked to the native recipient it's encrypted to
without access to the corresponding identity.
man-rage-ssh-keys-heading = SSH keys
man-rage-ssh-keys =
As a convenience feature, {-rage} also supports encrypting to RSA or Ed25519
{-ssh} keys. RSA keys must be at least 2048 bits. This feature employs more
complex cryptography, and should only be used when a native key is not available
for the recipient. Note that SSH keys might not be protected long-term by the
recipient, since they are revokable when used only for authentication.
A {recipient} encoding is an SSH public key in "{-ssh-authorized-keys}" format
(see the "{-authorized-keys-file-format}" section of {-sshd}), starting with
"{-ssh-rsa}" or "{-ssh-ed25519}", like the following:
{" "}{$example_ssh_rsa}
{" "}{$example_ssh_ed25519}
The comment at the end of the line, if present, is ignored.
In recipient files passed to {-flag-recipients-file}, unsupported but valid
SSH public keys are ignored with a warning, to facilitate using
"{-ssh-authorized-keys}" or GitHub "{-dot-keys}" files. (See {man-examples-heading}.)
An {identity} is an SSH private key _file_ passed individually to
{-flag-identity}. Note that keys held on hardware tokens such as YubiKeys
or accessed via {-ssh-agent} are not supported.
An encrypted file _can_ be linked to the SSH public key it was encrypted to.
This is so that {-rage} can identify the correct SSH private key before
requesting its password, if any.
man-rage-plugins-heading = Plugins
man-rage-plugins =
{-rage} can be extended through plugins. A plugin is only loaded if a corresponding
{recipient} or {identity} is specified. (Simply decrypting a file encrypted with
a plugin will not cause it to load, for security reasons among others.)
A {recipient} for a plugin named "{-example}" starts with "{-example-r}", while an
{identity} starts with "{-example-i}". They both encode arbitrary plugin-specific
data, and are generated by the plugin.
When either is specified, {-rage} searches for {-age-plugin-}{-example} in the PATH
and executes it to perform the file header encryption or decryption. The plugin
may request input from the user through {-rage} to complete the operation.
Plugins can be freely mixed with other plugins or natively supported keys.
A plugin is not bound to only encrypt or decrypt files meant for or generated by
the plugin. For example, a plugin can be used to decrypt files encrypted to a
native X25519 {recipient} or even with a passphrase. Similarly, a plugin can
encrypt a file such that it can be decrypted without the use of any plugin.
Plugins for which the {identity}/{recipient} distinction doesn't make sense
(such as a symmetric encryption plugin) may generate only an {identity} and
instruct the user to perform encryption with the {-flag-encrypt} and
{-flag-identity} flags. Plugins for which the concept of separate identities
doesn't make sense (such as a password-encryption plugin) may instruct the user
to use the {-flag-plugin-name} flag.
man-examples-heading = EXAMPLES
man-rage-example-single = Generate a new identity, encrypt data, and decrypt
man-rage-example-enc-multiple = Encrypt {$input} to multiple recipients and output to {$output}
man-rage-example-enc-list = Encrypt to a list of recipients
man-rage-example-password = Encrypt and decrypt a file using a passphrase
man-rage-example-identity-passphrase = Encrypt and decrypt with a passphrase-protected identity file
man-rage-example-ssh = Encrypt and decrypt with an SSH public key
man-rage-example-yubikey = Encrypt and decrypt with {-age-plugin-}{-yubikey}
man-rage-example-yubikey-setup = Run interactive setup, generate identity file and obtain recipient.
man-rage-example-enc-github = Encrypt to the SSH keys of a GitHub user
man-see-also-heading = SEE ALSO
## rage-keygen manpage
man-keygen-about = Generate age-compatible encryption key pairs
man-keygen-description =
{-rage-keygen} generates a new native {-age} key pair, and outputs the identity to
standard output or to the {output} file. The output includes the public key and
the current time as comments.
If the output is not going to a terminal, {-rage-keygen} prints the public key to
standard error.
man-keygen-flag-output =
Write the identity to {output} instead of standard output.
If {output} already exists, it is not overwritten.
man-keygen-flag-convert =
Read an identity file from {input} or from standard input and output the
corresponding recipient(s), one per line, with no comments.
man-keygen-example-stdout = Generate a new identity
man-keygen-example-file = Write a new identity to "{$filename}"
man-keygen-example-convert = Convert an identity to a recipient
## rage-mount manpage
man-mount-about = Mount an {-age} encrypted filesystem
man-mount-description =
{-rage-mount} decrypts the {-age} encrypted filesystem at {mnt-filename} on the
fly, and mounts it as a directory on the local filesystem at {mnt-mountpoint}.
Passphrase-encrypted files are detected automatically and the passphrase is
requested interactively. Otherwise, one or more {identities} specified with
{-flag-identity} are used to decrypt the file.
The previous contents (if any) and owner and mode of {mnt-mountpoint} become
invisible, and as long as this filesystem remains mounted, the pathname
{mnt-mountpoint} refers to the root of the filesystem on {mnt-filename}.
man-mount-flag-types =
Set the filesystem type. The following types are currently supported: {$types}.
This option is required. {-rage-mount} does not attempt to guess the filesystem
format.
In theory, any efficiently-seekable filesystem format can be supported. At
present, {-rage-mount} only supports seekable archive formats.
man-mount-example-identity = Mounting an archive encrypted to a recipient
man-mount-example-passphrase = Mounting an archive encrypted with a passphrase

View file

@ -145,14 +145,6 @@ err-ux-B = Faccelo sapere
# Put (len(A) - len(B) - 32) spaces here.
err-ux-C = {" "}
## Keygen errors
err-identity-file-contains-plugin = Il file '{$filename}' contiene identità per '{-age-plugin-}{$plugin_name}'.
rec-identity-file-contains-plugin = Prova a usare '{-age-plugin-}{$plugin_name}' per convertire questa identità in destinatario.
err-no-identities-in-file = Nessuna identità trovata nel file '{$filename}'.
err-no-identities-in-stdin = Nessuna identità trovata tramite standard input.
## Encryption errors
err-enc-broken-stdout = Impossibile scrivere sullo standard output: {$err}
@ -160,7 +152,6 @@ rec-enc-broken-stdout = Stai usando una pipe verso un programma che non sta legg
err-enc-broken-file = Impossibile scrivere sul file: {$err}
err-enc-missing-recipients = Destinatari mancanti.
rec-enc-missing-recipients = Hai dimenticato di specificare {-flag-recipient}?
err-enc-mixed-identity-passphrase = {-flag-identity} non può essere usato assieme a {-flag-passphrase}.

View file

@ -147,14 +147,6 @@ err-ux-B = Сообщите нам
# Поставьте здесь пробелы (len(A) - len(B) - 32).
err-ux-C = {" "}
## Keygen errors
err-identity-file-contains-plugin = Файл идентификации '{$filename}' содержит идентификаторы для '{-age-plugin-}{$plugin_name}'.
rec-identity-file-contains-plugin = Попробуйте использовать '{-age-plugin-}{$plugin_name}' для преобразования этого идентификатора в получателя.
err-no-identities-in-file = Идентификаторы в файле '{$filename}' не найдены.
err-no-identities-in-stdin = Идентификаторы в стандартном вводе не найдены.
## Encryption errors
err-enc-broken-stdout = Не удалось записать в stdout: {$err}
@ -162,7 +154,6 @@ rec-enc-broken-stdout = Вы передаете данные в программ
err-enc-broken-file = Не удалось записать в файл: {$err}
err-enc-missing-recipients = Отсутствуют получатели.
rec-enc-missing-recipients = Вы забыли указать {-flag-recipient}?
err-enc-mixed-identity-passphrase = {-flag-identity} не может использоваться с {-flag-passphrase}.

View file

@ -118,7 +118,6 @@ rec-enc-broken-stdout = 您是否输出至非从 stdin 读取数据的程序?
err-enc-broken-file = 未能写入文件: {$err}
err-enc-missing-recipients = 缺少接收方。
rec-enc-missing-recipients = 您是否忘记指定 {-flag-recipient} 标记?
err-enc-mixed-identity-passphrase = {-flag-identity} 和 {-flag-passphrase} 标记不可联用。

View file

@ -118,7 +118,6 @@ rec-enc-broken-stdout = 您是否輸出至非從 stdin 讀取數據的程序?
err-enc-broken-file = 未能寫入文件: {$err}
err-enc-missing-recipients = 缺少接收方。
rec-enc-missing-recipients = 您是否忘記指定 {-flag-recipient} 標記?
err-enc-mixed-identity-passphrase = {-flag-identity} 和 {-flag-passphrase} 標記不可聯用。

View file

@ -1,4 +1,7 @@
use clap::{builder::Styles, ArgAction, Parser};
use clap::{
builder::{Styles, ValueHint},
ArgAction, Parser,
};
use crate::fl;
@ -22,6 +25,7 @@ pub(crate) struct AgeOptions {
#[arg(help_heading = fl!("args-header"))]
#[arg(value_name = fl!("input"))]
#[arg(help = fl!("help-arg-input"))]
#[arg(value_hint = ValueHint::FilePath)]
pub(crate) input: Option<String>,
#[arg(action = ArgAction::Help, short, long)]
@ -35,6 +39,7 @@ pub(crate) struct AgeOptions {
#[arg(short, long)]
#[arg(value_name = fl!("output"))]
#[arg(help = fl!("keygen-help-flag-output"))]
#[arg(value_hint = ValueHint::DirPath)]
pub(crate) output: Option<String>,
#[arg(short = 'y')]

View file

@ -1,6 +1,8 @@
use std::fmt;
use std::io;
use age::IdentityFileConvertError;
macro_rules! wlnfl {
($f:ident, $message_id:literal) => {
writeln!($f, "{}", $crate::fl!($message_id))
@ -16,13 +18,7 @@ pub(crate) enum Error {
FailedToOpenOutput(io::Error),
FailedToReadInput(io::Error),
FailedToWriteOutput(io::Error),
IdentityFileContainsPlugin {
filename: Option<String>,
plugin_name: String,
},
NoIdentities {
filename: Option<String>,
},
IdentityFileConvert(IdentityFileConvertError),
}
// Rust only supports `fn main() -> Result<(), E: Debug>`, so we implement `Debug`
@ -42,28 +38,7 @@ impl fmt::Debug for Error {
Error::FailedToWriteOutput(e) => {
wlnfl!(f, "err-failed-to-write-output", err = e.to_string())?
}
Error::IdentityFileContainsPlugin {
filename,
plugin_name,
} => {
wlnfl!(
f,
"err-identity-file-contains-plugin",
filename = filename.as_deref().unwrap_or_default(),
plugin_name = plugin_name.as_str(),
)?;
wlnfl!(
f,
"rec-identity-file-contains-plugin",
plugin_name = plugin_name.as_str(),
)?
}
Error::NoIdentities { filename } => match filename {
Some(filename) => {
wlnfl!(f, "err-no-identities-in-file", filename = filename.as_str())?
}
None => wlnfl!(f, "err-no-identities-in-stdin")?,
},
Error::IdentityFileConvert(e) => writeln!(f, "{e}")?,
}
writeln!(f)?;
writeln!(f, "[ {} ]", crate::fl!("err-ux-A"))?;

View file

@ -73,33 +73,14 @@ fn generate(mut output: file_io::OutputWriter) -> io::Result<()> {
Ok(())
}
fn convert(
filename: Option<String>,
mut output: file_io::OutputWriter,
) -> Result<(), error::Error> {
fn convert(filename: Option<String>, output: file_io::OutputWriter) -> Result<(), error::Error> {
let file = age::IdentityFile::from_input_reader(
file_io::InputReader::new(filename.clone()).map_err(error::Error::FailedToOpenInput)?,
file_io::InputReader::new(filename).map_err(error::Error::FailedToOpenInput)?,
)
.map_err(error::Error::FailedToReadInput)?;
let identities = file.into_identities();
if identities.is_empty() {
return Err(error::Error::NoIdentities { filename });
}
for identity in identities {
match identity {
age::IdentityFileEntry::Native(sk) => {
writeln!(output, "{}", sk.to_public()).map_err(error::Error::FailedToWriteOutput)?
}
age::IdentityFileEntry::Plugin(id) => {
return Err(error::Error::IdentityFileContainsPlugin {
filename,
plugin_name: id.plugin().to_string(),
});
}
}
}
file.write_recipients_file(output)
.map_err(error::Error::IdentityFileConvert)?;
Ok(())
}

View file

@ -1,4 +1,7 @@
use clap::{builder::Styles, ArgAction, Parser};
use clap::{
builder::{Styles, ValueHint},
ArgAction, Parser,
};
use crate::fl;
@ -24,11 +27,13 @@ pub(crate) struct AgeMountOptions {
#[arg(help_heading = fl!("args-header"))]
#[arg(value_name = fl!("mnt-filename"))]
#[arg(help = fl!("help-arg-mnt-filename"))]
#[arg(value_hint = ValueHint::FilePath)]
pub(crate) filename: String,
#[arg(help_heading = fl!("args-header"))]
#[arg(value_name = fl!("mnt-mountpoint"))]
#[arg(help = fl!("help-arg-mnt-mountpoint"))]
#[arg(value_hint = ValueHint::DirPath)]
pub(crate) mountpoint: String,
#[arg(action = ArgAction::Help, short, long)]
@ -51,5 +56,6 @@ pub(crate) struct AgeMountOptions {
#[arg(short, long)]
#[arg(value_name = fl!("identity"))]
#[arg(help = fl!("help-flag-identity"))]
#[arg(value_hint = ValueHint::FilePath)]
pub(crate) identity: Vec<String>,
}

View file

@ -3,6 +3,7 @@
use age::{
armor::ArmoredReader,
cli_common::{read_identities, read_secret, StdinGuard},
scrypt,
stream::StreamReader,
};
use clap::{CommandFactory, Parser};
@ -209,28 +210,33 @@ fn main() -> Result<(), Error> {
let mut stdin_guard = StdinGuard::new(false);
match age::Decryptor::new_buffered(ArmoredReader::new(file))? {
age::Decryptor::Passphrase(decryptor) => {
match read_secret(&fl!("type-passphrase"), &fl!("prompt-passphrase"), None) {
Ok(passphrase) => decryptor
.decrypt(&passphrase, opts.max_work_factor)
let decryptor = age::Decryptor::new_buffered(ArmoredReader::new(file))?;
if decryptor.is_scrypt() {
match read_secret(&fl!("type-passphrase"), &fl!("prompt-passphrase"), None) {
Ok(passphrase) => {
let mut identity = scrypt::Identity::new(passphrase);
if let Some(max_work_factor) = opts.max_work_factor {
identity.set_max_work_factor(max_work_factor);
}
decryptor
.decrypt(Some(&identity as _).into_iter())
.map_err(|e| e.into())
.and_then(|stream| mount_stream(stream, types, mountpoint)),
Err(_) => Ok(()),
.and_then(|stream| mount_stream(stream, types, mountpoint))
}
Err(_) => Ok(()),
}
age::Decryptor::Recipients(decryptor) => {
let identities =
read_identities(opts.identity, opts.max_work_factor, &mut stdin_guard)?;
} else {
let identities = read_identities(opts.identity, opts.max_work_factor, &mut stdin_guard)?;
if identities.is_empty() {
return Err(Error::MissingIdentities);
}
decryptor
.decrypt(identities.iter().map(|i| &**i))
.map_err(|e| e.into())
.and_then(|stream| mount_stream(stream, types, mountpoint))
if identities.is_empty() {
return Err(Error::MissingIdentities);
}
decryptor
.decrypt(identities.iter().map(|i| &**i))
.map_err(|e| e.into())
.and_then(|stream| mount_stream(stream, types, mountpoint))
}
}

View file

@ -1,6 +1,9 @@
use std::path::Path;
use clap::{builder::Styles, ArgAction, Parser};
use clap::{
builder::{Styles, ValueHint},
ArgAction, Parser,
};
use crate::fl;
@ -99,6 +102,7 @@ pub(crate) struct AgeOptions {
#[arg(help_heading = fl!("args-header"))]
#[arg(value_name = fl!("input"))]
#[arg(help = fl!("help-arg-input"))]
#[arg(value_hint = ValueHint::FilePath)]
pub(crate) input: Option<String>,
#[arg(action = ArgAction::Help, short, long)]
@ -137,11 +141,13 @@ pub(crate) struct AgeOptions {
#[arg(short = 'R', long)]
#[arg(value_name = fl!("recipients-file"))]
#[arg(help = fl!("help-flag-recipients-file"))]
#[arg(value_hint = ValueHint::FilePath)]
pub(crate) recipients_file: Vec<String>,
#[arg(short, long)]
#[arg(value_name = fl!("identity"))]
#[arg(help = fl!("help-flag-identity"))]
#[arg(value_hint = ValueHint::FilePath)]
pub(crate) identity: Vec<String>,
#[arg(short = 'j')]
@ -152,5 +158,6 @@ pub(crate) struct AgeOptions {
#[arg(short, long)]
#[arg(value_name = fl!("output"))]
#[arg(help = fl!("help-flag-output"))]
#[arg(value_hint = ValueHint::AnyPath)]
pub(crate) output: Option<String>,
}

View file

@ -23,14 +23,17 @@ macro_rules! wlnfl {
pub(crate) enum EncryptError {
Age(age::EncryptError),
BrokenPipe { is_stdout: bool, source: io::Error },
BrokenPipe {
is_stdout: bool,
source: io::Error,
},
IdentityRead(age::cli_common::ReadError),
Io(io::Error),
MissingRecipients,
MixedIdentityAndPassphrase,
MixedRecipientAndPassphrase,
MixedRecipientsFileAndPassphrase,
PassphraseTimedOut,
#[cfg(not(unix))]
PassphraseWithoutFileArgument,
PluginNameFlag,
}
@ -59,6 +62,10 @@ impl From<io::Error> for EncryptError {
impl fmt::Display for EncryptError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
EncryptError::Age(e @ age::EncryptError::MissingRecipients) => {
writeln!(f, "{}", e)?;
wfl!(f, "rec-enc-missing-recipients")
}
EncryptError::Age(e) => write!(f, "{}", e),
EncryptError::BrokenPipe { is_stdout, source } => {
if *is_stdout {
@ -70,10 +77,6 @@ impl fmt::Display for EncryptError {
}
EncryptError::IdentityRead(e) => write!(f, "{}", e),
EncryptError::Io(e) => write!(f, "{}", e),
EncryptError::MissingRecipients => {
wlnfl!(f, "err-enc-missing-recipients")?;
wfl!(f, "rec-enc-missing-recipients")
}
EncryptError::MixedIdentityAndPassphrase => {
wfl!(f, "err-enc-mixed-identity-passphrase")
}
@ -84,6 +87,7 @@ impl fmt::Display for EncryptError {
wfl!(f, "err-enc-mixed-recipients-file-passphrase")
}
EncryptError::PassphraseTimedOut => wfl!(f, "err-passphrase-timed-out"),
#[cfg(not(unix))]
EncryptError::PassphraseWithoutFileArgument => {
wfl!(f, "err-enc-passphrase-without-file")
}

View file

@ -14,7 +14,7 @@ lazy_static! {
}
/// Selects the most suitable available language in order of preference by
/// `requested_languages`, and loads it using the `rage` [`LANGUAGE_LOADER`] from the
/// `requested_languages`, and loads it using the `rage` [`static@LANGUAGE_LOADER`] from the
/// languages available in `rage/i18n/`.
///
/// Returns the available languages that were negotiated as being the most suitable to be

View file

@ -6,7 +6,7 @@ use age::{
file_io, read_identities, read_or_generate_passphrase, read_recipients, read_secret,
Passphrase, StdinGuard, UiCallbacks,
},
plugin,
plugin, scrypt,
secrecy::ExposeSecret,
Identity,
};
@ -108,6 +108,7 @@ fn encrypt(opts: AgeOptions) -> Result<(), error::EncryptError> {
(Format::Binary, file_io::OutputFormat::Binary)
};
#[cfg(not(unix))]
let has_file_argument = opts.input.is_some();
let (input, output) = set_up_io(opts.input, opts.output, output_format)?;
@ -134,8 +135,13 @@ fn encrypt(opts: AgeOptions) -> Result<(), error::EncryptError> {
return Err(error::EncryptError::MixedRecipientsFileAndPassphrase);
}
if !has_file_argument {
return Err(error::EncryptError::PassphraseWithoutFileArgument);
// The `rpassword` crate opens `/dev/tty` directly on Unix, so we don't have
// any conflict with stdin.
#[cfg(not(unix))]
{
if !has_file_argument {
return Err(error::EncryptError::PassphraseWithoutFileArgument);
}
}
match read_or_generate_passphrase() {
@ -166,19 +172,20 @@ fn encrypt(opts: AgeOptions) -> Result<(), error::EncryptError> {
} else {
if opts.recipient.is_empty() && opts.recipients_file.is_empty() && opts.identity.is_empty()
{
return Err(error::EncryptError::MissingRecipients);
return Err(error::EncryptError::Age(
age::EncryptError::MissingRecipients,
));
}
match age::Encryptor::with_recipients(read_recipients(
let recipients = read_recipients(
opts.recipient,
opts.recipients_file,
opts.identity,
opts.max_work_factor,
&mut stdin_guard,
)?) {
Some(encryptor) => encryptor,
None => return Err(error::EncryptError::MissingRecipients),
}
)?;
age::Encryptor::with_recipients(recipients.iter().map(|r| r.as_ref() as _))?
};
let mut output = encryptor.wrap_output(ArmoredWriter::wrap_output(output, format)?)?;
@ -305,55 +312,61 @@ fn decrypt(opts: AgeOptions) -> Result<(), error::DecryptError> {
],
);
match age::Decryptor::new_buffered(ArmoredReader::new(input))? {
age::Decryptor::Passphrase(decryptor) => {
if identities_were_provided {
return Err(error::DecryptError::MixedIdentityAndPassphrase);
}
let decryptor = age::Decryptor::new_buffered(ArmoredReader::new(input))?;
// The `rpassword` crate opens `/dev/tty` directly on Unix, so we don't have
// any conflict with stdin.
#[cfg(not(unix))]
{
if !has_file_argument {
return Err(error::DecryptError::PassphraseWithoutFileArgument);
if decryptor.is_scrypt() {
if identities_were_provided {
return Err(error::DecryptError::MixedIdentityAndPassphrase);
}
// The `rpassword` crate opens `/dev/tty` directly on Unix, so we don't have
// any conflict with stdin.
#[cfg(not(unix))]
{
if !has_file_argument {
return Err(error::DecryptError::PassphraseWithoutFileArgument);
}
}
match read_secret(&fl!("type-passphrase"), &fl!("prompt-passphrase"), None) {
Ok(passphrase) => {
let mut identity = scrypt::Identity::new(passphrase);
if let Some(max_work_factor) = opts.max_work_factor {
identity.set_max_work_factor(max_work_factor);
}
}
match read_secret(&fl!("type-passphrase"), &fl!("prompt-passphrase"), None) {
Ok(passphrase) => decryptor
.decrypt(&passphrase, opts.max_work_factor)
decryptor
.decrypt(Some(&identity as _).into_iter())
.map_err(|e| e.into())
.and_then(|input| write_output(input, output)),
Err(pinentry::Error::Cancelled) => Ok(()),
Err(pinentry::Error::Timeout) => Err(error::DecryptError::PassphraseTimedOut),
Err(pinentry::Error::Encoding(e)) => {
// Pretend it is an I/O error
Err(error::DecryptError::Io(io::Error::new(
io::ErrorKind::InvalidData,
e,
)))
}
Err(pinentry::Error::Gpg(e)) => {
// Pretend it is an I/O error
Err(error::DecryptError::Io(io::Error::new(
io::ErrorKind::Other,
format!("{}", e),
)))
}
Err(pinentry::Error::Io(e)) => Err(error::DecryptError::Io(e)),
.and_then(|input| write_output(input, output))
}
Err(pinentry::Error::Cancelled) => Ok(()),
Err(pinentry::Error::Timeout) => Err(error::DecryptError::PassphraseTimedOut),
Err(pinentry::Error::Encoding(e)) => {
// Pretend it is an I/O error
Err(error::DecryptError::Io(io::Error::new(
io::ErrorKind::InvalidData,
e,
)))
}
Err(pinentry::Error::Gpg(e)) => {
// Pretend it is an I/O error
Err(error::DecryptError::Io(io::Error::new(
io::ErrorKind::Other,
format!("{}", e),
)))
}
Err(pinentry::Error::Io(e)) => Err(error::DecryptError::Io(e)),
}
} else {
if identities.is_empty() {
return Err(error::DecryptError::MissingIdentities { stdin_identity });
}
age::Decryptor::Recipients(decryptor) => {
if identities.is_empty() {
return Err(error::DecryptError::MissingIdentities { stdin_identity });
}
decryptor
.decrypt(identities.iter().map(|i| i.as_ref() as &dyn Identity))
.map_err(|e| e.into())
.and_then(|input| write_output(input, output))
}
decryptor
.decrypt(identities.iter().map(|i| i.as_ref() as &dyn Identity))
.map_err(|e| e.into())
.and_then(|input| write_output(input, output))
}
}

View file

@ -1,4 +1,12 @@
#[test]
fn cli_tests() {
trycmd::TestCases::new().case("tests/cmd/*/*.toml");
let tests = trycmd::TestCases::new();
tests.case("tests/cmd/*/*.toml");
#[cfg(unix)]
tests.case("tests/unix/*/*.toml");
#[cfg(not(unix))]
tests.case("tests/windows/*/*.toml");
}

View file

@ -0,0 +1,10 @@
bin.name = "rage-keygen"
args = "-o ''"
status = "failed"
stdout = ""
stderr = """
Error: Failed to open output: invalid filename ''.
[ Did rage not do what you expected? Could an error be more useful? ]
[ Tell us: https://str4d.xyz/rage/report ]
"""

View file

@ -0,0 +1,10 @@
bin.name = "rage-keygen"
args = "-o does-not-exist/key.txt"
status = "failed"
stdout = ""
stderr = """
Error: Failed to open output: directory 'does-not-exist' does not exist.
[ Did rage not do what you expected? Could an error be more useful? ]
[ Tell us: https://str4d.xyz/rage/report ]
"""

View file

@ -1,6 +1,6 @@
bin.name = "rage-keygen"
args = "--version"
stdout = """
rage-keygen 0.10.1
rage-keygen 0.11.1
"""
stderr = ""

View file

@ -1,6 +1,6 @@
bin.name = "rage-mount"
args = "--version"
stdout = """
rage-mount 0.10.1
rage-mount 0.11.1
"""
stderr = ""

View file

@ -0,0 +1,8 @@
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHUGc3Zlhpekp0K012aXdu
T1VZN0lmWlRmNjdLYVB4RldkTFVLTkNDUXlBCmJjRUcrM3E0a0U0N3IyK1JsTitG
dHVTd0N6TVFRTWgzdG5uSzJmNm9YMTgKLT4gQXQ1WWAtZ3JlYXNlIDxodGFSVHJg
IFg0cWYsO0ogZ2Fzc1EKZGtPSTB3Ci0tLSBKazRIaHJxdnNJcHpyclRkQjg3QW5r
SVE2MHdtWkErYTNrNWJibWd1bmNBCkK9FoOkiLB93gD79vNed8L3LM9rhKm5qma2
lSiwRx/aM1DKaZO0CMmYQkoM2tPReA==
-----END AGE ENCRYPTED FILE-----

View file

@ -0,0 +1,7 @@
bin.name = "rage"
args = "-d -i - file.age.txt"
stdin = "AGE-SECRET-KEY-1SRQGS50G584HFA5JG9D6D9S6639VVHJUE5XHHKJET9DRU76HK4RQP0X5Q3"
stdout = """
Test plaintext.
"""
stderr = ""

View file

@ -1,6 +1,6 @@
bin.name = "rage"
args = "--version"
stdout = """
rage 0.10.1
rage 0.11.1
"""
stderr = ""

View file

@ -0,0 +1,16 @@
bin.name = "rage"
args = "-p"
status = "failed"
stdin = ""
stdout = ""
stderr = """
Error: Parsing Error: Error { input: "", code: Tag }
[ Did rage not do what you expected? Could an error be more useful? ]
[ Tell us: https://str4d.xyz/rage/report ]
"""
# We get an error from the `pinentry` crate because we've passed a real but invalid binary
# that does not speak the pinentry protocol.
[env.add]
PINENTRY_PROGRAM = "true"

View file

@ -6,6 +6,12 @@ description = "The cryptographic code in this crate has been reviewed for correc
[audits]
[[trusted.pinentry]]
criteria = "safe-to-deploy"
user-id = 6289 # Jack Grigg (str4d)
start = "2020-01-12"
end = "2025-11-03"
[[trusted.windows-sys]]
criteria = "safe-to-deploy"
user-id = 64539 # Kenny Kerr (kennykerr)

View file

@ -2,7 +2,7 @@
# cargo-vet config file
[cargo-vet]
version = "0.9"
version = "0.10"
[imports.bytecode-alliance]
url = "https://raw.githubusercontent.com/bytecodealliance/wasmtime/main/supply-chain/audits.toml"
@ -53,10 +53,6 @@ criteria = "safe-to-deploy"
version = "0.10.3"
criteria = "safe-to-deploy"
[[exemptions.ahash]]
version = "0.8.6"
criteria = "safe-to-run"
[[exemptions.aho-corasick]]
version = "1.1.1"
criteria = "safe-to-deploy"
@ -86,13 +82,21 @@ version = "1.0.2"
criteria = "safe-to-deploy"
[[exemptions.arc-swap]]
version = "1.6.0"
version = "1.7.1"
criteria = "safe-to-deploy"
[[exemptions.backtrace]]
version = "0.3.73"
criteria = "safe-to-run"
[[exemptions.base64ct]]
version = "1.6.0"
criteria = "safe-to-deploy"
[[exemptions.basic-toml]]
version = "0.1.9"
criteria = "safe-to-deploy"
[[exemptions.bcrypt-pbkdf]]
version = "0.10.0"
criteria = "safe-to-deploy"
@ -101,10 +105,6 @@ criteria = "safe-to-deploy"
version = "0.9.1"
criteria = "safe-to-deploy"
[[exemptions.bitflags]]
version = "1.3.2"
criteria = "safe-to-deploy"
[[exemptions.block]]
version = "0.1.6"
criteria = "safe-to-deploy"
@ -117,14 +117,6 @@ criteria = "safe-to-deploy"
version = "0.9.1"
criteria = "safe-to-deploy"
[[exemptions.bytemuck]]
version = "1.14.1"
criteria = "safe-to-run"
[[exemptions.byteorder]]
version = "1.4.3"
criteria = "safe-to-deploy"
[[exemptions.bzip2]]
version = "0.4.4"
criteria = "safe-to-deploy"
@ -133,14 +125,14 @@ criteria = "safe-to-deploy"
version = "0.1.11+1.0.8"
criteria = "safe-to-deploy"
[[exemptions.cast]]
version = "0.3.0"
criteria = "safe-to-run"
[[exemptions.cbc]]
version = "0.1.2"
criteria = "safe-to-deploy"
[[exemptions.cc]]
version = "1.1.34"
criteria = "safe-to-deploy"
[[exemptions.chacha20]]
version = "0.9.1"
criteria = "safe-to-deploy"
@ -150,7 +142,7 @@ version = "0.10.1"
criteria = "safe-to-deploy"
[[exemptions.chrono]]
version = "0.4.33"
version = "0.4.38"
criteria = "safe-to-deploy"
[[exemptions.ciborium]]
@ -165,10 +157,6 @@ criteria = "safe-to-run"
version = "0.2.2"
criteria = "safe-to-run"
[[exemptions.cipher]]
version = "0.3.0"
criteria = "safe-to-deploy"
[[exemptions.clap]]
version = "4.3.24"
criteria = "safe-to-deploy"
@ -193,6 +181,10 @@ criteria = "safe-to-deploy"
version = "0.2.12"
criteria = "safe-to-deploy"
[[exemptions.colorchoice]]
version = "1.0.2"
criteria = "safe-to-deploy"
[[exemptions.console]]
version = "0.15.8"
criteria = "safe-to-deploy"
@ -210,27 +202,19 @@ version = "0.2.4"
criteria = "safe-to-run"
[[exemptions.cookie-factory]]
version = "0.3.2"
version = "0.3.3"
criteria = "safe-to-deploy"
[[exemptions.cpp_demangle]]
version = "0.4.3"
criteria = "safe-to-run"
[[exemptions.cpufeatures]]
version = "0.2.2"
criteria = "safe-to-deploy"
[[exemptions.crc32fast]]
version = "1.3.2"
criteria = "safe-to-deploy"
[[exemptions.criterion]]
version = "0.3.6"
criteria = "safe-to-run"
[[exemptions.criterion-cycles-per-byte]]
version = "0.6.0"
version = "0.6.1"
criteria = "safe-to-run"
[[exemptions.criterion-plot]]
@ -258,7 +242,7 @@ version = "0.1.0"
criteria = "safe-to-deploy"
[[exemptions.dashmap]]
version = "5.5.3"
version = "6.1.0"
criteria = "safe-to-deploy"
[[exemptions.der]]
@ -269,8 +253,12 @@ criteria = "safe-to-deploy"
version = "0.9.0"
criteria = "safe-to-deploy"
[[exemptions.displaydoc]]
version = "0.2.5"
criteria = "safe-to-deploy"
[[exemptions.dunce]]
version = "1.0.4"
version = "1.0.5"
criteria = "safe-to-run"
[[exemptions.encode_unicode]]
@ -282,7 +270,7 @@ version = "0.10.2"
criteria = "safe-to-deploy"
[[exemptions.filetime]]
version = "0.2.23"
version = "0.2.25"
criteria = "safe-to-deploy"
[[exemptions.find-crate]]
@ -293,8 +281,16 @@ criteria = "safe-to-deploy"
version = "0.10.2"
criteria = "safe-to-run"
[[exemptions.flate2]]
version = "1.0.28"
[[exemptions.fluent]]
version = "0.16.1"
criteria = "safe-to-deploy"
[[exemptions.fluent-bundle]]
version = "0.15.3"
criteria = "safe-to-deploy"
[[exemptions.fluent-syntax]]
version = "0.11.1"
criteria = "safe-to-deploy"
[[exemptions.fuse_mt]]
@ -305,18 +301,6 @@ criteria = "safe-to-deploy"
version = "0.13.0"
criteria = "safe-to-deploy"
[[exemptions.futures]]
version = "0.3.30"
criteria = "safe-to-deploy"
[[exemptions.futures-executor]]
version = "0.3.30"
criteria = "safe-to-deploy"
[[exemptions.futures-io]]
version = "0.3.30"
criteria = "safe-to-deploy"
[[exemptions.futures-macro]]
version = "0.3.30"
criteria = "safe-to-deploy"
@ -345,20 +329,24 @@ criteria = "safe-to-deploy"
version = "0.2.10"
criteria = "safe-to-deploy"
[[exemptions.gimli]]
version = "0.28.1"
criteria = "safe-to-run"
[[exemptions.half]]
version = "2.2.1"
criteria = "safe-to-run"
[[exemptions.ghash]]
version = "0.5.1"
criteria = "safe-to-deploy"
[[exemptions.hashbrown]]
version = "0.14.3"
version = "0.14.2"
criteria = "safe-to-deploy"
[[exemptions.hashbrown]]
version = "0.15.0"
criteria = "safe-to-run"
[[exemptions.hermit-abi]]
version = "0.3.3"
criteria = "safe-to-deploy"
[[exemptions.hermit-abi]]
version = "0.3.4"
version = "0.4.0"
criteria = "safe-to-deploy"
[[exemptions.hkdf]]
@ -378,39 +366,47 @@ version = "1.1.1"
criteria = "safe-to-run"
[[exemptions.i18n-config]]
version = "0.4.5"
version = "0.4.7"
criteria = "safe-to-deploy"
[[exemptions.i18n-embed]]
version = "0.14.1"
version = "0.15.2"
criteria = "safe-to-deploy"
[[exemptions.i18n-embed-fl]]
version = "0.7.0"
version = "0.9.2"
criteria = "safe-to-deploy"
[[exemptions.i18n-embed-impl]]
version = "0.8.3"
version = "0.8.4"
criteria = "safe-to-deploy"
[[exemptions.iana-time-zone]]
version = "0.1.61"
criteria = "safe-to-deploy"
[[exemptions.indexmap]]
version = "2.0.0"
criteria = "safe-to-deploy"
version = "2.6.0"
criteria = "safe-to-run"
[[exemptions.inferno]]
version = "0.11.19"
version = "0.11.17"
criteria = "safe-to-run"
[[exemptions.intl-memoizer]]
version = "0.5.2"
criteria = "safe-to-deploy"
[[exemptions.io_tee]]
version = "0.1.1"
criteria = "safe-to-deploy"
[[exemptions.is-terminal]]
version = "0.4.10"
version = "0.4.13"
criteria = "safe-to-deploy"
[[exemptions.jobserver]]
version = "0.1.26"
version = "0.1.24"
criteria = "safe-to-deploy"
[[exemptions.js-sys]]
@ -418,15 +414,19 @@ version = "0.3.60"
criteria = "safe-to-deploy"
[[exemptions.libc]]
version = "0.2.153"
version = "0.2.158"
criteria = "safe-to-deploy"
[[exemptions.libm]]
version = "0.2.2"
criteria = "safe-to-deploy"
[[exemptions.libredox]]
version = "0.0.1"
criteria = "safe-to-deploy"
[[exemptions.linux-raw-sys]]
version = "0.4.13"
version = "0.4.14"
criteria = "safe-to-deploy"
[[exemptions.locale_config]]
@ -434,17 +434,13 @@ version = "0.3.0"
criteria = "safe-to-deploy"
[[exemptions.lock_api]]
version = "0.4.11"
version = "0.4.12"
criteria = "safe-to-deploy"
[[exemptions.memchr]]
version = "2.6.3"
criteria = "safe-to-deploy"
[[exemptions.memmap2]]
version = "0.9.4"
criteria = "safe-to-run"
[[exemptions.minimal-lexical]]
version = "0.2.1"
criteria = "safe-to-deploy"
@ -453,10 +449,6 @@ criteria = "safe-to-deploy"
version = "0.26.1"
criteria = "safe-to-deploy"
[[exemptions.nom]]
version = "7.1.1"
criteria = "safe-to-deploy"
[[exemptions.num-bigint-dig]]
version = "0.8.4"
criteria = "safe-to-deploy"
@ -482,7 +474,7 @@ version = "0.1.1"
criteria = "safe-to-deploy"
[[exemptions.object]]
version = "0.32.2"
version = "0.36.5"
criteria = "safe-to-run"
[[exemptions.once_cell]]
@ -490,7 +482,7 @@ version = "1.15.0"
criteria = "safe-to-deploy"
[[exemptions.os_pipe]]
version = "1.1.5"
version = "1.2.1"
criteria = "safe-to-run"
[[exemptions.page_size]]
@ -498,11 +490,11 @@ version = "0.5.0"
criteria = "safe-to-deploy"
[[exemptions.parking_lot]]
version = "0.12.1"
version = "0.12.2"
criteria = "safe-to-deploy"
[[exemptions.parking_lot_core]]
version = "0.9.9"
version = "0.9.10"
criteria = "safe-to-deploy"
[[exemptions.password-hash]]
@ -518,15 +510,11 @@ version = "0.12.2"
criteria = "safe-to-deploy"
[[exemptions.pin-project]]
version = "1.1.4"
version = "1.1.5"
criteria = "safe-to-deploy"
[[exemptions.pin-project-internal]]
version = "1.1.4"
criteria = "safe-to-deploy"
[[exemptions.pinentry]]
version = "0.5.0"
version = "1.1.3"
criteria = "safe-to-deploy"
[[exemptions.pkcs1]]
@ -537,20 +525,16 @@ criteria = "safe-to-deploy"
version = "0.10.2"
criteria = "safe-to-deploy"
[[exemptions.pkg-config]]
version = "0.3.29"
criteria = "safe-to-deploy"
[[exemptions.plotters]]
version = "0.3.5"
version = "0.3.7"
criteria = "safe-to-run"
[[exemptions.plotters-backend]]
version = "0.3.5"
version = "0.3.7"
criteria = "safe-to-run"
[[exemptions.plotters-svg]]
version = "0.3.5"
version = "0.3.6"
criteria = "safe-to-run"
[[exemptions.poly1305]]
@ -558,7 +542,7 @@ version = "0.8.0"
criteria = "safe-to-deploy"
[[exemptions.polyval]]
version = "0.6.1"
version = "0.6.2"
criteria = "safe-to-deploy"
[[exemptions.pprof]]
@ -566,15 +550,19 @@ version = "0.13.0"
criteria = "safe-to-run"
[[exemptions.ppv-lite86]]
version = "0.2.16"
version = "0.2.20"
criteria = "safe-to-deploy"
[[exemptions.proc-macro-error]]
version = "1.0.4"
[[exemptions.proc-macro-error-attr2]]
version = "2.0.0"
criteria = "safe-to-deploy"
[[exemptions.proc-macro-error2]]
version = "2.0.1"
criteria = "safe-to-deploy"
[[exemptions.proptest]]
version = "1.2.0"
version = "1.5.0"
criteria = "safe-to-run"
[[exemptions.quick-error]]
@ -590,7 +578,7 @@ version = "0.8.5"
criteria = "safe-to-deploy"
[[exemptions.redox_syscall]]
version = "0.4.1"
version = "0.5.7"
criteria = "safe-to-deploy"
[[exemptions.regex]]
@ -606,7 +594,7 @@ version = "0.7.2"
criteria = "safe-to-deploy"
[[exemptions.rgb]]
version = "0.8.37"
version = "0.8.50"
criteria = "safe-to-run"
[[exemptions.roff]]
@ -626,19 +614,19 @@ version = "0.0.2"
criteria = "safe-to-deploy"
[[exemptions.rust-embed]]
version = "8.2.0"
version = "8.3.0"
criteria = "safe-to-deploy"
[[exemptions.rust-embed-impl]]
version = "8.2.0"
version = "8.3.0"
criteria = "safe-to-deploy"
[[exemptions.rust-embed-utils]]
version = "8.2.0"
version = "8.3.0"
criteria = "safe-to-deploy"
[[exemptions.rustix]]
version = "0.38.31"
version = "0.38.34"
criteria = "safe-to-deploy"
[[exemptions.rusty-fork]]
@ -666,7 +654,7 @@ version = "0.11.0"
criteria = "safe-to-deploy"
[[exemptions.secrecy]]
version = "0.8.0"
version = "0.10.3"
criteria = "safe-to-deploy"
[[exemptions.self_cell]]
@ -674,35 +662,23 @@ version = "0.10.3"
criteria = "safe-to-deploy"
[[exemptions.self_cell]]
version = "1.0.3"
criteria = "safe-to-deploy"
[[exemptions.semver]]
version = "1.0.21"
criteria = "safe-to-deploy"
[[exemptions.serde]]
version = "1.0.136"
criteria = "safe-to-deploy"
[[exemptions.serde_derive]]
version = "1.0.136"
version = "1.0.4"
criteria = "safe-to-deploy"
[[exemptions.serde_spanned]]
version = "0.6.3"
criteria = "safe-to-deploy"
[[exemptions.sha1]]
version = "0.10.6"
criteria = "safe-to-deploy"
criteria = "safe-to-run"
[[exemptions.sha2]]
version = "0.10.8"
criteria = "safe-to-deploy"
[[exemptions.shlex]]
version = "1.3.0"
criteria = "safe-to-deploy"
[[exemptions.similar]]
version = "2.4.0"
version = "2.6.0"
criteria = "safe-to-run"
[[exemptions.slab]]
@ -710,7 +686,7 @@ version = "0.4.9"
criteria = "safe-to-deploy"
[[exemptions.smallvec]]
version = "1.13.1"
version = "1.11.1"
criteria = "safe-to-deploy"
[[exemptions.snapbox]]
@ -722,7 +698,7 @@ version = "0.3.4"
criteria = "safe-to-run"
[[exemptions.spin]]
version = "0.5.2"
version = "0.9.8"
criteria = "safe-to-deploy"
[[exemptions.spki]]
@ -733,24 +709,20 @@ criteria = "safe-to-deploy"
version = "0.1.0"
criteria = "safe-to-run"
[[exemptions.strsim]]
version = "0.10.0"
criteria = "safe-to-deploy"
[[exemptions.symbolic-common]]
version = "12.8.0"
version = "12.12.0"
criteria = "safe-to-run"
[[exemptions.symbolic-demangle]]
version = "12.8.0"
version = "12.12.0"
criteria = "safe-to-run"
[[exemptions.syn]]
version = "1.0.102"
version = "2.0.87"
criteria = "safe-to-deploy"
[[exemptions.tar]]
version = "0.4.40"
version = "0.4.43"
criteria = "safe-to-deploy"
[[exemptions.tempfile]]
@ -781,36 +753,28 @@ criteria = "safe-to-deploy"
version = "0.1.44"
criteria = "safe-to-deploy"
[[exemptions.tinytemplate]]
version = "1.2.1"
criteria = "safe-to-run"
[[exemptions.tokio]]
version = "1.35.0"
version = "1.38.1"
criteria = "safe-to-run"
[[exemptions.tokio-macros]]
version = "2.2.0"
version = "2.3.0"
criteria = "safe-to-run"
[[exemptions.toml]]
version = "0.5.9"
criteria = "safe-to-deploy"
[[exemptions.toml]]
version = "0.7.6"
criteria = "safe-to-deploy"
[[exemptions.toml_edit]]
version = "0.19.14"
criteria = "safe-to-deploy"
criteria = "safe-to-run"
[[exemptions.trycmd]]
version = "0.14.16"
criteria = "safe-to-run"
[[exemptions.type-map]]
version = "0.4.0"
version = "0.5.0"
criteria = "safe-to-deploy"
[[exemptions.typenum]]
@ -821,24 +785,20 @@ criteria = "safe-to-deploy"
version = "0.1.4"
criteria = "safe-to-run"
[[exemptions.unic-langid]]
version = "0.9.4"
criteria = "safe-to-deploy"
[[exemptions.unic-langid-impl]]
version = "0.9.4"
[[exemptions.utf8parse]]
version = "0.2.2"
criteria = "safe-to-deploy"
[[exemptions.uuid]]
version = "1.7.0"
version = "1.11.0"
criteria = "safe-to-run"
[[exemptions.wait-timeout]]
version = "0.2.0"
criteria = "safe-to-run"
[[exemptions.version_check]]
version = "0.9.5"
criteria = "safe-to-deploy"
[[exemptions.walkdir]]
version = "2.4.0"
version = "2.5.0"
criteria = "safe-to-deploy"
[[exemptions.wasi]]
@ -846,23 +806,19 @@ version = "0.11.0+wasi-snapshot-preview1"
criteria = "safe-to-deploy"
[[exemptions.wasm-bindgen]]
version = "0.2.89"
version = "0.2.92"
criteria = "safe-to-deploy"
[[exemptions.wasm-bindgen-backend]]
version = "0.2.89"
version = "0.2.88"
criteria = "safe-to-deploy"
[[exemptions.wasm-bindgen-macro]]
version = "0.2.87"
criteria = "safe-to-deploy"
[[exemptions.wasm-bindgen-macro-support]]
version = "0.2.87"
criteria = "safe-to-deploy"
[[exemptions.web-sys]]
version = "0.3.66"
version = "0.3.65"
criteria = "safe-to-deploy"
[[exemptions.which]]
@ -878,7 +834,7 @@ version = "0.4.0"
criteria = "safe-to-deploy"
[[exemptions.winapi-util]]
version = "0.1.6"
version = "0.1.9"
criteria = "safe-to-deploy"
[[exemptions.winapi-x86_64-pc-windows-gnu]]
@ -889,32 +845,40 @@ criteria = "safe-to-deploy"
version = "0.52.0"
criteria = "safe-to-deploy"
[[exemptions.winnow]]
version = "0.5.37"
[[exemptions.windows_i686_gnullvm]]
version = "0.52.6"
criteria = "safe-to-deploy"
[[exemptions.winnow]]
version = "0.5.40"
criteria = "safe-to-run"
[[exemptions.wsl]]
version = "0.1.0"
criteria = "safe-to-deploy"
[[exemptions.x25519-dalek]]
version = "2.0.0"
criteria = "safe-to-deploy"
[[exemptions.xattr]]
version = "1.3.1"
version = "2.0.1"
criteria = "safe-to-deploy"
[[exemptions.zerocopy]]
version = "0.6.6"
criteria = "safe-to-deploy"
[[exemptions.zerocopy]]
version = "0.7.35"
criteria = "safe-to-deploy"
[[exemptions.zerocopy-derive]]
version = "0.6.6"
criteria = "safe-to-deploy"
[[exemptions.zerocopy-derive]]
version = "0.7.35"
criteria = "safe-to-deploy"
[[exemptions.zeroize]]
version = "1.7.0"
version = "1.8.1"
criteria = "safe-to-deploy"
[[exemptions.zeroize_derive]]
@ -934,5 +898,5 @@ version = "5.0.2+zstd.1.5.2"
criteria = "safe-to-deploy"
[[exemptions.zstd-sys]]
version = "2.0.9+zstd.1.5.5"
version = "2.0.13+zstd.1.5.6"
criteria = "safe-to-deploy"

File diff suppressed because it is too large Load diff

3
tap_migrations.json Normal file
View file

@ -0,0 +1,3 @@
{
"rage": "homebrew/core"
}