mirror of
https://github.com/FiloSottile/age.git
synced 2026-01-12 14:32:47 +00:00
Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0293aca1d7 | ||
|
|
e7601d8a67 | ||
|
|
acfa73142b | ||
|
|
b8564adb6d | ||
|
|
e4c611f778 | ||
|
|
6a8065f2da | ||
|
|
50a600eef5 | ||
|
|
13aab81842 | ||
|
|
52338c20df | ||
|
|
b70af41215 | ||
|
|
420273952a | ||
|
|
da2191789a | ||
|
|
2ff5d341f6 | ||
|
|
abe371e157 | ||
|
|
ec92694aad | ||
|
|
92ac13f51c | ||
|
|
a62324430d | ||
|
|
41167479ce | ||
|
|
4444afb267 | ||
|
|
db8ed63595 | ||
|
|
f1cc23a3f6 | ||
|
|
50a81fd5a9 | ||
|
|
44a4fcc27b | ||
|
|
2e0f1efe4d | ||
|
|
a7586b7557 | ||
|
|
de7813b5f6 | ||
|
|
2f5cf5438c | ||
|
|
830d84e777 | ||
|
|
b4cdeef465 | ||
|
|
6aae5b48ea | ||
|
|
d36e4ce2c7 | ||
|
|
ca8a69b1b6 | ||
|
|
38dd222823 | ||
|
|
c17d0b362c | ||
|
|
a36341de15 | ||
|
|
f3b008d1b8 | ||
|
|
9795b63263 | ||
|
|
bfae75d93d | ||
|
|
0d5b598cd0 | ||
|
|
50acf91174 | ||
|
|
ba67de8a4e | ||
|
|
7fa810b20a | ||
|
|
1b18d6b279 | ||
|
|
ad7bb569eb | ||
|
|
ed44098807 | ||
|
|
83bab2ae6a | ||
|
|
96b6476140 | ||
|
|
d7409cdc74 | ||
|
|
de158f906b | ||
|
|
78947d862d | ||
|
|
c6fcb5300c | ||
|
|
6ece9e45ee | ||
|
|
e2d30695f2 | ||
|
|
e9295dd867 | ||
|
|
acab3e5c9f | ||
|
|
a8de3de174 | ||
|
|
ae74b61b59 | ||
|
|
f882f40aa3 | ||
|
|
6d2c4e236c | ||
|
|
75063d25b1 | ||
|
|
20eba7e285 | ||
|
|
15153e699f | ||
|
|
fce45118ee | ||
|
|
c3657aca5c | ||
|
|
0447d8d089 | ||
|
|
3d91014ea0 | ||
|
|
482cf6fc9b | ||
|
|
cda3988cc7 | ||
|
|
176e245b3c | ||
|
|
faefdc3c81 | ||
|
|
bbe6ce5eeb | ||
|
|
1e1badabf7 | ||
|
|
2293a9afef | ||
|
|
01fe9cd84a | ||
|
|
bd0511b415 | ||
|
|
febaaded87 | ||
|
|
0a40718a93 |
30
.github/workflows/LICENSE.suffix.txt
vendored
Normal file
30
.github/workflows/LICENSE.suffix.txt
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
---
|
||||
|
||||
Copyright 2009 The Go Authors.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
* Neither the name of Google LLC nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
72
.github/workflows/build.yml
vendored
72
.github/workflows/build.yml
vendored
@@ -16,29 +16,28 @@ jobs:
|
||||
- {GOOS: linux, GOARCH: amd64}
|
||||
- {GOOS: linux, GOARCH: arm, GOARM: 6}
|
||||
- {GOOS: linux, GOARCH: arm64}
|
||||
- {GOOS: darwin, GOARCH: amd64}
|
||||
- {GOOS: darwin, GOARCH: arm64}
|
||||
- {GOOS: windows, GOARCH: amd64}
|
||||
- {GOOS: freebsd, GOARCH: amd64}
|
||||
steps:
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.x
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
- name: Build binary
|
||||
run: |
|
||||
cp LICENSE "$RUNNER_TEMP/LICENSE"
|
||||
echo -e "\n---\n" >> "$RUNNER_TEMP/LICENSE"
|
||||
curl -L "https://go.dev/LICENSE?m=text" >> "$RUNNER_TEMP/LICENSE"
|
||||
VERSION="$(git describe --tags)"
|
||||
DIR="$(mktemp -d)"
|
||||
mkdir "$DIR/age"
|
||||
cp "$RUNNER_TEMP/LICENSE" "$DIR/age"
|
||||
go build -o "$DIR/age" -ldflags "-X main.Version=$VERSION" -trimpath ./cmd/...
|
||||
go build -o "$DIR/age" -trimpath ./cmd/...
|
||||
cp LICENSE "$DIR/age/LICENSE"
|
||||
cat .github/workflows/LICENSE.suffix.txt >> "$DIR/age/LICENSE"
|
||||
if [ "$GOOS" == "windows" ]; then
|
||||
sudo apt-get update && sudo apt-get install -y osslsigncode
|
||||
if [ -n "${{ secrets.SIGN_PASS }}" ]; then
|
||||
@@ -62,22 +61,61 @@ jobs:
|
||||
GOARCH: ${{ matrix.GOARCH }}
|
||||
GOARM: ${{ matrix.GOARM }}
|
||||
- name: Upload workflow artifacts
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: age-binaries
|
||||
name: age-artifacts-${{ matrix.GOOS }}-${{ matrix.GOARCH }}
|
||||
path: age-*
|
||||
source:
|
||||
name: Package source code
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
- name: Create source tarball
|
||||
run: |
|
||||
VERSION="$(git describe --tags)"
|
||||
DIR="$(mktemp -d)"
|
||||
mkdir "$DIR/age"
|
||||
git archive --format=tar.gz HEAD | tar -xz -C "$DIR/age"
|
||||
( cd "$DIR/age"; go mod vendor )
|
||||
for cmd in "$DIR"/age/{cmd,extra}/*; do
|
||||
echo "package main" >> "$cmd/version.go"
|
||||
echo "" >> "$cmd/version.go"
|
||||
echo "func init() { Version = \"$VERSION\" }" >> "$cmd/version.go"
|
||||
done
|
||||
tar -cvzf "age-$VERSION-source.tar.gz" -C "$DIR" age
|
||||
- name: Upload workflow artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: age-artifacts-source
|
||||
path: age-*-source.tar.gz
|
||||
upload:
|
||||
name: Upload release binaries
|
||||
name: Upload and attest release artifacts
|
||||
if: github.event_name == 'release'
|
||||
needs: build
|
||||
needs: [build, source]
|
||||
permissions:
|
||||
contents: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download workflow artifacts
|
||||
uses: actions/download-artifact@v2
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: age-binaries
|
||||
pattern: age-artifacts-*
|
||||
merge-multiple: true
|
||||
- name: Generate artifacts attestation
|
||||
uses: actions/attest-build-provenance@v3
|
||||
with:
|
||||
subject-path: age-*
|
||||
- name: Upload release artifacts
|
||||
run: gh release upload "$GITHUB_REF_NAME" age-*
|
||||
env:
|
||||
|
||||
42
.github/workflows/ronn.yml
vendored
42
.github/workflows/ronn.yml
vendored
@@ -13,23 +13,23 @@ jobs:
|
||||
name: Ronn
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Install ronn
|
||||
run: sudo apt-get update && sudo apt-get install -y ronn
|
||||
- name: Run ronn
|
||||
run: bash -O globstar -c 'ronn **/*.ronn'
|
||||
- name: Undo email mangling
|
||||
# rdiscount randomizes the output for no good reason, which causes
|
||||
# changes to always get committed. Sigh.
|
||||
# https://github.com/davidfstr/rdiscount/blob/6b1471ec3/ext/generate.c#L781-L795
|
||||
run: |-
|
||||
for f in doc/*.html; do
|
||||
awk '/Filippo Valsorda/ { $0 = "<p>Filippo Valsorda <a href=\"mailto:age@filippo.io\" data-bare-link=\"true\">age@filippo.io</a></p>" } { print }' "$f" > "$f.tmp"
|
||||
mv "$f.tmp" "$f"
|
||||
done
|
||||
- name: Upload generated files
|
||||
uses: actions/upload-artifact@v3
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: geomys/sandboxed-step@v1.2.1
|
||||
with:
|
||||
persist-workspace-changes: true
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y ronn
|
||||
bash -O globstar -c 'ronn **/*.ronn'
|
||||
# rdiscount randomizes the output for no good reason, which causes
|
||||
# changes to always get committed. Sigh.
|
||||
# https://github.com/davidfstr/rdiscount/blob/6b1471ec3/ext/generate.c#L781-L795
|
||||
for f in doc/*.html; do
|
||||
awk '/Filippo Valsorda/ { $0 = "<p>Filippo Valsorda <a href=\"mailto:age@filippo.io\" data-bare-link=\"true\">age@filippo.io</a></p>" } { print }' "$f" > "$f.tmp"
|
||||
mv "$f.tmp" "$f"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: man-pages
|
||||
path: |
|
||||
@@ -42,10 +42,10 @@ jobs:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Download generated files
|
||||
uses: actions/download-artifact@v2
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: true
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: man-pages
|
||||
path: doc/
|
||||
|
||||
94
.github/workflows/test.yml
vendored
94
.github/workflows/test.yml
vendored
@@ -1,55 +1,75 @@
|
||||
name: Go tests
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
schedule: # daily at 09:42 UTC
|
||||
- cron: '42 9 * * *'
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
go: [1.19.x, 1.x]
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
go:
|
||||
- { go-version: stable }
|
||||
- { go-version: oldstable }
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- macos-latest
|
||||
- windows-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Install Go ${{ matrix.go }}
|
||||
uses: actions/setup-go@v5
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Run tests
|
||||
run: go test -race ./...
|
||||
gotip:
|
||||
name: Test (Go tip)
|
||||
go-version: ${{ matrix.go.go-version }}
|
||||
- run: |
|
||||
go test -race ./...
|
||||
test-latest:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
go:
|
||||
- { go-version: stable }
|
||||
- { go-version: oldstable }
|
||||
steps:
|
||||
- name: Install bootstrap Go
|
||||
uses: actions/setup-go@v5
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
go-version: 1.22
|
||||
- name: Install Go tip (UNIX)
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
git clone --filter=tree:0 https://go.googlesource.com/go $HOME/gotip
|
||||
cd $HOME/gotip/src && ./make.bash
|
||||
echo "$HOME/gotip/bin" >> $GITHUB_PATH
|
||||
- name: Install Go tip (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
git clone --filter=tree:0 https://go.googlesource.com/go $HOME/gotip
|
||||
cd $HOME/gotip/src && ./make.bat
|
||||
echo "$HOME/gotip/bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- run: go version
|
||||
- name: Run tests
|
||||
run: go test -race ./...
|
||||
go-version: ${{ matrix.go.go-version }}
|
||||
- uses: geomys/sandboxed-step@v1.2.1
|
||||
with:
|
||||
run: |
|
||||
go get -u -t ./...
|
||||
go test -race ./...
|
||||
staticcheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: stable
|
||||
- uses: geomys/sandboxed-step@v1.2.1
|
||||
with:
|
||||
run: go run honnef.co/go/tools/cmd/staticcheck@latest ./...
|
||||
govulncheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: stable
|
||||
- uses: geomys/sandboxed-step@v1.2.1
|
||||
with:
|
||||
run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...
|
||||
|
||||
6
AUTHORS
6
AUTHORS
@@ -1,6 +0,0 @@
|
||||
# This is the official list of age authors for copyright purposes.
|
||||
# To be included, send a change adding the individual or company
|
||||
# who owns a contribution's copyright.
|
||||
|
||||
Google LLC
|
||||
Filippo Valsorda
|
||||
2
LICENSE
2
LICENSE
@@ -1,4 +1,6 @@
|
||||
Copyright 2019 The age Authors
|
||||
Copyright 2019 Google LLC
|
||||
Copyright 2022 Filippo Valsorda
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
|
||||
73
README.md
73
README.md
@@ -12,7 +12,7 @@
|
||||
|
||||
age is a simple, modern and secure file encryption tool, format, and Go library.
|
||||
|
||||
It features small explicit keys, no config options, and UNIX-style composability.
|
||||
It features small explicit keys, post-quantum support, no config options, and UNIX-style composability.
|
||||
|
||||
```
|
||||
$ age-keygen -o key.txt
|
||||
@@ -21,17 +21,17 @@ $ tar cvz ~/data | age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9
|
||||
$ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
|
||||
```
|
||||
|
||||
📜 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).
|
||||
|
||||
📬 Follow the maintenance of this project by subscribing to [Maintainer Dispatches](https://filippo.io/newsletter)!
|
||||
📜 The format specification is at [age-encryption.org/v1](https://age-encryption.org/v1). age was designed by [@benjojo](https://github.com/benjojo) and [@FiloSottile](https://github.com/FiloSottile).
|
||||
|
||||
🦀 An alternative interoperable Rust implementation is available at [github.com/str4d/rage](https://github.com/str4d/rage).
|
||||
|
||||
🌍 [Typage](https://github.com/FiloSottile/typage) is a TypeScript implementation. It works in the browser, Node.js, Deno, and Bun.
|
||||
|
||||
🔑 Hardware PIV tokens such as YubiKeys are supported through the [age-plugin-yubikey](https://github.com/str4d/age-plugin-yubikey) plugin.
|
||||
|
||||
✨ For more plugins, implementations, tools, and integrations, check out the [awesome age](https://github.com/FiloSottile/awesome-age) list.
|
||||
|
||||
💬 The author pronounces it `[aɡe̞]` [with a hard *g*](https://translate.google.com/?sl=it&text=aghe), like GIF, and is always spelled lowercase.
|
||||
💬 The author pronounces it `[aɡe̞]` [with a hard *g*](https://translate.google.com/?sl=it&text=aghe), like GIF, and it's always spelled lowercase.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -48,6 +48,12 @@ $ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
|
||||
<code>port install age</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Windows</td>
|
||||
<td>
|
||||
<code>winget install --id FiloSottile.age</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Alpine Linux v3.15+</td>
|
||||
<td>
|
||||
@@ -85,6 +91,12 @@ $ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
|
||||
<code>emerge app-crypt/age</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Guix System</td>
|
||||
<td>
|
||||
<code>guix package -i age</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>NixOS / Nix</td>
|
||||
<td>
|
||||
@@ -139,10 +151,12 @@ On Windows, Linux, macOS, and FreeBSD you can use the pre-built binaries.
|
||||
|
||||
```
|
||||
https://dl.filippo.io/age/latest?for=linux/amd64
|
||||
https://dl.filippo.io/age/v1.1.1?for=darwin/arm64
|
||||
https://dl.filippo.io/age/v1.3.1?for=darwin/arm64
|
||||
...
|
||||
```
|
||||
|
||||
If you download the pre-built binaries, you can check their [Sigsum proofs](./SIGSUM.md).
|
||||
|
||||
If your system has [a supported version of Go](https://go.dev/dl/), you can build from source.
|
||||
|
||||
```
|
||||
@@ -215,6 +229,28 @@ $ age -R recipients.txt example.jpg > example.jpg.age
|
||||
|
||||
If the argument to `-R` (or `-i`) is `-`, the file is read from standard input.
|
||||
|
||||
### Post-quantum keys
|
||||
|
||||
To generate hybrid post-quantum keys, which are secure against future quantum
|
||||
computer attacks, use the `-pq` flag with `age-keygen`. This may become the
|
||||
default in the future.
|
||||
|
||||
Post-quantum identities start with `AGE-SECRET-KEY-PQ-1...` and recipients with
|
||||
`age1pq1...`. The recipients are unfortunately ~2000 characters long.
|
||||
|
||||
```
|
||||
$ age-keygen -pq -o key.txt
|
||||
$ age-keygen -y key.txt > recipient.txt
|
||||
$ age -R recipient.txt example.jpg > example.jpg.age
|
||||
$ age -d -i key.txt example.jpg.age > example.jpg
|
||||
```
|
||||
|
||||
Support for post-quantum keys is built into age v1.3.0 and later. Alternatively,
|
||||
the `age-plugin-pq` binary can be installed and placed in `$PATH` to add support
|
||||
to any version and implementation of age that supports plugins. Recipients will
|
||||
work out of the box, while identities will have to be converted to plugin
|
||||
identities with `age-plugin-pq -identity`.
|
||||
|
||||
### Passphrases
|
||||
|
||||
Files can be encrypted with a passphrase by using `-p/--passphrase`. By default age will automatically generate a secure passphrase. Passphrase protected files are automatically detected at decrypt time.
|
||||
@@ -263,3 +299,28 @@ $ curl https://github.com/benjojo.keys | age -R - example.jpg > example.jpg.age
|
||||
```
|
||||
|
||||
Keep in mind that people might not protect SSH keys long-term, since they are revokable when used only for authentication, and that SSH keys held on YubiKeys can't be used to decrypt files.
|
||||
|
||||
### Inspecting encrypted files
|
||||
|
||||
The `age-inspect` command can display metadata about an encrypted file without decrypting it, including the recipient types, whether it uses post-quantum encryption, and the payload size.
|
||||
|
||||
```
|
||||
$ age-inspect secrets.age
|
||||
secrets.age is an age file, version "age-encryption.org/v1".
|
||||
|
||||
This file is encrypted to the following recipient types:
|
||||
- "mlkem768x25519"
|
||||
|
||||
This file uses post-quantum encryption.
|
||||
|
||||
Size breakdown (assuming it decrypts successfully):
|
||||
|
||||
Header 1627 bytes
|
||||
Encryption overhead 32 bytes
|
||||
Payload 42 bytes
|
||||
-------------------
|
||||
Total 1701 bytes
|
||||
|
||||
```
|
||||
|
||||
For scripting, use `--json` to get machine-readable output.
|
||||
|
||||
42
SIGSUM.md
Normal file
42
SIGSUM.md
Normal file
@@ -0,0 +1,42 @@
|
||||
If you download the pre-built binaries of version v1.2.0+, you can check their
|
||||
[Sigsum](https://www.sigsum.org) proofs, which are like signatures with extra
|
||||
transparency: you can cryptographically verify that every proof is logged in a
|
||||
public append-only log, so the age project can be held accountable for every
|
||||
binary release we ever produced. This is similar to what the [Go Checksum
|
||||
Database](https://go.dev/blog/module-mirror-launch) provides.
|
||||
|
||||
```
|
||||
cat << EOF > age-sigsum-key.pub
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM1WpnEswJLPzvXJDiswowy48U+G+G1kmgwUE2eaRHZG
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAz2WM5CyPLqiNjk7CLl4roDXwKhQ0QExXLebukZEZFS
|
||||
EOF
|
||||
|
||||
curl -JLO "https://dl.filippo.io/age/v1.3.1?for=darwin/arm64"
|
||||
curl -JLO "https://dl.filippo.io/age/v1.3.1?for=darwin/arm64&proof"
|
||||
|
||||
go install sigsum.org/sigsum-go/cmd/sigsum-verify@v0.13.1
|
||||
sigsum-verify -k age-sigsum-key.pub -P sigsum-generic-2025-1 \
|
||||
age-v1.3.1-darwin-arm64.tar.gz.proof < age-v1.3.1-darwin-arm64.tar.gz
|
||||
```
|
||||
|
||||
You can learn more about what's happening above in the [Sigsum
|
||||
docs](https://www.sigsum.org/getting-started/).
|
||||
|
||||
### Release playbook
|
||||
|
||||
Dear future me, to sign a new release and produce Sigsum proofs, run the following
|
||||
|
||||
```
|
||||
VERSION=v1.3.1
|
||||
go install sigsum.org/sigsum-go/cmd/sigsum-verify@latest
|
||||
go install github.com/tillitis/tkey-ssh-agent/cmd/tkey-ssh-agent@latest
|
||||
tkey-ssh-agent --agent-socket tkey-ssh-agent.sock --uss
|
||||
SSH_AUTH_SOCK=tkey-ssh-agent.sock ssh-add -L > tkey-ssh-agent.pub
|
||||
passage other/sigsum-ratelimit > sigsum-ratelimit
|
||||
gh release download $VERSION --dir artifacts/
|
||||
SSH_AUTH_SOCK=tkey-ssh-agent.sock sigsum-submit -k tkey-ssh-agent.pub -P sigsum-generic-2025-1 -a sigsum-ratelimit -d filippo.io artifacts/*
|
||||
gh release upload $VERSION artifacts/*.proof
|
||||
```
|
||||
|
||||
In the future, we will move to reproducing the artifacts locally, and signing
|
||||
those instead of the ones built by GitHub Actions.
|
||||
325
age.go
325
age.go
@@ -5,10 +5,10 @@
|
||||
// Package age implements file encryption according to the age-encryption.org/v1
|
||||
// specification.
|
||||
//
|
||||
// For most use cases, use the Encrypt and Decrypt functions with
|
||||
// X25519Recipient and X25519Identity. If passphrase encryption is required, use
|
||||
// ScryptRecipient and ScryptIdentity. For compatibility with existing SSH keys
|
||||
// use the filippo.io/age/agessh package.
|
||||
// For most use cases, use the [Encrypt] and [Decrypt] functions with
|
||||
// [HybridRecipient] and [HybridIdentity]. If passphrase encryption is
|
||||
// required, use [ScryptRecipient] and [ScryptIdentity]. For compatibility with
|
||||
// existing SSH keys use the filippo.io/age/agessh package.
|
||||
//
|
||||
// age encrypted files are binary and not malleable. For encoding them as text,
|
||||
// use the filippo.io/age/armor package.
|
||||
@@ -26,13 +26,13 @@
|
||||
// There is no default path for age keys. Instead, they should be stored at
|
||||
// application-specific paths. The CLI supports files where private keys are
|
||||
// listed one per line, ignoring empty lines and lines starting with "#". These
|
||||
// files can be parsed with ParseIdentities.
|
||||
// files can be parsed with [ParseIdentities].
|
||||
//
|
||||
// When integrating age into a new system, it's recommended that you only
|
||||
// support X25519 keys, and not SSH keys. The latter are supported for manual
|
||||
// encryption operations. If you need to tie into existing key management
|
||||
// infrastructure, you might want to consider implementing your own Recipient
|
||||
// and Identity.
|
||||
// support native (X25519 and hybrid) keys, and not SSH keys. The latter are
|
||||
// supported for manual encryption operations. If you need to tie into existing
|
||||
// key management infrastructure, you might want to consider implementing your
|
||||
// own [Recipient] and [Identity].
|
||||
//
|
||||
// # Backwards compatibility
|
||||
//
|
||||
@@ -46,47 +46,47 @@
|
||||
package age
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"filippo.io/age/internal/format"
|
||||
"filippo.io/age/internal/stream"
|
||||
)
|
||||
|
||||
// An Identity is passed to Decrypt to unwrap an opaque file key from a
|
||||
// recipient stanza. It can be for example a secret key like X25519Identity, a
|
||||
// An Identity is passed to [Decrypt] to unwrap an opaque file key from a
|
||||
// recipient stanza. It can be for example a secret key like [HybridIdentity], a
|
||||
// plugin, or a custom implementation.
|
||||
//
|
||||
// Unwrap must return an error wrapping ErrIncorrectIdentity if none of the
|
||||
// recipient stanzas match the identity, any other error will be considered
|
||||
// fatal.
|
||||
//
|
||||
// Most age API users won't need to interact with this directly, and should
|
||||
// instead pass Recipient implementations to Encrypt and Identity
|
||||
// implementations to Decrypt.
|
||||
type Identity interface {
|
||||
// Unwrap must return an error wrapping [ErrIncorrectIdentity] if none of
|
||||
// the recipient stanzas match the identity, any other error will be
|
||||
// considered fatal.
|
||||
//
|
||||
// Most age API users won't need to interact with this method directly, and
|
||||
// should instead pass [Identity] implementations to [Decrypt].
|
||||
Unwrap(stanzas []*Stanza) (fileKey []byte, err error)
|
||||
}
|
||||
|
||||
// ErrIncorrectIdentity is returned by [Identity.Unwrap] if none of the
|
||||
// recipient stanzas match the identity.
|
||||
var ErrIncorrectIdentity = errors.New("incorrect identity for recipient block")
|
||||
|
||||
// A Recipient is passed to Encrypt to wrap an opaque file key to one or more
|
||||
// recipient stanza(s). It can be for example a public key like X25519Recipient,
|
||||
// A Recipient is passed to [Encrypt] to wrap an opaque file key to one or more
|
||||
// recipient stanza(s). It can be for example a public key like [HybridRecipient],
|
||||
// a plugin, or a custom implementation.
|
||||
//
|
||||
// Most age API users won't need to interact with this directly, and should
|
||||
// instead pass Recipient implementations to Encrypt and Identity
|
||||
// implementations to Decrypt.
|
||||
type Recipient interface {
|
||||
// Most age API users won't need to interact with this method directly, and
|
||||
// should instead pass [Recipient] implementations to [Encrypt].
|
||||
Wrap(fileKey []byte) ([]*Stanza, error)
|
||||
}
|
||||
|
||||
// RecipientWithLabels can be optionally implemented by a Recipient, in which
|
||||
// case Encrypt will use WrapWithLabels instead of Wrap.
|
||||
// RecipientWithLabels can be optionally implemented by a [Recipient], in which
|
||||
// case [Encrypt] will use WrapWithLabels instead of [Recipient.Wrap].
|
||||
//
|
||||
// Encrypt will succeed only if the labels returned by all the recipients
|
||||
// (assuming the empty set for those that don't implement RecipientWithLabels)
|
||||
@@ -103,9 +103,9 @@ type RecipientWithLabels interface {
|
||||
// A Stanza is a section of the age header that encapsulates the file key as
|
||||
// encrypted to a specific recipient.
|
||||
//
|
||||
// Most age API users won't need to interact with this directly, and should
|
||||
// instead pass Recipient implementations to Encrypt and Identity
|
||||
// implementations to Decrypt.
|
||||
// Most age API users won't need to interact with this type directly, and should
|
||||
// instead pass [Recipient] implementations to [Encrypt] and [Identity]
|
||||
// implementations to [Decrypt].
|
||||
type Stanza struct {
|
||||
Type string
|
||||
Args []string
|
||||
@@ -115,35 +115,23 @@ type Stanza struct {
|
||||
const fileKeySize = 16
|
||||
const streamNonceSize = 16
|
||||
|
||||
// Encrypt encrypts a file to one or more recipients.
|
||||
//
|
||||
// Writes to the returned WriteCloser are encrypted and written to dst as an age
|
||||
// file. Every recipient will be able to decrypt the file.
|
||||
//
|
||||
// The caller must call Close on the WriteCloser when done for the last chunk to
|
||||
// be encrypted and flushed to dst.
|
||||
func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) {
|
||||
func encryptHdr(fileKey []byte, recipients ...Recipient) (*format.Header, error) {
|
||||
if len(recipients) == 0 {
|
||||
return nil, errors.New("no recipients specified")
|
||||
}
|
||||
|
||||
fileKey := make([]byte, fileKeySize)
|
||||
if _, err := rand.Read(fileKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hdr := &format.Header{}
|
||||
var labels []string
|
||||
for i, r := range recipients {
|
||||
stanzas, l, err := wrapWithLabels(r, fileKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to wrap key for recipient #%d: %v", i, err)
|
||||
return nil, fmt.Errorf("failed to wrap key for recipient #%d: %w", i, err)
|
||||
}
|
||||
sort.Strings(l)
|
||||
if i == 0 {
|
||||
labels = l
|
||||
} else if !slicesEqual(labels, l) {
|
||||
return nil, fmt.Errorf("incompatible recipients")
|
||||
return nil, incompatibleLabelsError(labels, l)
|
||||
}
|
||||
for _, s := range stanzas {
|
||||
hdr.Recipients = append(hdr.Recipients, (*format.Stanza)(s))
|
||||
@@ -154,19 +142,62 @@ func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) {
|
||||
} else {
|
||||
hdr.MAC = mac
|
||||
}
|
||||
return hdr, nil
|
||||
}
|
||||
|
||||
// Encrypt encrypts a file to one or more recipients. Every recipient will be
|
||||
// able to decrypt the file.
|
||||
//
|
||||
// Writes to the returned WriteCloser are encrypted and written to dst as an age
|
||||
// file. The caller must call Close on the WriteCloser when done for the last
|
||||
// chunk to be encrypted and flushed to dst.
|
||||
func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) {
|
||||
fileKey := make([]byte, fileKeySize)
|
||||
rand.Read(fileKey)
|
||||
|
||||
hdr, err := encryptHdr(fileKey, recipients...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := hdr.Marshal(dst); err != nil {
|
||||
return nil, fmt.Errorf("failed to write header: %v", err)
|
||||
return nil, fmt.Errorf("failed to write header: %w", err)
|
||||
}
|
||||
|
||||
nonce := make([]byte, streamNonceSize)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rand.Read(nonce)
|
||||
if _, err := dst.Write(nonce); err != nil {
|
||||
return nil, fmt.Errorf("failed to write nonce: %v", err)
|
||||
return nil, fmt.Errorf("failed to write nonce: %w", err)
|
||||
}
|
||||
|
||||
return stream.NewWriter(streamKey(fileKey, nonce), dst)
|
||||
return stream.NewEncryptWriter(streamKey(fileKey, nonce), dst)
|
||||
}
|
||||
|
||||
// EncryptReader encrypts a file to one or more recipients. Every recipient will be
|
||||
// able to decrypt the file.
|
||||
//
|
||||
// Reads from the returned Reader produce the encrypted file, where the plaintext
|
||||
// is read from src.
|
||||
func EncryptReader(src io.Reader, recipients ...Recipient) (io.Reader, error) {
|
||||
fileKey := make([]byte, fileKeySize)
|
||||
rand.Read(fileKey)
|
||||
|
||||
hdr, err := encryptHdr(fileKey, recipients...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
if err := hdr.Marshal(buf); err != nil {
|
||||
return nil, fmt.Errorf("failed to prepare header: %w", err)
|
||||
}
|
||||
|
||||
nonce := make([]byte, streamNonceSize)
|
||||
rand.Read(nonce)
|
||||
|
||||
r, err := stream.NewEncryptReader(streamKey(fileKey, nonce), src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return io.MultiReader(buf, bytes.NewReader(nonce), r), nil
|
||||
}
|
||||
|
||||
func wrapWithLabels(r Recipient, fileKey []byte) (s []*Stanza, labels []string, err error) {
|
||||
@@ -189,39 +220,147 @@ func slicesEqual(s1, s2 []string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// NoIdentityMatchError is returned by Decrypt when none of the supplied
|
||||
func incompatibleLabelsError(l1, l2 []string) error {
|
||||
hasPQ1 := slices.Contains(l1, "postquantum")
|
||||
hasPQ2 := slices.Contains(l2, "postquantum")
|
||||
if hasPQ1 != hasPQ2 {
|
||||
return fmt.Errorf("incompatible recipients: can't mix post-quantum and classic recipients, or the file would be vulnerable to quantum computers")
|
||||
}
|
||||
return fmt.Errorf("incompatible recipients: %q and %q can't be mixed", l1, l2)
|
||||
}
|
||||
|
||||
// NoIdentityMatchError is returned by [Decrypt] when none of the supplied
|
||||
// identities match the encrypted file.
|
||||
type NoIdentityMatchError struct {
|
||||
// Errors is a slice of all the errors returned to Decrypt by the Unwrap
|
||||
// calls it made. They all wrap ErrIncorrectIdentity.
|
||||
// calls it made. They all wrap [ErrIncorrectIdentity].
|
||||
Errors []error
|
||||
// StanzaTypes are the first argument of each recipient stanza in the
|
||||
// encrypted file's header.
|
||||
StanzaTypes []string
|
||||
}
|
||||
|
||||
func (*NoIdentityMatchError) Error() string {
|
||||
func (e *NoIdentityMatchError) Error() string {
|
||||
if len(e.Errors) == 1 {
|
||||
return "identity did not match any of the recipients: " + e.Errors[0].Error()
|
||||
}
|
||||
return "no identity matched any of the recipients"
|
||||
}
|
||||
|
||||
// Decrypt decrypts a file encrypted to one or more identities.
|
||||
//
|
||||
// It returns a Reader reading the decrypted plaintext of the age file read
|
||||
// from src. All identities will be tried until one successfully decrypts the file.
|
||||
func Decrypt(src io.Reader, identities ...Identity) (io.Reader, error) {
|
||||
if len(identities) == 0 {
|
||||
return nil, errors.New("no identities specified")
|
||||
}
|
||||
func (e *NoIdentityMatchError) Unwrap() []error {
|
||||
return e.Errors
|
||||
}
|
||||
|
||||
// Decrypt decrypts a file encrypted to one or more identities.
|
||||
// All identities will be tried until one successfully decrypts the file.
|
||||
// Native, non-interactive identities are tried before any other identities.
|
||||
//
|
||||
// Decrypt returns a Reader reading the decrypted plaintext of the age file read
|
||||
// from src. If no identity matches the encrypted file, the returned error will
|
||||
// be of type [NoIdentityMatchError].
|
||||
func Decrypt(src io.Reader, identities ...Identity) (io.Reader, error) {
|
||||
hdr, payload, err := format.Parse(src)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read header: %w", err)
|
||||
}
|
||||
|
||||
fileKey, err := decryptHdr(hdr, identities...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonce := make([]byte, streamNonceSize)
|
||||
if _, err := io.ReadFull(payload, nonce); err != nil {
|
||||
return nil, fmt.Errorf("failed to read nonce: %w", err)
|
||||
}
|
||||
|
||||
return stream.NewDecryptReader(streamKey(fileKey, nonce), payload)
|
||||
}
|
||||
|
||||
// DecryptReaderAt decrypts a file encrypted to one or more identities.
|
||||
// All identities will be tried until one successfully decrypts the file.
|
||||
// Native, non-interactive identities are tried before any other identities.
|
||||
//
|
||||
// DecryptReaderAt takes an underlying [io.ReaderAt] and its total encrypted
|
||||
// size, and returns a ReaderAt of the decrypted plaintext and the plaintext
|
||||
// size. These can be used for example to instantiate an [io.SectionReader],
|
||||
// which implements [io.Reader] and [io.Seeker], or for [zip.NewReader].
|
||||
// Note that ReaderAt by definition disregards the seek position of src.
|
||||
//
|
||||
// The ReadAt method of the returned ReaderAt can be called concurrently.
|
||||
// The ReaderAt will internally cache the most recently decrypted chunk.
|
||||
// DecryptReaderAt reads and decrypts the final chunk before returning,
|
||||
// to authenticate the plaintext size.
|
||||
//
|
||||
// If no identity matches the encrypted file, the returned error will be of
|
||||
// type [NoIdentityMatchError].
|
||||
func DecryptReaderAt(src io.ReaderAt, encryptedSize int64, identities ...Identity) (io.ReaderAt, int64, error) {
|
||||
srcReader := io.NewSectionReader(src, 0, encryptedSize)
|
||||
hdr, payload, err := format.Parse(srcReader)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to read header: %w", err)
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
if err := hdr.Marshal(buf); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to serialize header: %w", err)
|
||||
}
|
||||
|
||||
fileKey, err := decryptHdr(hdr, identities...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
nonce := make([]byte, streamNonceSize)
|
||||
if _, err := io.ReadFull(payload, nonce); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to read nonce: %w", err)
|
||||
}
|
||||
|
||||
payloadOffset := int64(buf.Len()) + int64(len(nonce))
|
||||
payloadSize := encryptedSize - payloadOffset
|
||||
plaintextSize, err := stream.PlaintextSize(payloadSize)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
payloadReaderAt := io.NewSectionReader(src, payloadOffset, payloadSize)
|
||||
r, err := stream.NewDecryptReaderAt(streamKey(fileKey, nonce), payloadReaderAt, payloadSize)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return r, plaintextSize, nil
|
||||
}
|
||||
|
||||
func decryptHdr(hdr *format.Header, identities ...Identity) ([]byte, error) {
|
||||
if len(identities) == 0 {
|
||||
return nil, errors.New("no identities specified")
|
||||
}
|
||||
slices.SortStableFunc(identities, func(a, b Identity) int {
|
||||
var aIsNative, bIsNative bool
|
||||
switch a.(type) {
|
||||
case *X25519Identity, *HybridIdentity, *ScryptIdentity:
|
||||
aIsNative = true
|
||||
}
|
||||
switch b.(type) {
|
||||
case *X25519Identity, *HybridIdentity, *ScryptIdentity:
|
||||
bIsNative = true
|
||||
}
|
||||
if aIsNative && !bIsNative {
|
||||
return -1
|
||||
}
|
||||
if !aIsNative && bIsNative {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
stanzas := make([]*Stanza, 0, len(hdr.Recipients))
|
||||
errNoMatch := &NoIdentityMatchError{}
|
||||
for _, s := range hdr.Recipients {
|
||||
errNoMatch.StanzaTypes = append(errNoMatch.StanzaTypes, s.Type)
|
||||
stanzas = append(stanzas, (*Stanza)(s))
|
||||
}
|
||||
errNoMatch := &NoIdentityMatchError{}
|
||||
var fileKey []byte
|
||||
for _, id := range identities {
|
||||
var err error
|
||||
fileKey, err = id.Unwrap(stanzas)
|
||||
if errors.Is(err, ErrIncorrectIdentity) {
|
||||
errNoMatch.Errors = append(errNoMatch.Errors, err)
|
||||
@@ -243,12 +382,7 @@ func Decrypt(src io.Reader, identities ...Identity) (io.Reader, error) {
|
||||
return nil, errors.New("bad header MAC")
|
||||
}
|
||||
|
||||
nonce := make([]byte, streamNonceSize)
|
||||
if _, err := io.ReadFull(payload, nonce); err != nil {
|
||||
return nil, fmt.Errorf("failed to read nonce: %w", err)
|
||||
}
|
||||
|
||||
return stream.NewReader(streamKey(fileKey, nonce), payload)
|
||||
return fileKey, nil
|
||||
}
|
||||
|
||||
// multiUnwrap is a helper that implements Identity.Unwrap in terms of a
|
||||
@@ -269,3 +403,56 @@ func multiUnwrap(unwrap func(*Stanza) ([]byte, error), stanzas []*Stanza) ([]byt
|
||||
}
|
||||
return nil, ErrIncorrectIdentity
|
||||
}
|
||||
|
||||
// ExtractHeader returns a detached header from the src file.
|
||||
//
|
||||
// The detached header can be decrypted with [DecryptHeader] (for example on a
|
||||
// different system, without sharing the ciphertext) and then the file key can
|
||||
// be used with [NewInjectedFileKeyIdentity].
|
||||
//
|
||||
// This is a low-level function that most users won't need.
|
||||
func ExtractHeader(src io.Reader) ([]byte, error) {
|
||||
hdr, _, err := format.Parse(src)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read header: %w", err)
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
if err := hdr.Marshal(buf); err != nil {
|
||||
return nil, fmt.Errorf("failed to serialize header: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// DecryptHeader decrypts a detached header and returns a file key.
|
||||
//
|
||||
// The detached header can be produced by [ExtractHeader], and the
|
||||
// returned file key can be used with [NewInjectedFileKeyIdentity].
|
||||
//
|
||||
// This is a low-level function that most users won't need.
|
||||
// It is the caller's responsibility to keep track of what file the
|
||||
// returned file key decrypts, and to ensure the file key is not used
|
||||
// for any other purpose.
|
||||
func DecryptHeader(header []byte, identities ...Identity) ([]byte, error) {
|
||||
hdr, _, err := format.Parse(bytes.NewReader(header))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read header: %w", err)
|
||||
}
|
||||
return decryptHdr(hdr, identities...)
|
||||
}
|
||||
|
||||
type injectedFileKeyIdentity struct {
|
||||
fileKey []byte
|
||||
}
|
||||
|
||||
// NewInjectedFileKeyIdentity returns an [Identity] that always produces
|
||||
// a fixed file key, allowing the use of a file key obtained out-of-band,
|
||||
// for example via [DecryptHeader].
|
||||
//
|
||||
// This is a low-level function that most users won't need.
|
||||
func NewInjectedFileKeyIdentity(fileKey []byte) Identity {
|
||||
return injectedFileKeyIdentity{fileKey}
|
||||
}
|
||||
|
||||
func (i injectedFileKeyIdentity) Unwrap(stanzas []*Stanza) (fileKey []byte, err error) {
|
||||
return i.fileKey, nil
|
||||
}
|
||||
|
||||
265
age_test.go
265
age_test.go
@@ -5,11 +5,15 @@
|
||||
package age_test
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -189,6 +193,40 @@ func TestEncryptDecryptScrypt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func ExampleDecryptReaderAt() {
|
||||
identity, err := age.ParseX25519Identity(privateKey)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to parse private key: %v", err)
|
||||
}
|
||||
|
||||
f, err := os.Open("testdata/example.zip.age")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open file: %v", err)
|
||||
}
|
||||
stat, err := f.Stat()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to stat file: %v", err)
|
||||
}
|
||||
|
||||
r, size, err := age.DecryptReaderAt(f, stat.Size(), identity)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open encrypted file: %v", err)
|
||||
}
|
||||
|
||||
z, err := zip.NewReader(r, size)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open zip: %v", err)
|
||||
}
|
||||
contents, err := fs.ReadFile(z, "example.txt")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read file from zip: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("File contents: %q\n", contents)
|
||||
// Output:
|
||||
// File contents: "Black lives matter."
|
||||
}
|
||||
|
||||
func TestParseIdentities(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -284,3 +322,230 @@ func TestLabels(t *testing.T) {
|
||||
t.Errorf("expected pqc+foo mixed with foo+pqc to work, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// testIdentity is a non-native identity that records if Unwrap is called.
|
||||
type testIdentity struct {
|
||||
called bool
|
||||
}
|
||||
|
||||
func (ti *testIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
|
||||
ti.called = true
|
||||
return nil, age.ErrIncorrectIdentity
|
||||
}
|
||||
|
||||
func TestDecryptNativeIdentitiesFirst(t *testing.T) {
|
||||
correct, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unrelated, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
w, err := age.Encrypt(buf, correct.Recipient())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
nonNative := &testIdentity{}
|
||||
|
||||
// Pass identities: unrelated native, non-native, correct native.
|
||||
// Native identities should be tried first, so correct should match
|
||||
// before nonNative is ever called.
|
||||
_, err = age.Decrypt(bytes.NewReader(buf.Bytes()), unrelated, nonNative, correct)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if nonNative.called {
|
||||
t.Error("non-native identity was called, but native identities should be tried first")
|
||||
}
|
||||
}
|
||||
|
||||
type stanzaTypeRecipient string
|
||||
|
||||
func (s stanzaTypeRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
|
||||
return []*age.Stanza{{Type: string(s)}}, nil
|
||||
}
|
||||
|
||||
func TestNoIdentityMatchErrorStanzaTypes(t *testing.T) {
|
||||
a, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wrong, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
w, err := age.Encrypt(buf, a.Recipient(), stanzaTypeRecipient("other"), b.Recipient())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.WriteString(w, helloWorld); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = age.Decrypt(bytes.NewReader(buf.Bytes()), wrong)
|
||||
if err == nil {
|
||||
t.Fatal("expected decryption to fail")
|
||||
}
|
||||
|
||||
var noMatch *age.NoIdentityMatchError
|
||||
if !errors.As(err, &noMatch) {
|
||||
t.Fatalf("expected NoIdentityMatchError, got %T: %v", err, err)
|
||||
}
|
||||
|
||||
want := []string{"X25519", "other", "X25519"}
|
||||
if !slices.Equal(noMatch.StanzaTypes, want) {
|
||||
t.Errorf("StanzaTypes = %v, want %v", noMatch.StanzaTypes, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScryptIdentityErrors(t *testing.T) {
|
||||
t.Run("not passphrase-encrypted", func(t *testing.T) {
|
||||
i, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
w, err := age.Encrypt(buf, i.Recipient())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scryptID, err := age.NewScryptIdentity("password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = age.Decrypt(bytes.NewReader(buf.Bytes()), scryptID)
|
||||
if err == nil {
|
||||
t.Fatal("expected decryption to fail")
|
||||
}
|
||||
if !errors.Is(err, age.ErrIncorrectIdentity) {
|
||||
t.Errorf("expected ErrIncorrectIdentity, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not passphrase-encrypted") {
|
||||
t.Errorf("expected error to mention 'not passphrase-encrypted', got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("incorrect passphrase", func(t *testing.T) {
|
||||
r, err := age.NewScryptRecipient("correct-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.SetWorkFactor(10) // Low for fast test
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
w, err := age.Encrypt(buf, r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scryptID, err := age.NewScryptIdentity("wrong-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = age.Decrypt(bytes.NewReader(buf.Bytes()), scryptID)
|
||||
if err == nil {
|
||||
t.Fatal("expected decryption to fail")
|
||||
}
|
||||
if !errors.Is(err, age.ErrIncorrectIdentity) {
|
||||
t.Errorf("expected ErrIncorrectIdentity, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "incorrect passphrase") {
|
||||
t.Errorf("expected error to mention 'incorrect passphrase', got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDetachedHeader(t *testing.T) {
|
||||
i, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
w, err := age.Encrypt(buf, i.Recipient())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.WriteString(w, helloWorld); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encrypted := buf.Bytes()
|
||||
|
||||
header, err := age.ExtractHeader(bytes.NewReader(encrypted))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fileKey, err := age.DecryptHeader(header, i)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
identity := age.NewInjectedFileKeyIdentity(fileKey)
|
||||
out, err := age.Decrypt(bytes.NewReader(encrypted), identity)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outBytes, err := io.ReadAll(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(outBytes) != helloWorld {
|
||||
t.Errorf("wrong data: %q, expected %q", outBytes, helloWorld)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptReader(t *testing.T) {
|
||||
a, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := age.EncryptReader(strings.NewReader(helloWorld), a.Recipient())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
if _, err := io.Copy(buf, r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out, err := age.Decrypt(buf, a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outBytes, err := io.ReadAll(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(outBytes) != helloWorld {
|
||||
t.Errorf("wrong data: %q, excepted %q", outBytes, helloWorld)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// encryption with age-encryption.org/v1.
|
||||
//
|
||||
// These recipient types should only be used for compatibility with existing
|
||||
// keys, and native X25519 keys should be preferred otherwise.
|
||||
// keys, and native keys should be preferred otherwise.
|
||||
//
|
||||
// Note that these recipient types are not anonymous: the encrypted message will
|
||||
// include a short 32-bit ID of the public key.
|
||||
|
||||
@@ -140,6 +140,9 @@ func (r *armoredReader) Read(p []byte) (int, error) {
|
||||
if string(line) == Footer {
|
||||
return 0, r.setErr(drainTrailing())
|
||||
}
|
||||
if len(line) == 0 {
|
||||
return 0, r.setErr(errors.New("empty line in armored data"))
|
||||
}
|
||||
if len(line) > format.ColumnsPerLine {
|
||||
return 0, r.setErr(errors.New("column limit exceeded"))
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build go1.18
|
||||
// +build go1.18
|
||||
|
||||
package armor_test
|
||||
|
||||
|
||||
128
cmd/age-inspect/inspect.go
Normal file
128
cmd/age-inspect/inspect.go
Normal file
@@ -0,0 +1,128 @@
|
||||
// Copyright 2025 The age Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
|
||||
"filippo.io/age/internal/inspect"
|
||||
)
|
||||
|
||||
const usage = `Usage:
|
||||
age-inspect [--json] [INPUT]
|
||||
|
||||
Options:
|
||||
--json Output machine-readable JSON.
|
||||
|
||||
INPUT defaults to standard input. "-" may be used as INPUT to explicitly
|
||||
read from standard input.`
|
||||
|
||||
// Version can be set at link time to override debug.BuildInfo.Main.Version when
|
||||
// building manually without git history. It should look like "v1.2.3".
|
||||
var Version string
|
||||
|
||||
func main() {
|
||||
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
|
||||
|
||||
var (
|
||||
versionFlag bool
|
||||
jsonFlag bool
|
||||
)
|
||||
|
||||
flag.BoolVar(&versionFlag, "version", false, "print the version")
|
||||
flag.BoolVar(&jsonFlag, "json", false, "output machine-readable JSON")
|
||||
flag.Parse()
|
||||
|
||||
if versionFlag {
|
||||
if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
|
||||
Version = buildInfo.Main.Version
|
||||
}
|
||||
fmt.Println(Version)
|
||||
return
|
||||
}
|
||||
|
||||
if flag.NArg() > 1 {
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
in := os.Stdin
|
||||
var fileSize int64 = -1
|
||||
if name := flag.Arg(0); name != "" && name != "-" {
|
||||
f, err := os.Open(name)
|
||||
if err != nil {
|
||||
errorf("failed to open input file %q: %v", name, err)
|
||||
}
|
||||
defer f.Close()
|
||||
in = f
|
||||
if stat, err := f.Stat(); err == nil && stat.Mode().IsRegular() {
|
||||
fileSize = stat.Size()
|
||||
}
|
||||
}
|
||||
|
||||
data, err := inspect.Inspect(in, fileSize)
|
||||
if err != nil {
|
||||
errorf("inspection failed: %v", err)
|
||||
}
|
||||
|
||||
if jsonFlag {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(data); err != nil {
|
||||
errorf("failed to encode JSON output: %v", err)
|
||||
}
|
||||
} else {
|
||||
name := flag.Arg(0)
|
||||
if name == "" {
|
||||
name = "<stdin>"
|
||||
}
|
||||
fmt.Printf("%s is an age file, version %q.\n", name, data.Version)
|
||||
fmt.Printf("\n")
|
||||
if data.Armor {
|
||||
fmt.Printf("This file is ASCII-armored.\n")
|
||||
fmt.Printf("\n")
|
||||
}
|
||||
fmt.Printf("This file is encrypted to the following recipient types:\n")
|
||||
for _, t := range data.StanzaTypes {
|
||||
fmt.Printf(" - %q\n", t)
|
||||
}
|
||||
fmt.Printf("\n")
|
||||
switch data.Postquantum {
|
||||
case "yes":
|
||||
fmt.Printf("This file uses post-quantum encryption.\n")
|
||||
fmt.Printf("\n")
|
||||
case "no":
|
||||
fmt.Printf("This file does NOT use post-quantum encryption.\n")
|
||||
fmt.Printf("\n")
|
||||
}
|
||||
fmt.Printf("Size breakdown (assuming it decrypts successfully):\n")
|
||||
fmt.Printf("\n")
|
||||
fmt.Printf(" Header % 12d bytes\n", data.Sizes.Header)
|
||||
if data.Armor {
|
||||
fmt.Printf(" Armor overhead % 12d bytes\n", data.Sizes.Armor)
|
||||
}
|
||||
fmt.Printf(" Encryption overhead % 12d bytes\n", data.Sizes.Overhead)
|
||||
fmt.Printf(" Payload % 12d bytes\n", data.Sizes.MinPayload)
|
||||
fmt.Printf(" -------------------\n")
|
||||
total := data.Sizes.Header + data.Sizes.Overhead + data.Sizes.MinPayload + data.Sizes.Armor
|
||||
fmt.Printf(" Total % 12d bytes\n", total)
|
||||
fmt.Printf("\n")
|
||||
fmt.Printf("Tip: for machine-readable output, use --json.\n")
|
||||
}
|
||||
}
|
||||
|
||||
// l is a logger with no prefixes.
|
||||
var l = log.New(os.Stderr, "", 0)
|
||||
|
||||
func errorf(format string, v ...any) {
|
||||
l.Printf("age-inspect: error: "+format, v...)
|
||||
l.Printf("age-inspect: report unexpected or unhelpful errors at https://filippo.io/age/report")
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -18,15 +18,18 @@ import (
|
||||
)
|
||||
|
||||
const usage = `Usage:
|
||||
age-keygen [-o OUTPUT]
|
||||
age-keygen [-pq] [-o OUTPUT]
|
||||
age-keygen -y [-o OUTPUT] [INPUT]
|
||||
|
||||
Options:
|
||||
-pq Generate a post-quantum hybrid ML-KEM-768 + X25519 key pair.
|
||||
(This might become the default in the future.)
|
||||
-o, --output OUTPUT Write the result to the file at path OUTPUT.
|
||||
-y Convert an identity file to a recipients file.
|
||||
|
||||
age-keygen generates a new native X25519 key pair, and outputs it to
|
||||
standard output or to the OUTPUT file.
|
||||
age-keygen generates a new native X25519 or, with the -pq flag, post-quantum
|
||||
hybrid ML-KEM-768 + X25519 key pair, and outputs it to standard output or to
|
||||
the OUTPUT file.
|
||||
|
||||
If an OUTPUT file is specified, the public key is printed to standard error.
|
||||
If OUTPUT already exists, it is not overwritten.
|
||||
@@ -42,48 +45,51 @@ Examples:
|
||||
# public key: age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z
|
||||
AGE-SECRET-KEY-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9
|
||||
|
||||
$ age-keygen -pq
|
||||
# created: 2025-11-17T12:15:17+01:00
|
||||
# public key: age1pq1pd[... 1950 more characters ...]
|
||||
AGE-SECRET-KEY-PQ-1XXC4XS9DXHZ6TREKQTT3XECY8VNNU7GJ83C3Y49D0GZ3ZUME4JWS6QC3EF
|
||||
|
||||
$ age-keygen -o key.txt
|
||||
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
|
||||
|
||||
$ age-keygen -y key.txt
|
||||
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p`
|
||||
|
||||
// Version can be set at link time to override debug.BuildInfo.Main.Version,
|
||||
// which is "(devel)" when building from within the module. See
|
||||
// golang.org/issue/29814 and golang.org/issue/29228.
|
||||
// Version can be set at link time to override debug.BuildInfo.Main.Version when
|
||||
// building manually without git history. It should look like "v1.2.3".
|
||||
var Version string
|
||||
|
||||
func main() {
|
||||
log.SetFlags(0)
|
||||
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
|
||||
|
||||
var (
|
||||
versionFlag, convertFlag bool
|
||||
outFlag string
|
||||
)
|
||||
var outFlag string
|
||||
var pqFlag, versionFlag, convertFlag bool
|
||||
|
||||
flag.BoolVar(&versionFlag, "version", false, "print the version")
|
||||
flag.BoolVar(&pqFlag, "pq", false, "generate a post-quantum key pair")
|
||||
flag.BoolVar(&convertFlag, "y", false, "convert identities to recipients")
|
||||
flag.StringVar(&outFlag, "o", "", "output to `FILE` (default stdout)")
|
||||
flag.StringVar(&outFlag, "output", "", "output to `FILE` (default stdout)")
|
||||
flag.Parse()
|
||||
|
||||
if versionFlag {
|
||||
if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
|
||||
Version = buildInfo.Main.Version
|
||||
}
|
||||
fmt.Println(Version)
|
||||
return
|
||||
}
|
||||
|
||||
if len(flag.Args()) != 0 && !convertFlag {
|
||||
errorf("too many arguments")
|
||||
}
|
||||
if len(flag.Args()) > 1 && convertFlag {
|
||||
errorf("too many arguments")
|
||||
}
|
||||
if versionFlag {
|
||||
if Version != "" {
|
||||
fmt.Println(Version)
|
||||
return
|
||||
}
|
||||
if buildInfo, ok := debug.ReadBuildInfo(); ok {
|
||||
fmt.Println(buildInfo.Main.Version)
|
||||
return
|
||||
}
|
||||
fmt.Println("(unknown)")
|
||||
return
|
||||
if pqFlag && convertFlag {
|
||||
errorf("-pq cannot be used with -y")
|
||||
}
|
||||
|
||||
out := os.Stdout
|
||||
@@ -116,23 +122,36 @@ func main() {
|
||||
if fi, err := out.Stat(); err == nil && fi.Mode().IsRegular() && fi.Mode().Perm()&0004 != 0 {
|
||||
warning("writing secret key to a world-readable file")
|
||||
}
|
||||
generate(out)
|
||||
generate(out, pqFlag)
|
||||
}
|
||||
}
|
||||
|
||||
func generate(out *os.File) {
|
||||
k, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
errorf("internal error: %v", err)
|
||||
func generate(out *os.File, pq bool) {
|
||||
var i age.Identity
|
||||
var r age.Recipient
|
||||
if pq {
|
||||
k, err := age.GenerateHybridIdentity()
|
||||
if err != nil {
|
||||
errorf("internal error: %v", err)
|
||||
}
|
||||
i = k
|
||||
r = k.Recipient()
|
||||
} else {
|
||||
k, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
errorf("internal error: %v", err)
|
||||
}
|
||||
i = k
|
||||
r = k.Recipient()
|
||||
}
|
||||
|
||||
if !term.IsTerminal(int(out.Fd())) {
|
||||
fmt.Fprintf(os.Stderr, "Public key: %s\n", k.Recipient())
|
||||
fmt.Fprintf(os.Stderr, "Public key: %s\n", r)
|
||||
}
|
||||
|
||||
fmt.Fprintf(out, "# created: %s\n", time.Now().Format(time.RFC3339))
|
||||
fmt.Fprintf(out, "# public key: %s\n", k.Recipient())
|
||||
fmt.Fprintf(out, "%s\n", k)
|
||||
fmt.Fprintf(out, "# public key: %s\n", r)
|
||||
fmt.Fprintf(out, "%s\n", i)
|
||||
}
|
||||
|
||||
func convert(in io.Reader, out io.Writer) {
|
||||
@@ -144,19 +163,23 @@ func convert(in io.Reader, out io.Writer) {
|
||||
errorf("no identities found in the input")
|
||||
}
|
||||
for _, id := range ids {
|
||||
id, ok := id.(*age.X25519Identity)
|
||||
if !ok {
|
||||
switch id := id.(type) {
|
||||
case *age.X25519Identity:
|
||||
fmt.Fprintf(out, "%s\n", id.Recipient())
|
||||
case *age.HybridIdentity:
|
||||
fmt.Fprintf(out, "%s\n", id.Recipient())
|
||||
default:
|
||||
errorf("internal error: unexpected identity type: %T", id)
|
||||
}
|
||||
fmt.Fprintf(out, "%s\n", id.Recipient())
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func errorf(format string, v ...interface{}) {
|
||||
func errorf(format string, v ...any) {
|
||||
log.Printf("age-keygen: error: "+format, v...)
|
||||
log.Fatalf("age-keygen: report unexpected or unhelpful errors at https://filippo.io/age/report")
|
||||
}
|
||||
|
||||
func warning(msg string) {
|
||||
log.Printf("age-keygen: warning: " + msg)
|
||||
log.Printf("age-keygen: warning: %s", msg)
|
||||
}
|
||||
|
||||
214
cmd/age-plugin-batchpass/plugin-batchpass.go
Normal file
214
cmd/age-plugin-batchpass/plugin-batchpass.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"filippo.io/age"
|
||||
"filippo.io/age/plugin"
|
||||
)
|
||||
|
||||
const usage = `age-plugin-batchpass is an age plugin that enables non-interactive
|
||||
passphrase-based encryption and decryption using environment variables.
|
||||
|
||||
WARNING: IN 90% OF CASES, YOU DON'T NEED THIS PLUGIN.
|
||||
|
||||
This functionality is not built into the age CLI because most applications
|
||||
should use native keys instead of scripting passphrase-based encryption.
|
||||
|
||||
Humans are notoriously bad at remembering and generating strong passphrases.
|
||||
age uses scrypt to partially mitigate this, which is necessarily very slow.
|
||||
|
||||
If a computer will be doing the remembering anyway, you can and should use
|
||||
native keys instead. There is no need to manage separate public and private
|
||||
keys, you encrypt directly to the private key:
|
||||
|
||||
$ age-keygen -o key.txt
|
||||
$ age -e -i key.txt file.txt > file.txt.age
|
||||
$ age -d -i key.txt file.txt.age > file.txt
|
||||
|
||||
Likewise, you can store a native identity string in an environment variable
|
||||
or through your CI secrets manager and use it to encrypt and decrypt files
|
||||
non-interactively:
|
||||
|
||||
$ export AGE_SECRET=$(age-keygen)
|
||||
$ age -e -i <(echo "$AGE_SECRET") file.txt > file.txt.age
|
||||
$ age -d -i <(echo "$AGE_SECRET") file.txt.age > file.txt
|
||||
|
||||
The age CLI also natively supports passphrase-encrypted identity files, so you
|
||||
can use that functionality to non-interactively encrypt multiple files such that
|
||||
you will be able to decrypt them later by entering the same passphrase:
|
||||
|
||||
$ age-keygen -pq | age -p -o encrypted-identity.txt
|
||||
Public key: age1pq1cd[... 1950 more characters ...]
|
||||
Enter passphrase (leave empty to autogenerate a secure one):
|
||||
age: using autogenerated passphrase "eternal-erase-keen-suffer-fog-exclude-huge-scorpion-escape-scrub"
|
||||
$ age -r age1pq1cd[... 1950 more characters ...] file.txt > file.txt.age
|
||||
$ age -d -i encrypted-identity.txt file.txt.age > file.txt
|
||||
Enter passphrase for identity file "encrypted-identity.txt":
|
||||
|
||||
Finally, when using this plugin care should be taken not to let the password be
|
||||
persisted in the shell history or leaked to other users on multi-user systems.
|
||||
|
||||
Usage:
|
||||
|
||||
$ AGE_PASSPHRASE=password age -e -j batchpass file.txt > file.txt.age
|
||||
|
||||
$ AGE_PASSPHRASE=password age -d -j batchpass file.txt.age > file.txt
|
||||
|
||||
Alternatively, you can use AGE_PASSPHRASE_FD to read the passphrase from
|
||||
a file descriptor. Trailing newlines are stripped from the file contents.
|
||||
|
||||
When encrypting, you can set AGE_PASSPHRASE_WORK_FACTOR to adjust the scrypt
|
||||
work factor (between 1 and 30, default 18). Higher values are more secure
|
||||
but slower.
|
||||
|
||||
When decrypting, you can set AGE_PASSPHRASE_MAX_WORK_FACTOR to limit the
|
||||
maximum scrypt work factor accepted (between 1 and 30, default 30). This can
|
||||
be used to avoid very slow decryptions.`
|
||||
|
||||
// Version can be set at link time to override debug.BuildInfo.Main.Version when
|
||||
// building manually without git history. It should look like "v1.2.3".
|
||||
var Version string
|
||||
|
||||
func main() {
|
||||
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
|
||||
|
||||
p, err := plugin.New("batchpass")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
p.RegisterFlags(nil)
|
||||
|
||||
versionFlag := flag.Bool("version", false, "print the version")
|
||||
flag.Parse()
|
||||
|
||||
if *versionFlag {
|
||||
if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
|
||||
Version = buildInfo.Main.Version
|
||||
}
|
||||
fmt.Println(Version)
|
||||
return
|
||||
}
|
||||
|
||||
p.HandleIdentityAsRecipient(func(data []byte) (age.Recipient, error) {
|
||||
if len(data) != 0 {
|
||||
return nil, fmt.Errorf("batchpass identity does not take any payload")
|
||||
}
|
||||
pass, err := passphrase()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := age.NewScryptRecipient(pass)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create scrypt recipient: %v", err)
|
||||
}
|
||||
if envWorkFactor := os.Getenv("AGE_PASSPHRASE_WORK_FACTOR"); envWorkFactor != "" {
|
||||
workFactor, err := strconv.Atoi(envWorkFactor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid AGE_PASSPHRASE_WORK_FACTOR: %v", err)
|
||||
}
|
||||
if workFactor > 30 || workFactor < 1 {
|
||||
return nil, fmt.Errorf("AGE_PASSPHRASE_WORK_FACTOR must be between 1 and 30")
|
||||
}
|
||||
r.SetWorkFactor(workFactor)
|
||||
}
|
||||
return r, nil
|
||||
})
|
||||
p.HandleIdentity(func(data []byte) (age.Identity, error) {
|
||||
if len(data) != 0 {
|
||||
return nil, fmt.Errorf("batchpass identity does not take any payload")
|
||||
}
|
||||
pass, err := passphrase()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
maxWorkFactor := 0
|
||||
if envMaxWorkFactor := os.Getenv("AGE_PASSPHRASE_MAX_WORK_FACTOR"); envMaxWorkFactor != "" {
|
||||
maxWorkFactor, err = strconv.Atoi(envMaxWorkFactor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid AGE_PASSPHRASE_MAX_WORK_FACTOR: %v", err)
|
||||
}
|
||||
if maxWorkFactor > 30 || maxWorkFactor < 1 {
|
||||
return nil, fmt.Errorf("AGE_PASSPHRASE_MAX_WORK_FACTOR must be between 1 and 30")
|
||||
}
|
||||
}
|
||||
return &batchpassIdentity{password: pass, maxWorkFactor: maxWorkFactor}, nil
|
||||
})
|
||||
os.Exit(p.Main())
|
||||
}
|
||||
|
||||
type batchpassIdentity struct {
|
||||
password string
|
||||
maxWorkFactor int
|
||||
}
|
||||
|
||||
func (i *batchpassIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
|
||||
for _, s := range stanzas {
|
||||
if s.Type == "scrypt" && len(stanzas) != 1 {
|
||||
return nil, errors.New("an scrypt recipient must be the only one")
|
||||
}
|
||||
}
|
||||
if len(stanzas) != 1 || stanzas[0].Type != "scrypt" {
|
||||
// Don't fallback to other identities, this plugin should mostly be used
|
||||
// in isolation, from the CLI.
|
||||
return nil, fmt.Errorf("file is not passphrase-encrypted")
|
||||
}
|
||||
ii, err := age.NewScryptIdentity(i.password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if i.maxWorkFactor != 0 {
|
||||
ii.SetMaxWorkFactor(i.maxWorkFactor)
|
||||
}
|
||||
fileKey, err := ii.Unwrap(stanzas)
|
||||
if errors.Is(err, age.ErrIncorrectIdentity) {
|
||||
// ScryptIdentity returns ErrIncorrectIdentity to make it possible to
|
||||
// try multiple passphrases from the API. If a user is invoking this
|
||||
// plugin, it's safe to say they expect it to be the only mechanism to
|
||||
// decrypt a passphrase-protected file.
|
||||
return nil, fmt.Errorf("incorrect passphrase")
|
||||
}
|
||||
return fileKey, err
|
||||
}
|
||||
|
||||
func passphrase() (string, error) {
|
||||
envPASSPHRASE := os.Getenv("AGE_PASSPHRASE")
|
||||
envFD := os.Getenv("AGE_PASSPHRASE_FD")
|
||||
if envPASSPHRASE != "" && envFD != "" {
|
||||
return "", fmt.Errorf("AGE_PASSPHRASE and AGE_PASSPHRASE_FD are mutually exclusive")
|
||||
}
|
||||
if envPASSPHRASE == "" && envFD == "" {
|
||||
return "", fmt.Errorf("either AGE_PASSPHRASE or AGE_PASSPHRASE_FD must be set")
|
||||
}
|
||||
|
||||
if envPASSPHRASE != "" {
|
||||
return envPASSPHRASE, nil
|
||||
}
|
||||
|
||||
fd, err := strconv.Atoi(envFD)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid AGE_PASSPHRASE_FD: %v", err)
|
||||
}
|
||||
f := os.NewFile(uintptr(fd), "AGE_PASSPHRASE_FD")
|
||||
if f == nil {
|
||||
return "", fmt.Errorf("failed to open file descriptor %d", fd)
|
||||
}
|
||||
defer f.Close()
|
||||
const maxPassphraseSize = 1024 * 1024 // 1 MiB
|
||||
b, err := io.ReadAll(io.LimitReader(f, maxPassphraseSize+1))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read passphrase from fd %d: %v", fd, err)
|
||||
}
|
||||
if len(b) > maxPassphraseSize {
|
||||
return "", fmt.Errorf("passphrase from fd %d is too long", fd)
|
||||
}
|
||||
return strings.TrimRight(string(b), "\r\n"), nil
|
||||
}
|
||||
155
cmd/age/age.go
155
cmd/age/age.go
@@ -7,19 +7,24 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"iter"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"filippo.io/age"
|
||||
"filippo.io/age/agessh"
|
||||
"filippo.io/age/armor"
|
||||
"filippo.io/age/internal/term"
|
||||
"filippo.io/age/plugin"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const usage = `Usage:
|
||||
@@ -62,11 +67,6 @@ Example:
|
||||
$ tar cvz ~/data | age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p > data.tar.gz.age
|
||||
$ age --decrypt -i key.txt -o data.tar.gz data.tar.gz.age`
|
||||
|
||||
// Version can be set at link time to override debug.BuildInfo.Main.Version,
|
||||
// which is "(devel)" when building from within the module. See
|
||||
// golang.org/issue/29814 and golang.org/issue/29228.
|
||||
var Version string
|
||||
|
||||
// stdinInUse is used to ensure only one of input, recipients, or identities
|
||||
// file is read from stdin. It's a singleton like os.Stdin.
|
||||
var stdinInUse bool
|
||||
@@ -98,12 +98,16 @@ func (f *identityFlags) addPluginFlag(value string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Version can be set at link time to override debug.BuildInfo.Main.Version when
|
||||
// building manually without git history. It should look like "v1.2.3".
|
||||
var Version string
|
||||
|
||||
func main() {
|
||||
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
|
||||
|
||||
if len(os.Args) == 1 {
|
||||
flag.Usage()
|
||||
exit(1)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -136,17 +140,10 @@ func main() {
|
||||
flag.Parse()
|
||||
|
||||
if versionFlag {
|
||||
if Version != "" {
|
||||
fmt.Println(Version)
|
||||
return
|
||||
if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
|
||||
Version = buildInfo.Main.Version
|
||||
}
|
||||
if buildInfo, ok := debug.ReadBuildInfo(); ok {
|
||||
// TODO: use buildInfo.Settings to prepare a pseudoversion such as
|
||||
// v0.0.0-20210817164053-32db794688a5+dirty on Go 1.18+.
|
||||
fmt.Println(buildInfo.Main.Version)
|
||||
return
|
||||
}
|
||||
fmt.Println("(unknown)")
|
||||
fmt.Println(Version)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -161,11 +158,8 @@ func main() {
|
||||
|
||||
safe := true
|
||||
unsafeShell := regexp.MustCompile(`[^\w@%+=:,./-]`)
|
||||
for _, arg := range os.Args {
|
||||
if unsafeShell.MatchString(arg) {
|
||||
safe = false
|
||||
break
|
||||
}
|
||||
if slices.ContainsFunc(os.Args, unsafeShell.MatchString) {
|
||||
safe = false
|
||||
}
|
||||
if safe {
|
||||
i := len(os.Args) - flag.NArg()
|
||||
@@ -189,7 +183,7 @@ func main() {
|
||||
}
|
||||
if armorFlag {
|
||||
errorWithHint("-a/--armor can't be used with -d/--decrypt",
|
||||
"note that armored files are detected automatically")
|
||||
"note that armored files are detected automatically, try again without -a/--armor")
|
||||
}
|
||||
if passFlag {
|
||||
errorWithHint("-p/--passphrase can't be used with -d/--decrypt",
|
||||
@@ -223,9 +217,31 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
warnDuplicates(slices.Values(recipientFlags), "recipient")
|
||||
warnDuplicates(slices.Values(recipientsFileFlags), "recipients file")
|
||||
warnDuplicates(func(yield func(string) bool) {
|
||||
for _, f := range identityFlags {
|
||||
if f.Type == "i" && !yield(f.Value) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}, "identity file")
|
||||
|
||||
var inUseFiles []string
|
||||
for _, i := range identityFlags {
|
||||
if i.Type != "i" {
|
||||
continue
|
||||
}
|
||||
inUseFiles = append(inUseFiles, absPath(i.Value))
|
||||
}
|
||||
for _, f := range recipientsFileFlags {
|
||||
inUseFiles = append(inUseFiles, absPath(f))
|
||||
}
|
||||
|
||||
var in io.Reader = os.Stdin
|
||||
var out io.Writer = os.Stdout
|
||||
if name := flag.Arg(0); name != "" && name != "-" {
|
||||
inUseFiles = append(inUseFiles, absPath(name))
|
||||
f, err := os.Open(name)
|
||||
if err != nil {
|
||||
errorf("failed to open input file %q: %v", name, err)
|
||||
@@ -234,7 +250,7 @@ func main() {
|
||||
in = f
|
||||
} else {
|
||||
stdinInUse = true
|
||||
if decryptFlag && term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
if decryptFlag && term.IsTerminal(os.Stdin) {
|
||||
// If the input comes from a TTY, assume it's armored, and buffer up
|
||||
// to the END line (or EOF/EOT) so that a password prompt or the
|
||||
// output don't get in the way of typing the input. See Issue 364.
|
||||
@@ -246,6 +262,11 @@ func main() {
|
||||
}
|
||||
}
|
||||
if name := outFlag; name != "" && name != "-" {
|
||||
for _, f := range inUseFiles {
|
||||
if f == absPath(name) {
|
||||
errorf("input and output file are the same: %q", name)
|
||||
}
|
||||
}
|
||||
f := newLazyOpener(name)
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
@@ -253,10 +274,25 @@ func main() {
|
||||
}
|
||||
}()
|
||||
out = f
|
||||
} else if term.IsTerminal(int(os.Stdout.Fd())) {
|
||||
} else if term.IsTerminal(os.Stdout) {
|
||||
buf := &bytes.Buffer{}
|
||||
defer func() {
|
||||
if out == buf {
|
||||
io.Copy(os.Stdout, buf)
|
||||
}
|
||||
}()
|
||||
if name != "-" {
|
||||
if decryptFlag {
|
||||
// TODO: buffer the output and check it's printable.
|
||||
// Buffer the output to check it's printable.
|
||||
out = buf
|
||||
defer func() {
|
||||
if bytes.ContainsFunc(buf.Bytes(), func(r rune) bool {
|
||||
return r != '\n' && r != '\r' && r != '\t' && unicode.IsControl(r)
|
||||
}) {
|
||||
errorWithHint("refusing to output binary to the terminal",
|
||||
`force anyway with "-o -"`)
|
||||
}
|
||||
}()
|
||||
} else if !armorFlag {
|
||||
// If the output wouldn't be armored, refuse to send binary to
|
||||
// the terminal unless explicitly requested with "-o -".
|
||||
@@ -265,11 +301,9 @@ func main() {
|
||||
`force anyway with "-o -"`)
|
||||
}
|
||||
}
|
||||
if in == os.Stdin && term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
if in == os.Stdin && term.IsTerminal(os.Stdin) {
|
||||
// If the input comes from a TTY and output will go to a TTY,
|
||||
// buffer it up so it doesn't get in the way of typing the input.
|
||||
buf := &bytes.Buffer{}
|
||||
defer func() { io.Copy(os.Stdout, buf) }()
|
||||
out = buf
|
||||
}
|
||||
}
|
||||
@@ -287,14 +321,14 @@ func main() {
|
||||
}
|
||||
|
||||
func passphrasePromptForEncryption() (string, error) {
|
||||
pass, err := readSecret("Enter passphrase (leave empty to autogenerate a secure one):")
|
||||
pass, err := term.ReadSecret("Enter passphrase (leave empty to autogenerate a secure one):")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not read passphrase: %v", err)
|
||||
}
|
||||
p := string(pass)
|
||||
if p == "" {
|
||||
var words []string
|
||||
for i := 0; i < 10; i++ {
|
||||
for range 10 {
|
||||
words = append(words, randomWord())
|
||||
}
|
||||
p = strings.Join(words, "-")
|
||||
@@ -303,7 +337,7 @@ func passphrasePromptForEncryption() (string, error) {
|
||||
return "", fmt.Errorf("could not print passphrase: %v", err)
|
||||
}
|
||||
} else {
|
||||
confirm, err := readSecret("Confirm passphrase:")
|
||||
confirm, err := term.ReadSecret("Confirm passphrase:")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not read passphrase: %v", err)
|
||||
}
|
||||
@@ -348,7 +382,7 @@ func encryptNotPass(recs, files []string, identities identityFlags, in io.Reader
|
||||
}
|
||||
recipients = append(recipients, r...)
|
||||
case "j":
|
||||
id, err := plugin.NewIdentityWithoutData(f.Value, pluginTerminalUI)
|
||||
id, err := plugin.NewIdentityWithoutData(f.Value, plugin.NewTerminalUI(printf, warningf))
|
||||
if err != nil {
|
||||
errorf("initializing %q: %v", f.Value, err)
|
||||
}
|
||||
@@ -385,7 +419,11 @@ func encrypt(recipients []age.Recipient, in io.Reader, out io.Writer, withArmor
|
||||
out = a
|
||||
}
|
||||
w, err := age.Encrypt(out, recipients...)
|
||||
if err != nil {
|
||||
if e := new(plugin.NotFoundError); errors.As(err, &e) {
|
||||
errorWithHint(err.Error(),
|
||||
fmt.Sprintf("you might want to install the %q plugin", e.Name),
|
||||
"visit https://age-encryption.org/awesome#plugins for a list of available plugins")
|
||||
} else if err != nil {
|
||||
errorf("%v", err)
|
||||
}
|
||||
if _, err := io.Copy(w, in); err != nil {
|
||||
@@ -414,8 +452,7 @@ func (rejectScryptIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
|
||||
}
|
||||
|
||||
func decryptNotPass(flags identityFlags, in io.Reader, out io.Writer) {
|
||||
identities := []age.Identity{rejectScryptIdentity{}}
|
||||
|
||||
var identities []age.Identity
|
||||
for _, f := range flags {
|
||||
switch f.Type {
|
||||
case "i":
|
||||
@@ -425,14 +462,14 @@ func decryptNotPass(flags identityFlags, in io.Reader, out io.Writer) {
|
||||
}
|
||||
identities = append(identities, ids...)
|
||||
case "j":
|
||||
id, err := plugin.NewIdentityWithoutData(f.Value, pluginTerminalUI)
|
||||
id, err := plugin.NewIdentityWithoutData(f.Value, plugin.NewTerminalUI(printf, warningf))
|
||||
if err != nil {
|
||||
errorf("initializing %q: %v", f.Value, err)
|
||||
}
|
||||
identities = append(identities, id)
|
||||
}
|
||||
}
|
||||
|
||||
identities = append(identities, rejectScryptIdentity{})
|
||||
decrypt(identities, in, out)
|
||||
}
|
||||
|
||||
@@ -440,7 +477,7 @@ func decryptPass(in io.Reader, out io.Writer) {
|
||||
identities := []age.Identity{
|
||||
// If there is an scrypt recipient (it will have to be the only one and)
|
||||
// this identity will be invoked.
|
||||
&LazyScryptIdentity{passphrasePromptForDecryption},
|
||||
lazyScryptIdentity,
|
||||
}
|
||||
|
||||
decrypt(identities, in, out)
|
||||
@@ -455,23 +492,36 @@ func decrypt(identities []age.Identity, in io.Reader, out io.Writer) {
|
||||
"consider using -o or -a to encrypt files in PowerShell")
|
||||
}
|
||||
|
||||
if start, _ := rr.Peek(len(armor.Header)); string(start) == armor.Header {
|
||||
const maxWhitespace = 1024
|
||||
start, _ := rr.Peek(maxWhitespace + len(armor.Header))
|
||||
if strings.HasPrefix(string(bytes.TrimSpace(start)), armor.Header) {
|
||||
in = armor.NewReader(rr)
|
||||
} else {
|
||||
in = rr
|
||||
}
|
||||
|
||||
r, err := age.Decrypt(in, identities...)
|
||||
if err != nil {
|
||||
if e := new(plugin.NotFoundError); errors.As(err, &e) {
|
||||
errorWithHint(err.Error(),
|
||||
fmt.Sprintf("you might want to install the %q plugin", e.Name),
|
||||
"visit https://age-encryption.org/awesome#plugins for a list of available plugins")
|
||||
} else if errors.As(err, new(*age.NoIdentityMatchError)) &&
|
||||
len(identities) == 1 && identities[0] == lazyScryptIdentity {
|
||||
errorWithHint("the file is not passphrase-encrypted, identities are required",
|
||||
"specify identities with -i/--identity or -j to decrypt this file")
|
||||
} else if err != nil {
|
||||
errorf("%v", err)
|
||||
}
|
||||
out.Write(nil) // trigger the lazyOpener even if r is empty
|
||||
if _, err := io.Copy(out, r); err != nil {
|
||||
errorf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var lazyScryptIdentity = &LazyScryptIdentity{passphrasePromptForDecryption}
|
||||
|
||||
func passphrasePromptForDecryption() (string, error) {
|
||||
pass, err := readSecret("Enter passphrase:")
|
||||
pass, err := term.ReadSecret("Enter passphrase:")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not read passphrase: %v", err)
|
||||
}
|
||||
@@ -484,6 +534,8 @@ func identitiesToRecipients(ids []age.Identity) ([]age.Recipient, error) {
|
||||
switch id := id.(type) {
|
||||
case *age.X25519Identity:
|
||||
recipients = append(recipients, id.Recipient())
|
||||
case *age.HybridIdentity:
|
||||
recipients = append(recipients, id.Recipient())
|
||||
case *plugin.Identity:
|
||||
recipients = append(recipients, id.Recipient())
|
||||
case *agessh.RSAIdentity:
|
||||
@@ -531,3 +583,22 @@ func (l *lazyOpener) Close() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func absPath(name string) string {
|
||||
if abs, err := filepath.Abs(name); err == nil {
|
||||
return abs
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func warnDuplicates(s iter.Seq[string], name string) {
|
||||
seen := make(map[string]bool)
|
||||
warned := make(map[string]bool)
|
||||
for e := range s {
|
||||
if seen[e] && !warned[e] {
|
||||
warningf("duplicate %s %q", name, e)
|
||||
warned[e] = true
|
||||
}
|
||||
seen[e] = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,79 +5,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"filippo.io/age/plugin"
|
||||
"github.com/rogpeppe/go-internal/testscript"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(testscript.RunMain(m, map[string]func() int{
|
||||
"age": func() (exitCode int) {
|
||||
testOnlyPanicInsteadOfExit = true
|
||||
defer func() {
|
||||
if testOnlyDidExit {
|
||||
exitCode = recover().(int)
|
||||
}
|
||||
}()
|
||||
testscript.Main(m, map[string]func(){
|
||||
"age": func() {
|
||||
testOnlyConfigureScryptIdentity = func(r *age.ScryptRecipient) {
|
||||
r.SetWorkFactor(10)
|
||||
}
|
||||
testOnlyFixedRandomWord = "four"
|
||||
main()
|
||||
return 0
|
||||
},
|
||||
"age-plugin-test": func() (exitCode int) {
|
||||
// TODO: use plugin server package once it's available.
|
||||
switch os.Args[1] {
|
||||
case "--age-plugin=recipient-v1":
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
scanner.Scan() // add-recipient
|
||||
scanner.Scan() // body
|
||||
scanner.Scan() // grease
|
||||
scanner.Scan() // body
|
||||
scanner.Scan() // wrap-file-key
|
||||
scanner.Scan() // body
|
||||
fileKey := scanner.Text()
|
||||
scanner.Scan() // extension-labels
|
||||
scanner.Scan() // body
|
||||
scanner.Scan() // done
|
||||
scanner.Scan() // body
|
||||
os.Stdout.WriteString("-> recipient-stanza 0 test\n")
|
||||
os.Stdout.WriteString(fileKey + "\n")
|
||||
scanner.Scan() // ok
|
||||
scanner.Scan() // body
|
||||
os.Stdout.WriteString("-> done\n\n")
|
||||
return 0
|
||||
case "--age-plugin=identity-v1":
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
scanner.Scan() // add-identity
|
||||
scanner.Scan() // body
|
||||
scanner.Scan() // grease
|
||||
scanner.Scan() // body
|
||||
scanner.Scan() // recipient-stanza
|
||||
scanner.Scan() // body
|
||||
fileKey := scanner.Text()
|
||||
scanner.Scan() // done
|
||||
scanner.Scan() // body
|
||||
os.Stdout.WriteString("-> file-key 0\n")
|
||||
os.Stdout.WriteString(fileKey + "\n")
|
||||
scanner.Scan() // ok
|
||||
scanner.Scan() // body
|
||||
os.Stdout.WriteString("-> done\n\n")
|
||||
return 0
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
"age-plugin-test": func() {
|
||||
p, _ := plugin.New("test")
|
||||
p.HandleRecipient(func(data []byte) (age.Recipient, error) {
|
||||
return testPlugin{}, nil
|
||||
})
|
||||
p.HandleIdentity(func(data []byte) (age.Identity, error) {
|
||||
return testPlugin{}, nil
|
||||
})
|
||||
os.Exit(p.Main())
|
||||
},
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
type testPlugin struct{}
|
||||
|
||||
func (testPlugin) Wrap(fileKey []byte) ([]*age.Stanza, error) {
|
||||
return []*age.Stanza{{Type: "test", Body: fileKey}}, nil
|
||||
}
|
||||
|
||||
func (testPlugin) Unwrap(ss []*age.Stanza) ([]byte, error) {
|
||||
if len(ss) == 1 && ss[0].Type == "test" {
|
||||
return ss[0].Body, nil
|
||||
}
|
||||
return nil, age.ErrIncorrectIdentity
|
||||
}
|
||||
|
||||
var buildExtraCommands = sync.OnceValue(func() error {
|
||||
bindir := filepath.SplitList(os.Getenv("PATH"))[0]
|
||||
// Build age-keygen and age-plugin-pq into the test binary directory.
|
||||
cmd := exec.Command("go", "build", "-o", bindir)
|
||||
if testing.CoverMode() != "" {
|
||||
cmd.Args = append(cmd.Args, "-cover")
|
||||
}
|
||||
cmd.Args = append(cmd.Args, "filippo.io/age/cmd/age-keygen")
|
||||
cmd.Args = append(cmd.Args, "filippo.io/age/extra/age-plugin-pq")
|
||||
cmd.Args = append(cmd.Args, "filippo.io/age/cmd/age-plugin-batchpass")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
})
|
||||
|
||||
func TestScript(t *testing.T) {
|
||||
testscript.Run(t, testscript.Params{
|
||||
Dir: "testdata",
|
||||
Setup: func(e *testscript.Env) error {
|
||||
return buildExtraCommands()
|
||||
},
|
||||
// TODO: enable AGEDEBUG=plugin without breaking stderr checks.
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,11 +11,14 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"filippo.io/age"
|
||||
"filippo.io/age/agessh"
|
||||
"filippo.io/age/armor"
|
||||
"filippo.io/age/internal/term"
|
||||
"filippo.io/age/plugin"
|
||||
"filippo.io/age/tag"
|
||||
"golang.org/x/crypto/cryptobyte"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
@@ -30,8 +33,12 @@ func (gitHubRecipientError) Error() string {
|
||||
|
||||
func parseRecipient(arg string) (age.Recipient, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(arg, "age1tag1") || strings.HasPrefix(arg, "age1tagpq1"):
|
||||
return tag.ParseRecipient(arg)
|
||||
case strings.HasPrefix(arg, "age1pq1"):
|
||||
return age.ParseHybridRecipient(arg)
|
||||
case strings.HasPrefix(arg, "age1") && strings.Count(arg, "1") > 1:
|
||||
return plugin.NewRecipient(arg, pluginTerminalUI)
|
||||
return plugin.NewRecipient(arg, plugin.NewTerminalUI(printf, warningf))
|
||||
case strings.HasPrefix(arg, "age1"):
|
||||
return age.ParseX25519Recipient(arg)
|
||||
case strings.HasPrefix(arg, "ssh-"):
|
||||
@@ -72,6 +79,9 @@ func parseRecipientsFile(name string) ([]age.Recipient, error) {
|
||||
if strings.HasPrefix(line, "#") || line == "" {
|
||||
continue
|
||||
}
|
||||
if !utf8.ValidString(line) {
|
||||
return nil, fmt.Errorf("%q: recipients file is not valid UTF-8", name)
|
||||
}
|
||||
if len(line) > lineLengthLimit {
|
||||
return nil, fmt.Errorf("%q: line %d is too long", name, n)
|
||||
}
|
||||
@@ -82,6 +92,9 @@ func parseRecipientsFile(name string) ([]age.Recipient, error) {
|
||||
warningf("recipients file %q: ignoring unsupported SSH key of type %q at line %d", name, t, n)
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "AGE-") {
|
||||
return nil, fmt.Errorf("%q: error at line %d: apparent identity found in recipients file", name, n)
|
||||
}
|
||||
// Hide the error since it might unintentionally leak the contents
|
||||
// of confidential files.
|
||||
return nil, fmt.Errorf("%q: malformed recipient at line %d", name, n)
|
||||
@@ -121,8 +134,9 @@ func sshKeyType(s string) (string, bool) {
|
||||
}
|
||||
|
||||
// parseIdentitiesFile parses a file that contains age or SSH keys. It returns
|
||||
// one or more of *age.X25519Identity, *agessh.RSAIdentity, *agessh.Ed25519Identity,
|
||||
// *agessh.EncryptedSSHIdentity, or *EncryptedIdentity.
|
||||
// one or more of *[age.X25519Identity], *[age.HybridIdentity],
|
||||
// *[agessh.RSAIdentity], *[agessh.Ed25519Identity],
|
||||
// *[agessh.EncryptedSSHIdentity], or *[EncryptedIdentity].
|
||||
func parseIdentitiesFile(name string) ([]age.Identity, error) {
|
||||
var f *os.File
|
||||
if name == "-" {
|
||||
@@ -162,7 +176,7 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) {
|
||||
return []age.Identity{&EncryptedIdentity{
|
||||
Contents: contents,
|
||||
Passphrase: func() (string, error) {
|
||||
pass, err := readSecret(fmt.Sprintf("Enter passphrase for identity file %q:", name))
|
||||
pass, err := term.ReadSecret(fmt.Sprintf("Enter passphrase for identity file %q:", name))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not read passphrase: %v", err)
|
||||
}
|
||||
@@ -198,15 +212,17 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) {
|
||||
func parseIdentity(s string) (age.Identity, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(s, "AGE-PLUGIN-"):
|
||||
return plugin.NewIdentity(s, pluginTerminalUI)
|
||||
return plugin.NewIdentity(s, plugin.NewTerminalUI(printf, warningf))
|
||||
case strings.HasPrefix(s, "AGE-SECRET-KEY-1"):
|
||||
return age.ParseX25519Identity(s)
|
||||
case strings.HasPrefix(s, "AGE-SECRET-KEY-PQ-1"):
|
||||
return age.ParseHybridIdentity(s)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown identity type")
|
||||
}
|
||||
}
|
||||
|
||||
// parseIdentities is like age.ParseIdentities, but supports plugin identities.
|
||||
// parseIdentities is like [age.ParseIdentities], but supports plugin identities.
|
||||
func parseIdentities(f io.Reader) ([]age.Identity, error) {
|
||||
const privateKeySizeLimit = 1 << 24 // 16 MiB
|
||||
var ids []age.Identity
|
||||
@@ -218,19 +234,23 @@ func parseIdentities(f io.Reader) ([]age.Identity, error) {
|
||||
if strings.HasPrefix(line, "#") || line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !utf8.ValidString(line) {
|
||||
return nil, fmt.Errorf("identities file is not valid UTF-8")
|
||||
}
|
||||
i, err := parseIdentity(line)
|
||||
if err != nil {
|
||||
if strings.HasPrefix(line, "age1") {
|
||||
return nil, fmt.Errorf("error at line %d: apparent recipient found in identities file", n)
|
||||
}
|
||||
return nil, fmt.Errorf("error at line %d: %v", n, err)
|
||||
}
|
||||
ids = append(ids, i)
|
||||
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("failed to read secret keys file: %v", err)
|
||||
return nil, fmt.Errorf("failed to read identities file: %v", err)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("no secret keys found")
|
||||
return nil, fmt.Errorf("no identities found")
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
@@ -246,7 +266,7 @@ func parseSSHIdentity(name string, pemBytes []byte) ([]age.Identity, error) {
|
||||
}
|
||||
}
|
||||
passphrasePrompt := func() ([]byte, error) {
|
||||
pass, err := readSecret(fmt.Sprintf("Enter passphrase for %q:", name))
|
||||
pass, err := term.ReadSecret(fmt.Sprintf("Enter passphrase for %q:", name))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not read passphrase for %q: %v", name, err)
|
||||
}
|
||||
|
||||
21
cmd/age/testdata/armor.txt
vendored
Normal file
21
cmd/age/testdata/armor.txt
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
age -d -i key.txt armored_with_leading_and_trailing_whitespace.txt
|
||||
stdout test
|
||||
|
||||
-- key.txt --
|
||||
# created: 2025-12-23T22:21:12+01:00
|
||||
# public key: age15w9kgvgggmfra4sz6vk39kz4mveuq2sfv5vmcu090y0k2sluepaqv7z2fv
|
||||
AGE-SECRET-KEY-18J6FVYJE2AFSJ0RPH6M29GMUU62UVRSCNWUJZSGETH6R38Q5AZ3S2DHAZ9
|
||||
|
||||
-- armored_with_leading_and_trailing_whitespace.txt --
|
||||
|
||||
|
||||
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA5ODhFNHR6RVg0SGVHZFBM
|
||||
clBEclEzZ3NvOGhqVE9tcFZnbTc2c3R5a0Q4ClZjVzBLNjdxRElZV3E0Z3ZpZ255
|
||||
T3JWTFBHRFA2cytpWWtkeU45dDRadmcKLS0tIHV3L3hOVmJjL0hMRXBQa05lMlRs
|
||||
ZW45TndPeE9GcmRNeWFkR3YxeHg0YzQKJBp6KRlFFUE8jbAQUBlcAwaaQcPAflJD
|
||||
pWGoOjYP33gTxJHNPg==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
|
||||
|
||||
54
cmd/age/testdata/batchpass.txt
vendored
Normal file
54
cmd/age/testdata/batchpass.txt
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
# encrypt and decrypt with AGE_PASSPHRASE
|
||||
env AGE_PASSPHRASE_WORK_FACTOR=5
|
||||
env AGE_PASSPHRASE=password
|
||||
age -e -j batchpass -o test.age input
|
||||
age -d -j batchpass test.age
|
||||
cmp stdout input
|
||||
|
||||
# decrypt with AGE_PASSPHRASE_MAX_WORK_FACTOR
|
||||
env AGE_PASSPHRASE_MAX_WORK_FACTOR=10
|
||||
age -d -j batchpass test.age
|
||||
cmp stdout input
|
||||
|
||||
# AGE_PASSPHRASE_MAX_WORK_FACTOR lower than work factor
|
||||
env AGE_PASSPHRASE_MAX_WORK_FACTOR=3
|
||||
! age -d -j batchpass test.age
|
||||
stderr 'work factor'
|
||||
env AGE_PASSPHRASE_MAX_WORK_FACTOR=
|
||||
|
||||
# error: both AGE_PASSPHRASE and AGE_PASSPHRASE_FD set
|
||||
env AGE_PASSPHRASE=password
|
||||
env AGE_PASSPHRASE_FD=3
|
||||
! age -e -j batchpass -a input
|
||||
stderr 'mutually exclusive'
|
||||
|
||||
# error: neither AGE_PASSPHRASE nor AGE_PASSPHRASE_FD set
|
||||
env AGE_PASSPHRASE=
|
||||
env AGE_PASSPHRASE_FD=
|
||||
! age -e -j batchpass -a test.age
|
||||
stderr 'must be set'
|
||||
|
||||
# error: incorrect passphrase
|
||||
env AGE_PASSPHRASE=wrongpassword
|
||||
! age -d -j batchpass test.age
|
||||
stderr 'incorrect passphrase'
|
||||
|
||||
# error: encrypting to other recipients along with passphrase
|
||||
env AGE_PASSPHRASE=password
|
||||
! age -e -j batchpass -r age1gc2zwslg9j25pg9e9ld7623aewc2gjzquzgq90m75r75myqdt5cq3yudvh -a input
|
||||
stderr 'incompatible recipients'
|
||||
! age -e -r age1gc2zwslg9j25pg9e9ld7623aewc2gjzquzgq90m75r75myqdt5cq3yudvh -j batchpass -a input
|
||||
stderr 'incompatible recipients'
|
||||
|
||||
# decrypt with native scrypt
|
||||
[!linux] [!darwin] skip # no pty support
|
||||
[darwin] [go1.20] skip # https://go.dev/issue/61779
|
||||
ttyin terminal
|
||||
age -d test.age
|
||||
cmp stdout input
|
||||
|
||||
-- terminal --
|
||||
password
|
||||
password
|
||||
-- input --
|
||||
test
|
||||
49
cmd/age/testdata/duplicates.txt
vendored
Normal file
49
cmd/age/testdata/duplicates.txt
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
# Test duplicate recipient detection
|
||||
age -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -r age1yv2sxrjnyymx4qkn3ehdd5zdq4xnrea5ljm4hsmgxn5tpv985anqkrwqa7 -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -o test.age input
|
||||
stderr 'warning: duplicate recipient "age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85"'
|
||||
|
||||
# Test duplicates separated by different argument
|
||||
age -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -a -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -o test2.age input
|
||||
stderr 'warning: duplicate recipient "age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85"'
|
||||
|
||||
# Test duplicate recipients file detection
|
||||
age -R recipients1.txt -R recipients2.txt -R recipients1.txt -o test3.age input
|
||||
stderr 'warning: duplicate recipients file "recipients1.txt"'
|
||||
|
||||
# Test duplicates separated by output flag
|
||||
age -R recipients1.txt -o test4.age -R recipients1.txt input
|
||||
stderr 'warning: duplicate recipients file "recipients1.txt"'
|
||||
|
||||
# First create an encrypted file for decrypt tests
|
||||
age -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -o encrypted.age input
|
||||
|
||||
# Test duplicate identity file detection (decrypt mode)
|
||||
age -d -i key1.txt -i key2.txt -i key1.txt encrypted.age
|
||||
stderr 'warning: duplicate identity file "key1.txt"'
|
||||
|
||||
# Test duplicates separated by different argument in decrypt mode
|
||||
age -d -i key1.txt -o test.out -i key1.txt encrypted.age
|
||||
stderr 'warning: duplicate identity file "key1.txt"'
|
||||
|
||||
# Test no warning when no duplicates
|
||||
age -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -r age1yv2sxrjnyymx4qkn3ehdd5zdq4xnrea5ljm4hsmgxn5tpv985anqkrwqa7 -o test5.age input
|
||||
! stderr 'warning: duplicate'
|
||||
|
||||
# Test multiple duplicates (same value repeated 3+ times)
|
||||
age -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -r age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85 -o test6.age input
|
||||
stderr 'warning: duplicate recipient "age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85"'
|
||||
|
||||
-- input --
|
||||
test data
|
||||
-- recipients1.txt --
|
||||
age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85
|
||||
-- recipients2.txt --
|
||||
age1yv2sxrjnyymx4qkn3ehdd5zdq4xnrea5ljm4hsmgxn5tpv985anqkrwqa7
|
||||
-- key1.txt --
|
||||
# created: 2025-12-22T22:06:22+01:00
|
||||
# public key: age1jh0u9yam5jhql7vy4acz90p0rhqcr0rcyu4zm85s9sdcwrs7rfgshtdg85
|
||||
AGE-SECRET-KEY-1WRM2S8SP3XSKLLXAXS489EXZNKCKRZWYQLQ8D2NRNQWCVAPSMA9SC5JWZQ
|
||||
-- key2.txt --
|
||||
# created: 2025-12-22T22:06:27+01:00
|
||||
# public key: age1yv2sxrjnyymx4qkn3ehdd5zdq4xnrea5ljm4hsmgxn5tpv985anqkrwqa7
|
||||
AGE-SECRET-KEY-1WZ3MRPAWEWR4DG474H460MXX7J2T0TEYNJ0SKQDMKP02JU7UJ9UQFGLZCE
|
||||
47
cmd/age/testdata/hybrid.txt
vendored
Normal file
47
cmd/age/testdata/hybrid.txt
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
# encrypt and decrypt a file with -r
|
||||
age -r age1pq1044e3hfmq2tltgck54m0gnpv8rfrwkdxjgl38psnwg8zfyv4uqdu63kk0u94ztt0q35k9dzuwgv405kxq6due2sre2kfsjftf7k2vz2mdpetku9pufy9d6ny6k5ss0cghpxdxrux0yd5xy40f8fu8ch9xn2rczyek2pqetqk0l9fqj0e3hfhkyfmcf9j6cpe5mf5stt9cymfq3xm276x4sramhn8srr42g78q9rkmqesevy33p4kdhkuggl99x9zx7j05uflx3s8q558xeaq9q9ctxqx52rzg6yffwykxngvxf7g3n83ddawagq2lnk59kcnexm54jrsmkjk9nemlge9v6p7fxu5z6yvq3ghpet8ww2c3h9fylvjx6j9njwz744376ap5r3xcpzh7qlk0kq3gknzfqhmxmjqypf6xz6geffdmy25apzsfr4ce0x825njkf62mgzx0x9n3zt0490u4dnt4468zse4zvd9sv0ycx2u2wsseuy4yymrg0nmfuxcwq9q0jykq7g5f59vunf95vnmcw336ksrh43fgapsd40u4227ymx56xtudtrte39twnq6fdfdj4p9pgx7pcqdk953za76fqcnvatx2cmywurag7ky4y8ecq5lvf25rydhxg6gmcn6vf78vtsqw79hp3mzv2u4v4jzn0nk50kl4gpv7k60cgz79c6k30jzy8dyetqtwg04tguxkv2p4hqj02yvsz2szaqdn9flu24rhth3v0t33023nwy27gt0x32eqpa5tpz6ygz3yzcsa3w889drpynzd5hlywuk9dwgafuax7upfnnxch4znrx4vactasg32u7vl886pxfswskk7wjgqkggrewtnaecfusl3nmg0xxh8e5nl5eft0xqwj5rk9ajn2j6yxehhgg5k6kpw5pk382hxmpu8wcd3rqx6m8905c7f8v0x2z9gyduxtqp2fdy9w2z4g2rrgu4d5cqes5tnwag007h2f326s3j9p6970xyd73rv5awx27zzezrax467c6vuye62ha88pzhw9xytjpezw3vg3l6x4k9rxwm9f6lwj6j7r9makg59rhgt0355s3jx9vw3raz5qdk5cnpny56q5h3dyd75suskuz63wmmfp9333tx4ry38vnh7jxy0j0cka99y7v2dmyjldjy0mqjetcefsmmg3pw0h9ll0rtskpmxz2cs8vmx6lu4v5rjtzpzgaqt46cwvnmhy5e3ye0tv6sre342lwg0tfd0yjkfz2haqc3hprpqf5ce8c9lx076dnk5cukn3tu38jgprrxt9sk9nqzt8grvkc4qzlg8jjf638v07ej9cf4eu48kphrgyjy83qm822xq8v9k22zwa7txr439x74j2f9ay7frkwtfdwn02enyx8stjpr4wgg25n5tj7uxyx0dqt3hnrqf7dcgt9fqnkjgemjfzyck6pawewptcayxm49sq3477f4cvm2p8fmg27rcmwm5l2vmzvdmymvkrr3aywu04y3zlhs7awu3r9u9lseheqpffvm2xf8y6w2qaj9g22pcu0sdsnqseusrxq9awru5lyqxnzftlslgpe3njegxkze98rveswp3u0xgx69wkg38vgp4k55lwwgv5l7ccv503mh9q23nud4xtm499xq276u40rxyrzaqfyuz2f3v5qr2vq00xs4g3ruynmq0c6nprvjcwqc5zrdn4cnzeeuwumjvqfjdpxr739n4cqcwytc8pjmfcj2qs3l2a8p5fzq7nvtng2jvvklx0x5ypnlkeuegsm587f6d4dkwel2es0lwwt88y2jdg8enjyph02tnjyndlx34h52fngjk96ggtu6vcxqkreqcxs4gwa8ww40t0kzlxfzyfhtjmfdm5fcrr5pt2xm4yslfynr0h9wkranhyadxf33kjn9xpg2fq5hnlq0 -o test.age input
|
||||
age -d -i key.txt test.age
|
||||
cmp stdout input
|
||||
! stderr .
|
||||
|
||||
# encrypt and decrypt a file with -i
|
||||
age -e -i key.txt -o test.age input
|
||||
age -d -i key.txt test.age
|
||||
cmp stdout input
|
||||
! stderr .
|
||||
|
||||
# encrypt and decrypt a file with the wrong key
|
||||
age -r age12phkzssndd5axajas2h74vtge62c86xjhd6u9anyanqhzvdg6sps0xthgl -o test.age input
|
||||
! age -d -i key.txt test.age
|
||||
stderr 'no identity matched any of the recipients'
|
||||
|
||||
age -r age1pq13tgqupu990y5qlwwjy4zrzwvxg54cq89x9pxzdjenv4cl7affsqeerksrc6ufw3ndp4h5p3l5hm2jekkd7uay2mlh9eqg3sjcwmv5dcrwguc8djf83qr8gswtdrpav3jpw47kgkh6z0rnfk92xyqeyce48t02n4qz68augry3r3nd4rpdsw998vnnxzd2vepypktx2wxhfvd56jw7p32te6ttcyhc7tkqy2ks77jtjl8cgyal43pcd3g9wkcsgytccqtcnzcucjjdadpr0v7ye8u9fqusq6cq2ah0e4ejrltc8sk6763d7mjkuchmc5tkx6xztygqjx4ldxqh7jzkkyuv6ywt07ys4x09eq7gat4wuznrvgs3pkxjju8t9n0gpfqrsv658m5v09ltqc3c5z30n43ld43dwe5dn8nsk89f43tzsw2zta5kvl95zv0a94tc5y9r4ncj8g63fgljzhdxl96eg39rgd56yuvr2xdvavkk93a85n99jpjeqkfveprqc7eqft0wp7qgxcgsrwcdymgp0cqaj93jp4ckln36ylcwdzew234esa2awty25zc6pmc0jcpkrfuuptr9z5a4kx9ff4ppfff2lw95um853tcg47fa444er9k9j4e8vy5hfjx626j3tz3kc94jl8r66ahfprwtcyxae3swe9xxjhx5dfnt53n5fs2ut83t597wscjg9jc4f6h4uen2ll2ne30w7uvts8vuc5mpcfpp9espa78y0f8588m62kzez5kxxtcxykdkrnjx08w5az3pr6g6dsykwazcxtetpkq85mg7kay84z58slmvxmq56342pm6v8y3evr5wvc9g4j87jfhmryymkz9wk2en2v6tfy83rz657t0w7p3va46jt22f29u9ggzt9nu6qtguw4cp7cpj572ccyenw5rdh43h6s54g73gvxapsq3hw5yfkgttcyhkrjf9ykzd3wm5zsqgp5fca0nr56dnqe3kqrszasagqne0a83cguzwj9aw456jaxg9rurq4n4f6qec4srd0rxqfqc6rw22c2p4xyg25pls4dmxvce2fah8m6shue84preky2h0c4sa9y9tjuzn20gr9aav96e689nqx23s7zz0lvhq925jdkn04cdpzfscvhh93vh9f2tmwkzsq7pq0ncjk8g8cu75u77lyej46a4m2fefy8v2x4nzay05kulhyzk69520r7fdg3fzh0z7ysequcltr94hyf88h46hfujk30x9jp8u0pcjywh06g7tv483ypmc3pm3x3z4h9ph66lx228t3r96hyt764yxe2rn5clnfxkj3k5wf5hffehj58y56qyykwayk6qt9skvctxw20v9xy5ppnld3lushqpsutxht7cqygypdn5d2ppdgaerqzgehyrpdwzkhu0qe8w3u5h6htz8aa5zfpzy4s7sl8zdv3q0gq8ez08p86e4v3m82882yvawrvyrcxewrznwkvvez8m5aj6ktgvynyy0kc2trmtjzvjlxdf06e3rjmf55lwxrucfwu2sxdtnte83fgq0tvr2juv3pfqp8ddsrnckzqcfcvjp02mfg5y4aqlsunxpfcrdm46fphwsslrudwrfh542xg62kphca6h3xxqn538pprkknt35y00ygvrse5mxpvnstvcrnak5qduhf5dqslkn0yeadgpq6tv4wzy98kdjdzp22cpq6dy3kve856y2qlk7elyqyzj7ezpnh3vjwwmcm7ctp23k2sct86jd46ztplsq5vdjg9lyspxt0k8qx5udu9lwgulzapn3kdg7yd2zdz5dqf9933mpzwc32x8uxn8h2v5hlhdd4qk0uvwxhxyul8keaw39pz2avywk3wfly6veet9pjnj7nqecrgz824whs9sf7wl2shxk9kvkteht4z9x3w2gc6hqz5y -o test.age input
|
||||
! age -d -i key.txt test.age
|
||||
stderr 'no identity matched any of the recipients'
|
||||
|
||||
# cannot mix hybrid and X25519 recipients
|
||||
! age -r age1pq1044e3hfmq2tltgck54m0gnpv8rfrwkdxjgl38psnwg8zfyv4uqdu63kk0u94ztt0q35k9dzuwgv405kxq6due2sre2kfsjftf7k2vz2mdpetku9pufy9d6ny6k5ss0cghpxdxrux0yd5xy40f8fu8ch9xn2rczyek2pqetqk0l9fqj0e3hfhkyfmcf9j6cpe5mf5stt9cymfq3xm276x4sramhn8srr42g78q9rkmqesevy33p4kdhkuggl99x9zx7j05uflx3s8q558xeaq9q9ctxqx52rzg6yffwykxngvxf7g3n83ddawagq2lnk59kcnexm54jrsmkjk9nemlge9v6p7fxu5z6yvq3ghpet8ww2c3h9fylvjx6j9njwz744376ap5r3xcpzh7qlk0kq3gknzfqhmxmjqypf6xz6geffdmy25apzsfr4ce0x825njkf62mgzx0x9n3zt0490u4dnt4468zse4zvd9sv0ycx2u2wsseuy4yymrg0nmfuxcwq9q0jykq7g5f59vunf95vnmcw336ksrh43fgapsd40u4227ymx56xtudtrte39twnq6fdfdj4p9pgx7pcqdk953za76fqcnvatx2cmywurag7ky4y8ecq5lvf25rydhxg6gmcn6vf78vtsqw79hp3mzv2u4v4jzn0nk50kl4gpv7k60cgz79c6k30jzy8dyetqtwg04tguxkv2p4hqj02yvsz2szaqdn9flu24rhth3v0t33023nwy27gt0x32eqpa5tpz6ygz3yzcsa3w889drpynzd5hlywuk9dwgafuax7upfnnxch4znrx4vactasg32u7vl886pxfswskk7wjgqkggrewtnaecfusl3nmg0xxh8e5nl5eft0xqwj5rk9ajn2j6yxehhgg5k6kpw5pk382hxmpu8wcd3rqx6m8905c7f8v0x2z9gyduxtqp2fdy9w2z4g2rrgu4d5cqes5tnwag007h2f326s3j9p6970xyd73rv5awx27zzezrax467c6vuye62ha88pzhw9xytjpezw3vg3l6x4k9rxwm9f6lwj6j7r9makg59rhgt0355s3jx9vw3raz5qdk5cnpny56q5h3dyd75suskuz63wmmfp9333tx4ry38vnh7jxy0j0cka99y7v2dmyjldjy0mqjetcefsmmg3pw0h9ll0rtskpmxz2cs8vmx6lu4v5rjtzpzgaqt46cwvnmhy5e3ye0tv6sre342lwg0tfd0yjkfz2haqc3hprpqf5ce8c9lx076dnk5cukn3tu38jgprrxt9sk9nqzt8grvkc4qzlg8jjf638v07ej9cf4eu48kphrgyjy83qm822xq8v9k22zwa7txr439x74j2f9ay7frkwtfdwn02enyx8stjpr4wgg25n5tj7uxyx0dqt3hnrqf7dcgt9fqnkjgemjfzyck6pawewptcayxm49sq3477f4cvm2p8fmg27rcmwm5l2vmzvdmymvkrr3aywu04y3zlhs7awu3r9u9lseheqpffvm2xf8y6w2qaj9g22pcu0sdsnqseusrxq9awru5lyqxnzftlslgpe3njegxkze98rveswp3u0xgx69wkg38vgp4k55lwwgv5l7ccv503mh9q23nud4xtm499xq276u40rxyrzaqfyuz2f3v5qr2vq00xs4g3ruynmq0c6nprvjcwqc5zrdn4cnzeeuwumjvqfjdpxr739n4cqcwytc8pjmfcj2qs3l2a8p5fzq7nvtng2jvvklx0x5ypnlkeuegsm587f6d4dkwel2es0lwwt88y2jdg8enjyph02tnjyndlx34h52fngjk96ggtu6vcxqkreqcxs4gwa8ww40t0kzlxfzyfhtjmfdm5fcrr5pt2xm4yslfynr0h9wkranhyadxf33kjn9xpg2fq5hnlq0 -r age12phkzssndd5axajas2h74vtge62c86xjhd6u9anyanqhzvdg6sps0xthgl -o test.age input
|
||||
stderr 'incompatible'
|
||||
|
||||
! age -r age12phkzssndd5axajas2h74vtge62c86xjhd6u9anyanqhzvdg6sps0xthgl -r age1pq1044e3hfmq2tltgck54m0gnpv8rfrwkdxjgl38psnwg8zfyv4uqdu63kk0u94ztt0q35k9dzuwgv405kxq6due2sre2kfsjftf7k2vz2mdpetku9pufy9d6ny6k5ss0cghpxdxrux0yd5xy40f8fu8ch9xn2rczyek2pqetqk0l9fqj0e3hfhkyfmcf9j6cpe5mf5stt9cymfq3xm276x4sramhn8srr42g78q9rkmqesevy33p4kdhkuggl99x9zx7j05uflx3s8q558xeaq9q9ctxqx52rzg6yffwykxngvxf7g3n83ddawagq2lnk59kcnexm54jrsmkjk9nemlge9v6p7fxu5z6yvq3ghpet8ww2c3h9fylvjx6j9njwz744376ap5r3xcpzh7qlk0kq3gknzfqhmxmjqypf6xz6geffdmy25apzsfr4ce0x825njkf62mgzx0x9n3zt0490u4dnt4468zse4zvd9sv0ycx2u2wsseuy4yymrg0nmfuxcwq9q0jykq7g5f59vunf95vnmcw336ksrh43fgapsd40u4227ymx56xtudtrte39twnq6fdfdj4p9pgx7pcqdk953za76fqcnvatx2cmywurag7ky4y8ecq5lvf25rydhxg6gmcn6vf78vtsqw79hp3mzv2u4v4jzn0nk50kl4gpv7k60cgz79c6k30jzy8dyetqtwg04tguxkv2p4hqj02yvsz2szaqdn9flu24rhth3v0t33023nwy27gt0x32eqpa5tpz6ygz3yzcsa3w889drpynzd5hlywuk9dwgafuax7upfnnxch4znrx4vactasg32u7vl886pxfswskk7wjgqkggrewtnaecfusl3nmg0xxh8e5nl5eft0xqwj5rk9ajn2j6yxehhgg5k6kpw5pk382hxmpu8wcd3rqx6m8905c7f8v0x2z9gyduxtqp2fdy9w2z4g2rrgu4d5cqes5tnwag007h2f326s3j9p6970xyd73rv5awx27zzezrax467c6vuye62ha88pzhw9xytjpezw3vg3l6x4k9rxwm9f6lwj6j7r9makg59rhgt0355s3jx9vw3raz5qdk5cnpny56q5h3dyd75suskuz63wmmfp9333tx4ry38vnh7jxy0j0cka99y7v2dmyjldjy0mqjetcefsmmg3pw0h9ll0rtskpmxz2cs8vmx6lu4v5rjtzpzgaqt46cwvnmhy5e3ye0tv6sre342lwg0tfd0yjkfz2haqc3hprpqf5ce8c9lx076dnk5cukn3tu38jgprrxt9sk9nqzt8grvkc4qzlg8jjf638v07ej9cf4eu48kphrgyjy83qm822xq8v9k22zwa7txr439x74j2f9ay7frkwtfdwn02enyx8stjpr4wgg25n5tj7uxyx0dqt3hnrqf7dcgt9fqnkjgemjfzyck6pawewptcayxm49sq3477f4cvm2p8fmg27rcmwm5l2vmzvdmymvkrr3aywu04y3zlhs7awu3r9u9lseheqpffvm2xf8y6w2qaj9g22pcu0sdsnqseusrxq9awru5lyqxnzftlslgpe3njegxkze98rveswp3u0xgx69wkg38vgp4k55lwwgv5l7ccv503mh9q23nud4xtm499xq276u40rxyrzaqfyuz2f3v5qr2vq00xs4g3ruynmq0c6nprvjcwqc5zrdn4cnzeeuwumjvqfjdpxr739n4cqcwytc8pjmfcj2qs3l2a8p5fzq7nvtng2jvvklx0x5ypnlkeuegsm587f6d4dkwel2es0lwwt88y2jdg8enjyph02tnjyndlx34h52fngjk96ggtu6vcxqkreqcxs4gwa8ww40t0kzlxfzyfhtjmfdm5fcrr5pt2xm4yslfynr0h9wkranhyadxf33kjn9xpg2fq5hnlq0 -o test.age input
|
||||
stderr 'incompatible'
|
||||
|
||||
# convert to plugin identity and use plugin
|
||||
exec age-plugin-pq -identity -o key-plugin.txt key.txt
|
||||
|
||||
age -e -i key.txt -o test.age input
|
||||
age -d -i key-plugin.txt test.age
|
||||
cmp stdout input
|
||||
! stderr .
|
||||
|
||||
age -e -i key-plugin.txt -o test.age input
|
||||
age -d -i key.txt test.age
|
||||
cmp stdout input
|
||||
! stderr .
|
||||
|
||||
-- input --
|
||||
test
|
||||
-- key.txt --
|
||||
# created: 2025-11-17T13:27:37+01:00
|
||||
# public key: age1pq1044e3hfmq2tltgck54m0gnpv8rfrwkdxjgl38psnwg8zfyv4uqdu63kk0u94ztt0q35k9dzuwgv405kxq6due2sre2kfsjftf7k2vz2mdpetku9pufy9d6ny6k5ss0cghpxdxrux0yd5xy40f8fu8ch9xn2rczyek2pqetqk0l9fqj0e3hfhkyfmcf9j6cpe5mf5stt9cymfq3xm276x4sramhn8srr42g78q9rkmqesevy33p4kdhkuggl99x9zx7j05uflx3s8q558xeaq9q9ctxqx52rzg6yffwykxngvxf7g3n83ddawagq2lnk59kcnexm54jrsmkjk9nemlge9v6p7fxu5z6yvq3ghpet8ww2c3h9fylvjx6j9njwz744376ap5r3xcpzh7qlk0kq3gknzfqhmxmjqypf6xz6geffdmy25apzsfr4ce0x825njkf62mgzx0x9n3zt0490u4dnt4468zse4zvd9sv0ycx2u2wsseuy4yymrg0nmfuxcwq9q0jykq7g5f59vunf95vnmcw336ksrh43fgapsd40u4227ymx56xtudtrte39twnq6fdfdj4p9pgx7pcqdk953za76fqcnvatx2cmywurag7ky4y8ecq5lvf25rydhxg6gmcn6vf78vtsqw79hp3mzv2u4v4jzn0nk50kl4gpv7k60cgz79c6k30jzy8dyetqtwg04tguxkv2p4hqj02yvsz2szaqdn9flu24rhth3v0t33023nwy27gt0x32eqpa5tpz6ygz3yzcsa3w889drpynzd5hlywuk9dwgafuax7upfnnxch4znrx4vactasg32u7vl886pxfswskk7wjgqkggrewtnaecfusl3nmg0xxh8e5nl5eft0xqwj5rk9ajn2j6yxehhgg5k6kpw5pk382hxmpu8wcd3rqx6m8905c7f8v0x2z9gyduxtqp2fdy9w2z4g2rrgu4d5cqes5tnwag007h2f326s3j9p6970xyd73rv5awx27zzezrax467c6vuye62ha88pzhw9xytjpezw3vg3l6x4k9rxwm9f6lwj6j7r9makg59rhgt0355s3jx9vw3raz5qdk5cnpny56q5h3dyd75suskuz63wmmfp9333tx4ry38vnh7jxy0j0cka99y7v2dmyjldjy0mqjetcefsmmg3pw0h9ll0rtskpmxz2cs8vmx6lu4v5rjtzpzgaqt46cwvnmhy5e3ye0tv6sre342lwg0tfd0yjkfz2haqc3hprpqf5ce8c9lx076dnk5cukn3tu38jgprrxt9sk9nqzt8grvkc4qzlg8jjf638v07ej9cf4eu48kphrgyjy83qm822xq8v9k22zwa7txr439x74j2f9ay7frkwtfdwn02enyx8stjpr4wgg25n5tj7uxyx0dqt3hnrqf7dcgt9fqnkjgemjfzyck6pawewptcayxm49sq3477f4cvm2p8fmg27rcmwm5l2vmzvdmymvkrr3aywu04y3zlhs7awu3r9u9lseheqpffvm2xf8y6w2qaj9g22pcu0sdsnqseusrxq9awru5lyqxnzftlslgpe3njegxkze98rveswp3u0xgx69wkg38vgp4k55lwwgv5l7ccv503mh9q23nud4xtm499xq276u40rxyrzaqfyuz2f3v5qr2vq00xs4g3ruynmq0c6nprvjcwqc5zrdn4cnzeeuwumjvqfjdpxr739n4cqcwytc8pjmfcj2qs3l2a8p5fzq7nvtng2jvvklx0x5ypnlkeuegsm587f6d4dkwel2es0lwwt88y2jdg8enjyph02tnjyndlx34h52fngjk96ggtu6vcxqkreqcxs4gwa8ww40t0kzlxfzyfhtjmfdm5fcrr5pt2xm4yslfynr0h9wkranhyadxf33kjn9xpg2fq5hnlq0
|
||||
AGE-SECRET-KEY-PQ-16XDSLZ3XCSZ3236YJ5J0T9NAPLZTP96LJKQCVHJYUDTQVJR5J5PQTDPQCX
|
||||
25
cmd/age/testdata/keygen.txt
vendored
Normal file
25
cmd/age/testdata/keygen.txt
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
exec age-keygen
|
||||
stdout '# created: 20'
|
||||
stdout '# public key: age1'
|
||||
stdout 'AGE-SECRET-KEY-1'
|
||||
stderr 'Public key: age1'
|
||||
|
||||
exec age-keygen -pq
|
||||
stdout '# created: 20'
|
||||
stdout '# public key: age1pq1'
|
||||
stdout 'AGE-SECRET-KEY-PQ-1'
|
||||
stderr 'Public key: age1pq1'
|
||||
|
||||
exec age-keygen -pq -o key.txt
|
||||
! stdout .
|
||||
stderr 'Public key: age1pq1'
|
||||
grep '# created: 20' key.txt
|
||||
grep '# public key: age1pq1' key.txt
|
||||
grep 'AGE-SECRET-KEY-PQ-1' key.txt
|
||||
|
||||
stdin key.txt
|
||||
exec age-keygen -y
|
||||
stdout age1pq1
|
||||
|
||||
exec age-keygen -y key.txt
|
||||
stdout age1pq1
|
||||
78
cmd/age/testdata/output_file.txt
vendored
Normal file
78
cmd/age/testdata/output_file.txt
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
# https://github.com/FiloSottile/age/issues/57
|
||||
age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o test.age input
|
||||
! age -o test.out -d -i wrong.txt test.age
|
||||
! exists test.out
|
||||
! age -o test.out -d test.age
|
||||
! exists test.out
|
||||
! age -o test.out -d -i notexist test.age
|
||||
! exists test.out
|
||||
! age -o test.out -d -i wrong.txt notexist
|
||||
! exists test.out
|
||||
! age -o test.out -r BAD
|
||||
! exists test.out
|
||||
! age -o test.out -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef notexist
|
||||
! exists test.out
|
||||
! age -o test.out -p notexist
|
||||
! exists test.out
|
||||
|
||||
# https://github.com/FiloSottile/age/issues/555
|
||||
age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o empty.age empty
|
||||
exists empty.age
|
||||
age -d -i key.txt empty.age
|
||||
! stdout .
|
||||
! stderr .
|
||||
age -d -i key.txt -o new empty.age
|
||||
! stderr .
|
||||
cmp new empty
|
||||
|
||||
# https://github.com/FiloSottile/age/issues/491
|
||||
cp input inputcopy
|
||||
! age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o inputcopy inputcopy
|
||||
stderr 'input and output file are the same'
|
||||
cmp inputcopy input
|
||||
! age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o ./inputcopy inputcopy
|
||||
stderr 'input and output file are the same'
|
||||
cmp inputcopy input
|
||||
mkdir foo
|
||||
! age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o inputcopy foo/../inputcopy
|
||||
stderr 'input and output file are the same'
|
||||
cmp inputcopy input
|
||||
cp key.txt keycopy
|
||||
age -e -i keycopy -o test.age input
|
||||
! age -d -i keycopy -o keycopy test.age
|
||||
stderr 'input and output file are the same'
|
||||
cmp key.txt keycopy
|
||||
|
||||
[!linux] [!darwin] skip # no pty support
|
||||
[darwin] [go1.20] skip # https://go.dev/issue/61779
|
||||
|
||||
ttyin terminal
|
||||
! age -p -o inputcopy inputcopy
|
||||
stderr 'input and output file are the same'
|
||||
cmp inputcopy input
|
||||
|
||||
# https://github.com/FiloSottile/age/issues/159
|
||||
ttyin terminal
|
||||
age -p -a -o test.age input
|
||||
ttyin terminalwrong
|
||||
! age -o test.out -d test.age
|
||||
ttyout 'Enter passphrase'
|
||||
stderr 'incorrect passphrase'
|
||||
! exists test.out
|
||||
|
||||
-- terminal --
|
||||
password
|
||||
password
|
||||
-- terminalwrong --
|
||||
wrong
|
||||
-- input --
|
||||
age
|
||||
-- empty --
|
||||
-- key.txt --
|
||||
# created: 2021-02-02T13:09:43+01:00
|
||||
# public key: age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef
|
||||
AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0
|
||||
-- wrong.txt --
|
||||
# created: 2024-06-16T12:14:00+02:00
|
||||
# public key: age10k7vsqmeg3sp8elfyq5ts55feg4huarpcaf9dmljn9umydg3gymsvx4dp9
|
||||
AGE-SECRET-KEY-1NPX08S4LELW9K68FKU0U05XXEKG6X7GT004TPNYLF86H3M00D3FQ3VQQNN
|
||||
22
cmd/age/testdata/plugin.txt
vendored
22
cmd/age/testdata/plugin.txt
vendored
@@ -10,6 +10,21 @@ age -d -i long-key.txt test.age
|
||||
cmp stdout input
|
||||
! stderr .
|
||||
|
||||
# check that path separators are rejected
|
||||
chmod 755 age-plugin-pwn/pwn
|
||||
mkdir $TMPDIR/age-plugin-pwn
|
||||
cp age-plugin-pwn/pwn $TMPDIR/age-plugin-pwn/pwn
|
||||
! age -r age1pwn/pwn19gt89dfz input
|
||||
! age -d -i pwn-identity.txt test.age
|
||||
! age -d -j pwn/pwn test.age
|
||||
! exists pwn
|
||||
|
||||
# check plugin not found hint
|
||||
! age -r age1nonexistentplugin1pt5d8z -o test1.age
|
||||
stderr /awesome#plugins
|
||||
! age -d -i nonexistent-identity.txt test.age
|
||||
stderr /awesome#plugins
|
||||
|
||||
-- input --
|
||||
test
|
||||
-- key.txt --
|
||||
@@ -18,3 +33,10 @@ AGE-PLUGIN-TEST-10Q32NLXM
|
||||
age1test10pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7qj6rl8p
|
||||
-- long-key.txt --
|
||||
AGE-PLUGIN-TEST-10PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7Q5U8SUD
|
||||
-- pwn-identity.txt --
|
||||
AGE-PLUGIN-PWN/PWN-19GYK4WLY
|
||||
-- age-plugin-pwn/pwn --
|
||||
#!/bin/sh
|
||||
touch "$WORK/pwn"
|
||||
-- nonexistent-identity.txt --
|
||||
AGE-PLUGIN-NONEXISTENTPLUGIN-1R4XFW4
|
||||
|
||||
5
cmd/age/testdata/scrypt.txt
vendored
5
cmd/age/testdata/scrypt.txt
vendored
@@ -42,6 +42,11 @@ ttyin wrong
|
||||
stderr 'passphrases didn''t match'
|
||||
! exists fail.age
|
||||
|
||||
# fail when -i is missing
|
||||
age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o test.age input
|
||||
! age -d test.age
|
||||
stderr 'file is not passphrase-encrypted, identities are required'
|
||||
|
||||
-- terminal --
|
||||
password
|
||||
password
|
||||
|
||||
5
cmd/age/testdata/x25519.txt
vendored
5
cmd/age/testdata/x25519.txt
vendored
@@ -15,6 +15,11 @@ age -r age12phkzssndd5axajas2h74vtge62c86xjhd6u9anyanqhzvdg6sps0xthgl -o test.ag
|
||||
! age -d -i key.txt test.age
|
||||
stderr 'no identity matched any of the recipients'
|
||||
|
||||
# decrypt an empty file
|
||||
! age -d -i key.txt empty
|
||||
stderr empty
|
||||
|
||||
-- empty --
|
||||
-- input --
|
||||
test
|
||||
-- key.txt --
|
||||
|
||||
170
cmd/age/tui.go
170
cmd/age/tui.go
@@ -12,35 +12,35 @@ package main
|
||||
//
|
||||
// - Everything else goes to standard error with an "age:" prefix.
|
||||
// No capitalized initials and no periods at the end.
|
||||
//
|
||||
// The one exception is the autogenerated passphrase, which goes to
|
||||
// the terminal, since we really want it to reach the user only.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"filippo.io/age/armor"
|
||||
"filippo.io/age/plugin"
|
||||
"golang.org/x/term"
|
||||
"filippo.io/age/internal/term"
|
||||
)
|
||||
|
||||
// l is a logger with no prefixes.
|
||||
var l = log.New(os.Stderr, "", 0)
|
||||
|
||||
func printf(format string, v ...interface{}) {
|
||||
func printf(format string, v ...any) {
|
||||
l.Printf("age: "+format, v...)
|
||||
}
|
||||
|
||||
func errorf(format string, v ...interface{}) {
|
||||
func errorf(format string, v ...any) {
|
||||
l.Printf("age: error: "+format, v...)
|
||||
l.Printf("age: report unexpected or unhelpful errors at https://filippo.io/age/report")
|
||||
exit(1)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func warningf(format string, v ...interface{}) {
|
||||
func warningf(format string, v ...any) {
|
||||
l.Printf("age: warning: "+format, v...)
|
||||
}
|
||||
|
||||
@@ -50,164 +50,16 @@ func errorWithHint(error string, hints ...string) {
|
||||
l.Printf("age: hint: %s", hint)
|
||||
}
|
||||
l.Printf("age: report unexpected or unhelpful errors at https://filippo.io/age/report")
|
||||
exit(1)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// If testOnlyPanicInsteadOfExit is true, exit will set testOnlyDidExit and
|
||||
// panic instead of calling os.Exit. This way, the wrapper in TestMain can
|
||||
// recover the panic and return the exit code only if it was originated in exit.
|
||||
var testOnlyPanicInsteadOfExit bool
|
||||
var testOnlyDidExit bool
|
||||
|
||||
func exit(code int) {
|
||||
if testOnlyPanicInsteadOfExit {
|
||||
testOnlyDidExit = true
|
||||
panic(code)
|
||||
}
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// clearLine clears the current line on the terminal, or opens a new line if
|
||||
// terminal escape codes don't work.
|
||||
func clearLine(out io.Writer) {
|
||||
const (
|
||||
CUI = "\033[" // Control Sequence Introducer
|
||||
CPL = CUI + "F" // Cursor Previous Line
|
||||
EL = CUI + "K" // Erase in Line
|
||||
)
|
||||
|
||||
// First, open a new line, which is guaranteed to work everywhere. Then, try
|
||||
// to erase the line above with escape codes.
|
||||
//
|
||||
// (We use CRLF instead of LF to work around an apparent bug in WSL2's
|
||||
// handling of CONOUT$. Only when running a Windows binary from WSL2, the
|
||||
// cursor would not go back to the start of the line with a simple LF.
|
||||
// Honestly, it's impressive CONIN$ and CONOUT$ work at all inside WSL2.)
|
||||
fmt.Fprintf(out, "\r\n"+CPL+EL)
|
||||
}
|
||||
|
||||
// withTerminal runs f with the terminal input and output files, if available.
|
||||
// withTerminal does not open a non-terminal stdin, so the caller does not need
|
||||
// to check stdinInUse.
|
||||
func withTerminal(f func(in, out *os.File) error) error {
|
||||
if runtime.GOOS == "windows" {
|
||||
in, err := os.OpenFile("CONIN$", os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
return f(in, out)
|
||||
} else if tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0); err == nil {
|
||||
defer tty.Close()
|
||||
return f(tty, tty)
|
||||
} else if term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
return f(os.Stdin, os.Stdin)
|
||||
} else {
|
||||
return fmt.Errorf("standard input is not a terminal, and /dev/tty is not available: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func printfToTerminal(format string, v ...interface{}) error {
|
||||
return withTerminal(func(_, out *os.File) error {
|
||||
func printfToTerminal(format string, v ...any) error {
|
||||
return term.WithTerminal(func(_, out *os.File) error {
|
||||
_, err := fmt.Fprintf(out, "age: "+format+"\n", v...)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// readSecret reads a value from the terminal with no echo. The prompt is ephemeral.
|
||||
func readSecret(prompt string) (s []byte, err error) {
|
||||
err = withTerminal(func(in, out *os.File) error {
|
||||
fmt.Fprintf(out, "%s ", prompt)
|
||||
defer clearLine(out)
|
||||
s, err = term.ReadPassword(int(in.Fd()))
|
||||
return err
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// readCharacter reads a single character from the terminal with no echo. The
|
||||
// prompt is ephemeral.
|
||||
func readCharacter(prompt string) (c byte, err error) {
|
||||
err = withTerminal(func(in, out *os.File) error {
|
||||
fmt.Fprintf(out, "%s ", prompt)
|
||||
defer clearLine(out)
|
||||
|
||||
oldState, err := term.MakeRaw(int(in.Fd()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer term.Restore(int(in.Fd()), oldState)
|
||||
|
||||
b := make([]byte, 1)
|
||||
if _, err := in.Read(b); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c = b[0]
|
||||
return nil
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var pluginTerminalUI = &plugin.ClientUI{
|
||||
DisplayMessage: func(name, message string) error {
|
||||
printf("%s plugin: %s", name, message)
|
||||
return nil
|
||||
},
|
||||
RequestValue: func(name, message string, _ bool) (s string, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
warningf("could not read value for age-plugin-%s: %v", name, err)
|
||||
}
|
||||
}()
|
||||
secret, err := readSecret(message)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(secret), nil
|
||||
},
|
||||
Confirm: func(name, message, yes, no string) (choseYes bool, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
warningf("could not read value for age-plugin-%s: %v", name, err)
|
||||
}
|
||||
}()
|
||||
if no == "" {
|
||||
message += fmt.Sprintf(" (press enter for %q)", yes)
|
||||
_, err := readSecret(message)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
message += fmt.Sprintf(" (press [1] for %q or [2] for %q)", yes, no)
|
||||
for {
|
||||
selection, err := readCharacter(message)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch selection {
|
||||
case '1':
|
||||
return true, nil
|
||||
case '2':
|
||||
return false, nil
|
||||
case '\x03': // CTRL-C
|
||||
return false, errors.New("user cancelled prompt")
|
||||
default:
|
||||
warningf("reading value for age-plugin-%s: invalid selection %q", name, selection)
|
||||
}
|
||||
}
|
||||
},
|
||||
WaitTimer: func(name string) {
|
||||
printf("waiting on %s plugin...", name)
|
||||
},
|
||||
}
|
||||
|
||||
func bufferTerminalInput(in io.Reader) (io.Reader, error) {
|
||||
buf := &bytes.Buffer{}
|
||||
if _, err := buf.ReadFrom(ReaderFunc(func(p []byte) (n int, err error) {
|
||||
|
||||
95
doc/age-inspect.1
Normal file
95
doc/age-inspect.1
Normal file
@@ -0,0 +1,95 @@
|
||||
.\" generated with Ronn-NG/v0.9.1
|
||||
.\" http://github.com/apjanke/ronn-ng/tree/0.9.1
|
||||
.TH "AGE\-INSPECT" "1" "December 2025" ""
|
||||
.SH "NAME"
|
||||
\fBage\-inspect\fR \- inspect age(1) encrypted files
|
||||
.SH "SYNOPSIS"
|
||||
\fBage\-inspect\fR [\fB\-\-json\fR] [\fIINPUT\fR]
|
||||
.SH "DESCRIPTION"
|
||||
\fBage\-inspect\fR reads an age(1) encrypted file from \fIINPUT\fR (or standard input) and displays metadata about it without decrypting\.
|
||||
.P
|
||||
This includes the recipient types, whether it uses post\-quantum encryption, and a size breakdown of the file components\.
|
||||
.SH "OPTIONS"
|
||||
.TP
|
||||
\fB\-\-json\fR
|
||||
Output machine\-readable JSON instead of human\-readable text\.
|
||||
.TP
|
||||
\fB\-\-version\fR
|
||||
Print the version and exit\.
|
||||
.SH "JSON FORMAT"
|
||||
When \fB\-\-json\fR is specified, the output is a JSON object with these fields:
|
||||
.IP "\[ci]" 4
|
||||
\fBversion\fR: The age format version (e\.g\., \fB"age\-encryption\.org/v1"\fR)\.
|
||||
.IP "\[ci]" 4
|
||||
\fBpostquantum\fR: Whether the file uses post\-quantum encryption: \fB"yes"\fR, \fB"no"\fR, or \fB"unknown"\fR\.
|
||||
.IP "\[ci]" 4
|
||||
\fBarmor\fR: Boolean indicating whether the file is ASCII\-armored\.
|
||||
.IP "\[ci]" 4
|
||||
\fBstanza_types\fR: Array of recipient stanza type strings (e\.g\., \fB["X25519"]\fR or \fB["mlkem768x25519"]\fR)\.
|
||||
.IP "\[ci]" 4
|
||||
\fBsizes\fR: Object containing size information in bytes:
|
||||
.IP "\[ci]" 4
|
||||
\fBheader\fR: Size of the age header\.
|
||||
.IP "\[ci]" 4
|
||||
\fBarmor\fR: Armor encoding overhead (0 if not armored)\.
|
||||
.IP "\[ci]" 4
|
||||
\fBoverhead\fR: Stream encryption overhead\.
|
||||
.IP "\[ci]" 4
|
||||
\fBmin_payload\fR, \fBmax_payload\fR: Payload size bounds (currently always matching)\.
|
||||
.IP "\[ci]" 4
|
||||
\fBmin_padding\fR, \fBmax_padding\fR: Padding size bounds (currently always 0)\.
|
||||
.IP "" 0
|
||||
.IP
|
||||
The fields add up to the total size of the file\.
|
||||
.IP "" 0
|
||||
.SH "EXAMPLES"
|
||||
Inspect an encrypted file:
|
||||
.IP "" 4
|
||||
.nf
|
||||
$ age\-inspect secrets\.age
|
||||
secrets\.age is an age file, version "age\-encryption\.org/v1"\.
|
||||
|
||||
This file is encrypted to the following recipient types:
|
||||
\- "mlkem768x25519"
|
||||
|
||||
This file uses post\-quantum encryption\.
|
||||
|
||||
Size breakdown (assuming it decrypts successfully):
|
||||
|
||||
Header 1627 bytes
|
||||
Encryption overhead 32 bytes
|
||||
Payload 42 bytes
|
||||
\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-\-
|
||||
Total 1701 bytes
|
||||
|
||||
Tip: for machine\-readable output, use \-\-json\.
|
||||
.fi
|
||||
.IP "" 0
|
||||
.P
|
||||
Get JSON output for scripting:
|
||||
.IP "" 4
|
||||
.nf
|
||||
$ age\-inspect \-\-json secrets\.age
|
||||
{
|
||||
"version": "age\-encryption\.org/v1",
|
||||
"postquantum": "yes",
|
||||
"armor": false,
|
||||
"stanza_types": [
|
||||
"mlkem768x25519"
|
||||
],
|
||||
"sizes": {
|
||||
"header": 1627,
|
||||
"armor": 0,
|
||||
"overhead": 32,
|
||||
"min_payload": 42,
|
||||
"max_payload": 42,
|
||||
"min_padding": 0,
|
||||
"max_padding": 0
|
||||
}
|
||||
}
|
||||
.fi
|
||||
.IP "" 0
|
||||
.SH "SEE ALSO"
|
||||
age(1), age\-keygen(1)
|
||||
.SH "AUTHORS"
|
||||
Filippo Valsorda \fIage@filippo\.io\fR
|
||||
204
doc/age-inspect.1.html
Normal file
204
doc/age-inspect.1.html
Normal file
@@ -0,0 +1,204 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv='content-type' content='text/html;charset=utf8'>
|
||||
<meta name='generator' content='Ronn-NG/v0.9.1 (http://github.com/apjanke/ronn-ng/tree/0.9.1)'>
|
||||
<title>age-inspect(1) - inspect age(1) encrypted files</title>
|
||||
<style type='text/css' media='all'>
|
||||
/* style: man */
|
||||
body#manpage {margin:0}
|
||||
.mp {max-width:100ex;padding:0 9ex 1ex 4ex}
|
||||
.mp p,.mp pre,.mp ul,.mp ol,.mp dl {margin:0 0 20px 0}
|
||||
.mp h2 {margin:10px 0 0 0}
|
||||
.mp > p,.mp > pre,.mp > ul,.mp > ol,.mp > dl {margin-left:8ex}
|
||||
.mp h3 {margin:0 0 0 4ex}
|
||||
.mp dt {margin:0;clear:left}
|
||||
.mp dt.flush {float:left;width:8ex}
|
||||
.mp dd {margin:0 0 0 9ex}
|
||||
.mp h1,.mp h2,.mp h3,.mp h4 {clear:left}
|
||||
.mp pre {margin-bottom:20px}
|
||||
.mp pre+h2,.mp pre+h3 {margin-top:22px}
|
||||
.mp h2+pre,.mp h3+pre {margin-top:5px}
|
||||
.mp img {display:block;margin:auto}
|
||||
.mp h1.man-title {display:none}
|
||||
.mp,.mp code,.mp pre,.mp tt,.mp kbd,.mp samp,.mp h3,.mp h4 {font-family:monospace;font-size:14px;line-height:1.42857142857143}
|
||||
.mp h2 {font-size:16px;line-height:1.25}
|
||||
.mp h1 {font-size:20px;line-height:2}
|
||||
.mp {text-align:justify;background:#fff}
|
||||
.mp,.mp code,.mp pre,.mp pre code,.mp tt,.mp kbd,.mp samp {color:#131211}
|
||||
.mp h1,.mp h2,.mp h3,.mp h4 {color:#030201}
|
||||
.mp u {text-decoration:underline}
|
||||
.mp code,.mp strong,.mp b {font-weight:bold;color:#131211}
|
||||
.mp em,.mp var {font-style:italic;color:#232221;text-decoration:none}
|
||||
.mp a,.mp a:link,.mp a:hover,.mp a code,.mp a pre,.mp a tt,.mp a kbd,.mp a samp {color:#0000ff}
|
||||
.mp b.man-ref {font-weight:normal;color:#434241}
|
||||
.mp pre {padding:0 4ex}
|
||||
.mp pre code {font-weight:normal;color:#434241}
|
||||
.mp h2+pre,h3+pre {padding-left:0}
|
||||
ol.man-decor,ol.man-decor li {margin:3px 0 10px 0;padding:0;float:left;width:33%;list-style-type:none;text-transform:uppercase;color:#999;letter-spacing:1px}
|
||||
ol.man-decor {width:100%}
|
||||
ol.man-decor li.tl {text-align:left}
|
||||
ol.man-decor li.tc {text-align:center;letter-spacing:4px}
|
||||
ol.man-decor li.tr {text-align:right;float:right}
|
||||
</style>
|
||||
</head>
|
||||
<!--
|
||||
The following styles are deprecated and will be removed at some point:
|
||||
div#man, div#man ol.man, div#man ol.head, div#man ol.man.
|
||||
|
||||
The .man-page, .man-decor, .man-head, .man-foot, .man-title, and
|
||||
.man-navigation should be used instead.
|
||||
-->
|
||||
<body id='manpage'>
|
||||
<div class='mp' id='man'>
|
||||
|
||||
<div class='man-navigation' style='display:none'>
|
||||
<a href="#NAME">NAME</a>
|
||||
<a href="#SYNOPSIS">SYNOPSIS</a>
|
||||
<a href="#DESCRIPTION">DESCRIPTION</a>
|
||||
<a href="#OPTIONS">OPTIONS</a>
|
||||
<a href="#JSON-FORMAT">JSON FORMAT</a>
|
||||
<a href="#EXAMPLES">EXAMPLES</a>
|
||||
<a href="#SEE-ALSO">SEE ALSO</a>
|
||||
<a href="#AUTHORS">AUTHORS</a>
|
||||
</div>
|
||||
|
||||
<ol class='man-decor man-head man head'>
|
||||
<li class='tl'>age-inspect(1)</li>
|
||||
<li class='tc'></li>
|
||||
<li class='tr'>age-inspect(1)</li>
|
||||
</ol>
|
||||
|
||||
|
||||
|
||||
<h2 id="NAME">NAME</h2>
|
||||
<p class="man-name">
|
||||
<code>age-inspect</code> - <span class="man-whatis">inspect <a class="man-ref" href="age.1.html">age<span class="s">(1)</span></a> encrypted files</span>
|
||||
</p>
|
||||
<h2 id="SYNOPSIS">SYNOPSIS</h2>
|
||||
|
||||
<p><code>age-inspect</code> [<code>--json</code>] [<var>INPUT</var>]</p>
|
||||
|
||||
<h2 id="DESCRIPTION">DESCRIPTION</h2>
|
||||
|
||||
<p><code>age-inspect</code> reads an <a class="man-ref" href="age.1.html">age<span class="s">(1)</span></a> encrypted file from <var>INPUT</var> (or standard input)
|
||||
and displays metadata about it without decrypting.</p>
|
||||
|
||||
<p>This includes the recipient types, whether it uses post-quantum encryption,
|
||||
and a size breakdown of the file components.</p>
|
||||
|
||||
<h2 id="OPTIONS">OPTIONS</h2>
|
||||
|
||||
<dl>
|
||||
<dt><code>--json</code></dt>
|
||||
<dd> Output machine-readable JSON instead of human-readable text.</dd>
|
||||
<dt><code>--version</code></dt>
|
||||
<dd> Print the version and exit.</dd>
|
||||
</dl>
|
||||
|
||||
<h2 id="JSON-FORMAT">JSON FORMAT</h2>
|
||||
|
||||
<p>When <code>--json</code> is specified, the output is a JSON object with these fields:</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<p><code>version</code>:
|
||||
The age format version (e.g., <code>"age-encryption.org/v1"</code>).</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><code>postquantum</code>:
|
||||
Whether the file uses post-quantum encryption: <code>"yes"</code>, <code>"no"</code>, or
|
||||
<code>"unknown"</code>.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><code>armor</code>:
|
||||
Boolean indicating whether the file is ASCII-armored.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><code>stanza_types</code>:
|
||||
Array of recipient stanza type strings (e.g., <code>["X25519"]</code> or
|
||||
<code>["mlkem768x25519"]</code>).</p>
|
||||
</li>
|
||||
<li>
|
||||
<p><code>sizes</code>:
|
||||
Object containing size information in bytes:</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<code>header</code>: Size of the age header.</li>
|
||||
<li>
|
||||
<code>armor</code>: Armor encoding overhead (0 if not armored).</li>
|
||||
<li>
|
||||
<code>overhead</code>: Stream encryption overhead.</li>
|
||||
<li>
|
||||
<code>min_payload</code>, <code>max_payload</code>: Payload size bounds (currently always matching).</li>
|
||||
<li>
|
||||
<code>min_padding</code>, <code>max_padding</code>: Padding size bounds (currently always 0).</li>
|
||||
</ul>
|
||||
|
||||
<p>The fields add up to the total size of the file.</p>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="EXAMPLES">EXAMPLES</h2>
|
||||
|
||||
<p>Inspect an encrypted file:</p>
|
||||
|
||||
<pre><code>$ age-inspect secrets.age
|
||||
secrets.age is an age file, version "age-encryption.org/v1".
|
||||
|
||||
This file is encrypted to the following recipient types:
|
||||
- "mlkem768x25519"
|
||||
|
||||
This file uses post-quantum encryption.
|
||||
|
||||
Size breakdown (assuming it decrypts successfully):
|
||||
|
||||
Header 1627 bytes
|
||||
Encryption overhead 32 bytes
|
||||
Payload 42 bytes
|
||||
-------------------
|
||||
Total 1701 bytes
|
||||
|
||||
Tip: for machine-readable output, use --json.
|
||||
</code></pre>
|
||||
|
||||
<p>Get JSON output for scripting:</p>
|
||||
|
||||
<pre><code>$ age-inspect --json secrets.age
|
||||
{
|
||||
"version": "age-encryption.org/v1",
|
||||
"postquantum": "yes",
|
||||
"armor": false,
|
||||
"stanza_types": [
|
||||
"mlkem768x25519"
|
||||
],
|
||||
"sizes": {
|
||||
"header": 1627,
|
||||
"armor": 0,
|
||||
"overhead": 32,
|
||||
"min_payload": 42,
|
||||
"max_payload": 42,
|
||||
"min_padding": 0,
|
||||
"max_padding": 0
|
||||
}
|
||||
}
|
||||
</code></pre>
|
||||
|
||||
<h2 id="SEE-ALSO">SEE ALSO</h2>
|
||||
|
||||
<p><a class="man-ref" href="age.1.html">age<span class="s">(1)</span></a>, <a class="man-ref" href="age-keygen.1.html">age-keygen<span class="s">(1)</span></a></p>
|
||||
|
||||
<h2 id="AUTHORS">AUTHORS</h2>
|
||||
|
||||
<p>Filippo Valsorda <a href="mailto:age@filippo.io" data-bare-link="true">age@filippo.io</a></p>
|
||||
|
||||
<ol class='man-decor man-foot man foot'>
|
||||
<li class='tl'></li>
|
||||
<li class='tc'>December 2025</li>
|
||||
<li class='tr'>age-inspect(1)</li>
|
||||
</ol>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
102
doc/age-inspect.1.ronn
Normal file
102
doc/age-inspect.1.ronn
Normal file
@@ -0,0 +1,102 @@
|
||||
age-inspect(1) -- inspect age(1) encrypted files
|
||||
====================================================
|
||||
|
||||
## SYNOPSIS
|
||||
|
||||
`age-inspect` [`--json`] [<INPUT>]
|
||||
|
||||
## DESCRIPTION
|
||||
|
||||
`age-inspect` reads an age(1) encrypted file from <INPUT> (or standard input)
|
||||
and displays metadata about it without decrypting.
|
||||
|
||||
This includes the recipient types, whether it uses post-quantum encryption,
|
||||
and a size breakdown of the file components.
|
||||
|
||||
## OPTIONS
|
||||
|
||||
* `--json`:
|
||||
Output machine-readable JSON instead of human-readable text.
|
||||
|
||||
* `--version`:
|
||||
Print the version and exit.
|
||||
|
||||
## JSON FORMAT
|
||||
|
||||
When `--json` is specified, the output is a JSON object with these fields:
|
||||
|
||||
* `version`:
|
||||
The age format version (e.g., `"age-encryption.org/v1"`).
|
||||
|
||||
* `postquantum`:
|
||||
Whether the file uses post-quantum encryption: `"yes"`, `"no"`, or
|
||||
`"unknown"`.
|
||||
|
||||
* `armor`:
|
||||
Boolean indicating whether the file is ASCII-armored.
|
||||
|
||||
* `stanza_types`:
|
||||
Array of recipient stanza type strings (e.g., `["X25519"]` or
|
||||
`["mlkem768x25519"]`).
|
||||
|
||||
* `sizes`:
|
||||
Object containing size information in bytes:
|
||||
|
||||
* `header`: Size of the age header.
|
||||
* `armor`: Armor encoding overhead (0 if not armored).
|
||||
* `overhead`: Stream encryption overhead.
|
||||
* `min_payload`, `max_payload`: Payload size bounds (currently always matching).
|
||||
* `min_padding`, `max_padding`: Padding size bounds (currently always 0).
|
||||
|
||||
The fields add up to the total size of the file.
|
||||
|
||||
## EXAMPLES
|
||||
|
||||
Inspect an encrypted file:
|
||||
|
||||
$ age-inspect secrets.age
|
||||
secrets.age is an age file, version "age-encryption.org/v1".
|
||||
|
||||
This file is encrypted to the following recipient types:
|
||||
- "mlkem768x25519"
|
||||
|
||||
This file uses post-quantum encryption.
|
||||
|
||||
Size breakdown (assuming it decrypts successfully):
|
||||
|
||||
Header 1627 bytes
|
||||
Encryption overhead 32 bytes
|
||||
Payload 42 bytes
|
||||
-------------------
|
||||
Total 1701 bytes
|
||||
|
||||
Tip: for machine-readable output, use --json.
|
||||
|
||||
Get JSON output for scripting:
|
||||
|
||||
$ age-inspect --json secrets.age
|
||||
{
|
||||
"version": "age-encryption.org/v1",
|
||||
"postquantum": "yes",
|
||||
"armor": false,
|
||||
"stanza_types": [
|
||||
"mlkem768x25519"
|
||||
],
|
||||
"sizes": {
|
||||
"header": 1627,
|
||||
"armor": 0,
|
||||
"overhead": 32,
|
||||
"min_payload": 42,
|
||||
"max_payload": 42,
|
||||
"min_padding": 0,
|
||||
"max_padding": 0
|
||||
}
|
||||
}
|
||||
|
||||
## SEE ALSO
|
||||
|
||||
age(1), age-keygen(1)
|
||||
|
||||
## AUTHORS
|
||||
|
||||
Filippo Valsorda <age@filippo.io>
|
||||
@@ -1,10 +1,10 @@
|
||||
.\" generated with Ronn-NG/v0.9.1
|
||||
.\" http://github.com/apjanke/ronn-ng/tree/0.9.1
|
||||
.TH "AGE\-KEYGEN" "1" "April 2023" ""
|
||||
.TH "AGE\-KEYGEN" "1" "December 2025" ""
|
||||
.SH "NAME"
|
||||
\fBage\-keygen\fR \- generate age(1) key pairs
|
||||
.SH "SYNOPSIS"
|
||||
\fBage\-keygen\fR [\fB\-o\fR \fIOUTPUT\fR]
|
||||
\fBage\-keygen\fR [\fB\-pq\fR] [\fB\-o\fR \fIOUTPUT\fR]
|
||||
.br
|
||||
\fBage\-keygen\fR \fB\-y\fR [\fB\-o\fR \fIOUTPUT\fR] [\fIINPUT\fR]
|
||||
.br
|
||||
@@ -14,6 +14,11 @@
|
||||
If the output is not going to a terminal, \fBage\-keygen\fR prints the public key to standard error\.
|
||||
.SH "OPTIONS"
|
||||
.TP
|
||||
\fB\-pq\fR
|
||||
Generate a post\-quantum hybrid ML\-KEM\-768 + X25519 key pair\.
|
||||
.IP
|
||||
In the future, this might become the default\.
|
||||
.TP
|
||||
\fB\-o\fR, \fB\-\-output\fR=\fIOUTPUT\fR
|
||||
Write the identity to \fIOUTPUT\fR instead of standard output\.
|
||||
.IP
|
||||
@@ -25,7 +30,17 @@ Read an identity file from \fIINPUT\fR or from standard input and output the cor
|
||||
\fB\-\-version\fR
|
||||
Print the version and exit\.
|
||||
.SH "EXAMPLES"
|
||||
Generate a new identity:
|
||||
Generate a new post\-quantum identity:
|
||||
.IP "" 4
|
||||
.nf
|
||||
$ age\-keygen \-pq
|
||||
# created: 2025\-11\-17T13:39:06+01:00
|
||||
# public key: age1pq167[\|\.\|\.\|\. 1950 more characters \|\.\|\.\|\.]
|
||||
AGE\-SECRET\-KEY\-PQ\-1K30MYPZAHAXHR22YHH27EGDVLU0QNSUH3DSV7J7NR3X6D9LHXNWSDLTV4T
|
||||
.fi
|
||||
.IP "" 0
|
||||
.P
|
||||
Generate a new traditional identity:
|
||||
.IP "" 4
|
||||
.nf
|
||||
$ age\-keygen
|
||||
@@ -35,11 +50,11 @@ AGE\-SECRET\-KEY\-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9
|
||||
.fi
|
||||
.IP "" 0
|
||||
.P
|
||||
Write a new identity to \fBkey\.txt\fR:
|
||||
Write a new post\-quantum identity to \fBkey\.txt\fR:
|
||||
.IP "" 4
|
||||
.nf
|
||||
$ age\-keygen \-o key\.txt
|
||||
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
|
||||
$ age\-keygen \-pq \-o key\.txt
|
||||
Public key: age1pq1cd[\|\.\|\.\|\. 1950 more characters \|\.\|\.\|\.]
|
||||
.fi
|
||||
.IP "" 0
|
||||
.P
|
||||
@@ -47,10 +62,10 @@ Convert an identity to a recipient:
|
||||
.IP "" 4
|
||||
.nf
|
||||
$ age\-keygen \-y key\.txt
|
||||
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
|
||||
age1pq1cd[\|\.\|\.\|\. 1950 more characters \|\.\|\.\|\.]
|
||||
.fi
|
||||
.IP "" 0
|
||||
.SH "SEE ALSO"
|
||||
age(1)
|
||||
age(1), age\-inspect(1)
|
||||
.SH "AUTHORS"
|
||||
Filippo Valsorda \fIage@filippo\.io\fR
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
</p>
|
||||
<h2 id="SYNOPSIS">SYNOPSIS</h2>
|
||||
|
||||
<p><code>age-keygen</code> [<code>-o</code> <var>OUTPUT</var>]<br>
|
||||
<p><code>age-keygen</code> [<code>-pq</code>] [<code>-o</code> <var>OUTPUT</var>]<br>
|
||||
<code>age-keygen</code> <code>-y</code> [<code>-o</code> <var>OUTPUT</var>] [<var>INPUT</var>]<br></p>
|
||||
|
||||
<h2 id="DESCRIPTION">DESCRIPTION</h2>
|
||||
@@ -91,6 +91,11 @@ standard error.</p>
|
||||
<h2 id="OPTIONS">OPTIONS</h2>
|
||||
|
||||
<dl>
|
||||
<dt><code>-pq</code></dt>
|
||||
<dd> Generate a post-quantum hybrid ML-KEM-768 + X25519 key pair.
|
||||
|
||||
<p>In the future, this might become the default.</p>
|
||||
</dd>
|
||||
<dt>
|
||||
<code>-o</code>, <code>--output</code>=<var>OUTPUT</var>
|
||||
</dt>
|
||||
@@ -107,7 +112,15 @@ standard error.</p>
|
||||
|
||||
<h2 id="EXAMPLES">EXAMPLES</h2>
|
||||
|
||||
<p>Generate a new identity:</p>
|
||||
<p>Generate a new post-quantum identity:</p>
|
||||
|
||||
<pre><code>$ age-keygen -pq
|
||||
# created: 2025-11-17T13:39:06+01:00
|
||||
# public key: age1pq167[... 1950 more characters ...]
|
||||
AGE-SECRET-KEY-PQ-1K30MYPZAHAXHR22YHH27EGDVLU0QNSUH3DSV7J7NR3X6D9LHXNWSDLTV4T
|
||||
</code></pre>
|
||||
|
||||
<p>Generate a new traditional identity:</p>
|
||||
|
||||
<pre><code>$ age-keygen
|
||||
# created: 2021-01-02T15:30:45+01:00
|
||||
@@ -115,21 +128,21 @@ standard error.</p>
|
||||
AGE-SECRET-KEY-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9
|
||||
</code></pre>
|
||||
|
||||
<p>Write a new identity to <code>key.txt</code>:</p>
|
||||
<p>Write a new post-quantum identity to <code>key.txt</code>:</p>
|
||||
|
||||
<pre><code>$ age-keygen -o key.txt
|
||||
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
|
||||
<pre><code>$ age-keygen -pq -o key.txt
|
||||
Public key: age1pq1cd[... 1950 more characters ...]
|
||||
</code></pre>
|
||||
|
||||
<p>Convert an identity to a recipient:</p>
|
||||
|
||||
<pre><code>$ age-keygen -y key.txt
|
||||
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
|
||||
age1pq1cd[... 1950 more characters ...]
|
||||
</code></pre>
|
||||
|
||||
<h2 id="SEE-ALSO">SEE ALSO</h2>
|
||||
|
||||
<p><a class="man-ref" href="age.1.html">age<span class="s">(1)</span></a></p>
|
||||
<p><a class="man-ref" href="age.1.html">age<span class="s">(1)</span></a>, <a class="man-ref" href="age-inspect.1.html">age-inspect<span class="s">(1)</span></a></p>
|
||||
|
||||
<h2 id="AUTHORS">AUTHORS</h2>
|
||||
|
||||
@@ -137,7 +150,7 @@ age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
|
||||
|
||||
<ol class='man-decor man-foot man foot'>
|
||||
<li class='tl'></li>
|
||||
<li class='tc'>April 2023</li>
|
||||
<li class='tc'>December 2025</li>
|
||||
<li class='tr'>age-keygen(1)</li>
|
||||
</ol>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ age-keygen(1) -- generate age(1) key pairs
|
||||
|
||||
## SYNOPSIS
|
||||
|
||||
`age-keygen` [`-o` <OUTPUT>]<br>
|
||||
`age-keygen` [`-pq`] [`-o` <OUTPUT>]<br>
|
||||
`age-keygen` `-y` [`-o` <OUTPUT>] [<INPUT>]<br>
|
||||
|
||||
## DESCRIPTION
|
||||
@@ -17,6 +17,11 @@ standard error.
|
||||
|
||||
## OPTIONS
|
||||
|
||||
* `-pq`:
|
||||
Generate a post-quantum hybrid ML-KEM-768 + X25519 key pair.
|
||||
|
||||
In the future, this might become the default.
|
||||
|
||||
* `-o`, `--output`=<OUTPUT>:
|
||||
Write the identity to <OUTPUT> instead of standard output.
|
||||
|
||||
@@ -31,26 +36,33 @@ standard error.
|
||||
|
||||
## EXAMPLES
|
||||
|
||||
Generate a new identity:
|
||||
Generate a new post-quantum identity:
|
||||
|
||||
$ age-keygen -pq
|
||||
# created: 2025-11-17T13:39:06+01:00
|
||||
# public key: age1pq167[... 1950 more characters ...]
|
||||
AGE-SECRET-KEY-PQ-1K30MYPZAHAXHR22YHH27EGDVLU0QNSUH3DSV7J7NR3X6D9LHXNWSDLTV4T
|
||||
|
||||
Generate a new traditional identity:
|
||||
|
||||
$ age-keygen
|
||||
# created: 2021-01-02T15:30:45+01:00
|
||||
# public key: age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z
|
||||
AGE-SECRET-KEY-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9
|
||||
|
||||
Write a new identity to `key.txt`:
|
||||
Write a new post-quantum identity to `key.txt`:
|
||||
|
||||
$ age-keygen -o key.txt
|
||||
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
|
||||
$ age-keygen -pq -o key.txt
|
||||
Public key: age1pq1cd[... 1950 more characters ...]
|
||||
|
||||
Convert an identity to a recipient:
|
||||
|
||||
$ age-keygen -y key.txt
|
||||
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
|
||||
age1pq1cd[... 1950 more characters ...]
|
||||
|
||||
## SEE ALSO
|
||||
|
||||
age(1)
|
||||
age(1), age-inspect(1)
|
||||
|
||||
## AUTHORS
|
||||
|
||||
|
||||
87
doc/age-plugin-batchpass.1
Normal file
87
doc/age-plugin-batchpass.1
Normal file
@@ -0,0 +1,87 @@
|
||||
.\" generated with Ronn-NG/v0.9.1
|
||||
.\" http://github.com/apjanke/ronn-ng/tree/0.9.1
|
||||
.TH "AGE\-PLUGIN\-BATCHPASS" "1" "December 2025" ""
|
||||
.SH "NAME"
|
||||
\fBage\-plugin\-batchpass\fR \- non\-interactive passphrase encryption plugin for age(1)
|
||||
.SH "SYNOPSIS"
|
||||
\fBage\fR \fB\-e\fR \fB\-j\fR \fBbatchpass\fR
|
||||
.br
|
||||
\fBage\fR \fB\-d\fR \fB\-j\fR \fBbatchpass\fR
|
||||
.br
|
||||
.SH "DESCRIPTION"
|
||||
\fBage\-plugin\-batchpass\fR is an age(1) plugin that enables non\-interactive passphrase\-based encryption and decryption using environment variables\.
|
||||
.SH "WARNING"
|
||||
This functionality is not built into the age CLI because most applications should use native keys instead of scripting passphrase\-based encryption\.
|
||||
.P
|
||||
Humans are notoriously bad at remembering and generating strong passphrases\. age uses scrypt to partially mitigate this, which is necessarily very slow\.
|
||||
.P
|
||||
If a computer will be doing the remembering anyway, you can and should use native keys instead\. There is no need to manage separate public and private keys, you encrypt directly to the private key:
|
||||
.IP "" 4
|
||||
.nf
|
||||
$ age\-keygen \-o key\.txt
|
||||
$ age \-e \-i key\.txt file\.txt > file\.txt\.age
|
||||
$ age \-d \-i key\.txt file\.txt\.age > file\.txt
|
||||
.fi
|
||||
.IP "" 0
|
||||
.P
|
||||
Likewise, you can store a native identity string in an environment variable or through your CI secrets manager and use it to encrypt and decrypt files non\-interactively:
|
||||
.IP "" 4
|
||||
.nf
|
||||
$ export AGE_SECRET=$(age\-keygen)
|
||||
$ age \-e \-i <(echo "$AGE_SECRET") file\.txt > file\.txt\.age
|
||||
$ age \-d \-i <(echo "$AGE_SECRET") file\.txt\.age > file\.txt
|
||||
.fi
|
||||
.IP "" 0
|
||||
.P
|
||||
The age CLI also natively supports passphrase\-encrypted identity files, so you can use that functionality to non\-interactively encrypt multiple files such that you will be able to decrypt them later by entering the same passphrase:
|
||||
.IP "" 4
|
||||
.nf
|
||||
$ age\-keygen \-pq | age \-p \-o encrypted\-identity\.txt
|
||||
Public key: age1pq1cd[\|\.\|\.\|\. 1950 more characters \|\.\|\.\|\.]
|
||||
Enter passphrase (leave empty to autogenerate a secure one):
|
||||
age: using autogenerated passphrase "eternal\-erase\-keen\-suffer\-fog\-exclude\-huge\-scorpion\-escape\-scrub"
|
||||
$ age \-r age1pq1cd[\|\.\|\.\|\. 1950 more characters \|\.\|\.\|\.] file\.txt > file\.txt\.age
|
||||
$ age \-d \-i encrypted\-identity\.txt file\.txt\.age > file\.txt
|
||||
Enter passphrase for identity file "encrypted\-identity\.txt":
|
||||
.fi
|
||||
.IP "" 0
|
||||
.P
|
||||
Finally, when using this plugin care should be taken not to let the password be persisted in the shell history or leaked to other users on multi\-user systems\.
|
||||
.SH "ENVIRONMENT"
|
||||
.TP
|
||||
\fBAGE_PASSPHRASE\fR
|
||||
The passphrase to use for encryption or decryption\. Mutually exclusive with \fBAGE_PASSPHRASE_FD\fR\.
|
||||
.TP
|
||||
\fBAGE_PASSPHRASE_FD\fR
|
||||
A file descriptor number to read the passphrase from\. Trailing newlines are stripped from the file contents\. Mutually exclusive with \fBAGE_PASSPHRASE\fR\.
|
||||
.TP
|
||||
\fBAGE_PASSPHRASE_WORK_FACTOR\fR
|
||||
The scrypt work factor to use when encrypting\. Must be between 1 and 30\. Default is 18\. Higher values are more secure but slower\.
|
||||
.TP
|
||||
\fBAGE_PASSPHRASE_MAX_WORK_FACTOR\fR
|
||||
The maximum scrypt work factor to accept when decrypting\. Must be between 1 and 30\. Default is 30\. Can be used to avoid very slow decryptions\.
|
||||
.SH "EXAMPLES"
|
||||
Encrypt a file with a passphrase:
|
||||
.IP "" 4
|
||||
.nf
|
||||
$ AGE_PASSPHRASE=secret age \-e \-j batchpass file\.txt > file\.txt\.age
|
||||
.fi
|
||||
.IP "" 0
|
||||
.P
|
||||
Decrypt a file with a passphrase:
|
||||
.IP "" 4
|
||||
.nf
|
||||
$ AGE_PASSPHRASE=secret age \-d \-j batchpass file\.txt\.age > file\.txt
|
||||
.fi
|
||||
.IP "" 0
|
||||
.P
|
||||
Read the passphrase from a file descriptor:
|
||||
.IP "" 4
|
||||
.nf
|
||||
$ AGE_PASSPHRASE_FD=3 age \-e \-j batchpass file\.txt 3< passphrase\.txt > file\.txt\.age
|
||||
.fi
|
||||
.IP "" 0
|
||||
.SH "SEE ALSO"
|
||||
age(1)
|
||||
.SH "AUTHORS"
|
||||
Filippo Valsorda \fIage@filippo\.io\fR
|
||||
183
doc/age-plugin-batchpass.1.html
Normal file
183
doc/age-plugin-batchpass.1.html
Normal file
@@ -0,0 +1,183 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv='content-type' content='text/html;charset=utf8'>
|
||||
<meta name='generator' content='Ronn-NG/v0.9.1 (http://github.com/apjanke/ronn-ng/tree/0.9.1)'>
|
||||
<title>age-plugin-batchpass(1) - non-interactive passphrase encryption plugin for age(1)</title>
|
||||
<style type='text/css' media='all'>
|
||||
/* style: man */
|
||||
body#manpage {margin:0}
|
||||
.mp {max-width:100ex;padding:0 9ex 1ex 4ex}
|
||||
.mp p,.mp pre,.mp ul,.mp ol,.mp dl {margin:0 0 20px 0}
|
||||
.mp h2 {margin:10px 0 0 0}
|
||||
.mp > p,.mp > pre,.mp > ul,.mp > ol,.mp > dl {margin-left:8ex}
|
||||
.mp h3 {margin:0 0 0 4ex}
|
||||
.mp dt {margin:0;clear:left}
|
||||
.mp dt.flush {float:left;width:8ex}
|
||||
.mp dd {margin:0 0 0 9ex}
|
||||
.mp h1,.mp h2,.mp h3,.mp h4 {clear:left}
|
||||
.mp pre {margin-bottom:20px}
|
||||
.mp pre+h2,.mp pre+h3 {margin-top:22px}
|
||||
.mp h2+pre,.mp h3+pre {margin-top:5px}
|
||||
.mp img {display:block;margin:auto}
|
||||
.mp h1.man-title {display:none}
|
||||
.mp,.mp code,.mp pre,.mp tt,.mp kbd,.mp samp,.mp h3,.mp h4 {font-family:monospace;font-size:14px;line-height:1.42857142857143}
|
||||
.mp h2 {font-size:16px;line-height:1.25}
|
||||
.mp h1 {font-size:20px;line-height:2}
|
||||
.mp {text-align:justify;background:#fff}
|
||||
.mp,.mp code,.mp pre,.mp pre code,.mp tt,.mp kbd,.mp samp {color:#131211}
|
||||
.mp h1,.mp h2,.mp h3,.mp h4 {color:#030201}
|
||||
.mp u {text-decoration:underline}
|
||||
.mp code,.mp strong,.mp b {font-weight:bold;color:#131211}
|
||||
.mp em,.mp var {font-style:italic;color:#232221;text-decoration:none}
|
||||
.mp a,.mp a:link,.mp a:hover,.mp a code,.mp a pre,.mp a tt,.mp a kbd,.mp a samp {color:#0000ff}
|
||||
.mp b.man-ref {font-weight:normal;color:#434241}
|
||||
.mp pre {padding:0 4ex}
|
||||
.mp pre code {font-weight:normal;color:#434241}
|
||||
.mp h2+pre,h3+pre {padding-left:0}
|
||||
ol.man-decor,ol.man-decor li {margin:3px 0 10px 0;padding:0;float:left;width:33%;list-style-type:none;text-transform:uppercase;color:#999;letter-spacing:1px}
|
||||
ol.man-decor {width:100%}
|
||||
ol.man-decor li.tl {text-align:left}
|
||||
ol.man-decor li.tc {text-align:center;letter-spacing:4px}
|
||||
ol.man-decor li.tr {text-align:right;float:right}
|
||||
</style>
|
||||
</head>
|
||||
<!--
|
||||
The following styles are deprecated and will be removed at some point:
|
||||
div#man, div#man ol.man, div#man ol.head, div#man ol.man.
|
||||
|
||||
The .man-page, .man-decor, .man-head, .man-foot, .man-title, and
|
||||
.man-navigation should be used instead.
|
||||
-->
|
||||
<body id='manpage'>
|
||||
<div class='mp' id='man'>
|
||||
|
||||
<div class='man-navigation' style='display:none'>
|
||||
<a href="#NAME">NAME</a>
|
||||
<a href="#SYNOPSIS">SYNOPSIS</a>
|
||||
<a href="#DESCRIPTION">DESCRIPTION</a>
|
||||
<a href="#WARNING">WARNING</a>
|
||||
<a href="#ENVIRONMENT">ENVIRONMENT</a>
|
||||
<a href="#EXAMPLES">EXAMPLES</a>
|
||||
<a href="#SEE-ALSO">SEE ALSO</a>
|
||||
<a href="#AUTHORS">AUTHORS</a>
|
||||
</div>
|
||||
|
||||
<ol class='man-decor man-head man head'>
|
||||
<li class='tl'>age-plugin-batchpass(1)</li>
|
||||
<li class='tc'></li>
|
||||
<li class='tr'>age-plugin-batchpass(1)</li>
|
||||
</ol>
|
||||
|
||||
|
||||
|
||||
<h2 id="NAME">NAME</h2>
|
||||
<p class="man-name">
|
||||
<code>age-plugin-batchpass</code> - <span class="man-whatis">non-interactive passphrase encryption plugin for <a class="man-ref" href="age.1.html">age<span class="s">(1)</span></a></span>
|
||||
</p>
|
||||
<h2 id="SYNOPSIS">SYNOPSIS</h2>
|
||||
|
||||
<p><code>age</code> <code>-e</code> <code>-j</code> <code>batchpass</code><br>
|
||||
<code>age</code> <code>-d</code> <code>-j</code> <code>batchpass</code><br></p>
|
||||
|
||||
<h2 id="DESCRIPTION">DESCRIPTION</h2>
|
||||
|
||||
<p><code>age-plugin-batchpass</code> is an <a class="man-ref" href="age.1.html">age<span class="s">(1)</span></a> plugin that enables non-interactive
|
||||
passphrase-based encryption and decryption using environment variables.</p>
|
||||
|
||||
<h2 id="WARNING">WARNING</h2>
|
||||
|
||||
<p>This functionality is not built into the age CLI because most applications
|
||||
should use native keys instead of scripting passphrase-based encryption.</p>
|
||||
|
||||
<p>Humans are notoriously bad at remembering and generating strong passphrases.
|
||||
age uses scrypt to partially mitigate this, which is necessarily very slow.</p>
|
||||
|
||||
<p>If a computer will be doing the remembering anyway, you can and should use
|
||||
native keys instead. There is no need to manage separate public and private
|
||||
keys, you encrypt directly to the private key:</p>
|
||||
|
||||
<pre><code>$ age-keygen -o key.txt
|
||||
$ age -e -i key.txt file.txt > file.txt.age
|
||||
$ age -d -i key.txt file.txt.age > file.txt
|
||||
</code></pre>
|
||||
|
||||
<p>Likewise, you can store a native identity string in an environment variable
|
||||
or through your CI secrets manager and use it to encrypt and decrypt files
|
||||
non-interactively:</p>
|
||||
|
||||
<pre><code>$ export AGE_SECRET=$(age-keygen)
|
||||
$ age -e -i <(echo "$AGE_SECRET") file.txt > file.txt.age
|
||||
$ age -d -i <(echo "$AGE_SECRET") file.txt.age > file.txt
|
||||
</code></pre>
|
||||
|
||||
<p>The age CLI also natively supports passphrase-encrypted identity files, so you
|
||||
can use that functionality to non-interactively encrypt multiple files such that
|
||||
you will be able to decrypt them later by entering the same passphrase:</p>
|
||||
|
||||
<pre><code>$ age-keygen -pq | age -p -o encrypted-identity.txt
|
||||
Public key: age1pq1cd[... 1950 more characters ...]
|
||||
Enter passphrase (leave empty to autogenerate a secure one):
|
||||
age: using autogenerated passphrase "eternal-erase-keen-suffer-fog-exclude-huge-scorpion-escape-scrub"
|
||||
$ age -r age1pq1cd[... 1950 more characters ...] file.txt > file.txt.age
|
||||
$ age -d -i encrypted-identity.txt file.txt.age > file.txt
|
||||
Enter passphrase for identity file "encrypted-identity.txt":
|
||||
</code></pre>
|
||||
|
||||
<p>Finally, when using this plugin care should be taken not to let the password be
|
||||
persisted in the shell history or leaked to other users on multi-user systems.</p>
|
||||
|
||||
<h2 id="ENVIRONMENT">ENVIRONMENT</h2>
|
||||
|
||||
<dl>
|
||||
<dt><code>AGE_PASSPHRASE</code></dt>
|
||||
<dd> The passphrase to use for encryption or decryption.
|
||||
Mutually exclusive with <code>AGE_PASSPHRASE_FD</code>.</dd>
|
||||
<dt><code>AGE_PASSPHRASE_FD</code></dt>
|
||||
<dd> A file descriptor number to read the passphrase from.
|
||||
Trailing newlines are stripped from the file contents.
|
||||
Mutually exclusive with <code>AGE_PASSPHRASE</code>.</dd>
|
||||
<dt><code>AGE_PASSPHRASE_WORK_FACTOR</code></dt>
|
||||
<dd> The scrypt work factor to use when encrypting.
|
||||
Must be between 1 and 30. Default is 18.
|
||||
Higher values are more secure but slower.</dd>
|
||||
<dt><code>AGE_PASSPHRASE_MAX_WORK_FACTOR</code></dt>
|
||||
<dd> The maximum scrypt work factor to accept when decrypting.
|
||||
Must be between 1 and 30. Default is 30.
|
||||
Can be used to avoid very slow decryptions.</dd>
|
||||
</dl>
|
||||
|
||||
<h2 id="EXAMPLES">EXAMPLES</h2>
|
||||
|
||||
<p>Encrypt a file with a passphrase:</p>
|
||||
|
||||
<pre><code>$ AGE_PASSPHRASE=secret age -e -j batchpass file.txt > file.txt.age
|
||||
</code></pre>
|
||||
|
||||
<p>Decrypt a file with a passphrase:</p>
|
||||
|
||||
<pre><code>$ AGE_PASSPHRASE=secret age -d -j batchpass file.txt.age > file.txt
|
||||
</code></pre>
|
||||
|
||||
<p>Read the passphrase from a file descriptor:</p>
|
||||
|
||||
<pre><code>$ AGE_PASSPHRASE_FD=3 age -e -j batchpass file.txt 3< passphrase.txt > file.txt.age
|
||||
</code></pre>
|
||||
|
||||
<h2 id="SEE-ALSO">SEE ALSO</h2>
|
||||
|
||||
<p><a class="man-ref" href="age.1.html">age<span class="s">(1)</span></a></p>
|
||||
|
||||
<h2 id="AUTHORS">AUTHORS</h2>
|
||||
|
||||
<p>Filippo Valsorda <a href="mailto:age@filippo.io" data-bare-link="true">age@filippo.io</a></p>
|
||||
|
||||
<ol class='man-decor man-foot man foot'>
|
||||
<li class='tl'></li>
|
||||
<li class='tc'>December 2025</li>
|
||||
<li class='tr'>age-plugin-batchpass(1)</li>
|
||||
</ol>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
94
doc/age-plugin-batchpass.1.ronn
Normal file
94
doc/age-plugin-batchpass.1.ronn
Normal file
@@ -0,0 +1,94 @@
|
||||
age-plugin-batchpass(1) -- non-interactive passphrase encryption plugin for age(1)
|
||||
==================================================================================
|
||||
|
||||
## SYNOPSIS
|
||||
|
||||
`age` `-e` `-j` `batchpass`<br>
|
||||
`age` `-d` `-j` `batchpass`<br>
|
||||
|
||||
## DESCRIPTION
|
||||
|
||||
`age-plugin-batchpass` is an age(1) plugin that enables non-interactive
|
||||
passphrase-based encryption and decryption using environment variables.
|
||||
|
||||
## WARNING
|
||||
|
||||
This functionality is not built into the age CLI because most applications
|
||||
should use native keys instead of scripting passphrase-based encryption.
|
||||
|
||||
Humans are notoriously bad at remembering and generating strong passphrases.
|
||||
age uses scrypt to partially mitigate this, which is necessarily very slow.
|
||||
|
||||
If a computer will be doing the remembering anyway, you can and should use
|
||||
native keys instead. There is no need to manage separate public and private
|
||||
keys, you encrypt directly to the private key:
|
||||
|
||||
$ age-keygen -o key.txt
|
||||
$ age -e -i key.txt file.txt > file.txt.age
|
||||
$ age -d -i key.txt file.txt.age > file.txt
|
||||
|
||||
Likewise, you can store a native identity string in an environment variable
|
||||
or through your CI secrets manager and use it to encrypt and decrypt files
|
||||
non-interactively:
|
||||
|
||||
$ export AGE_SECRET=$(age-keygen)
|
||||
$ age -e -i <(echo "$AGE_SECRET") file.txt > file.txt.age
|
||||
$ age -d -i <(echo "$AGE_SECRET") file.txt.age > file.txt
|
||||
|
||||
The age CLI also natively supports passphrase-encrypted identity files, so you
|
||||
can use that functionality to non-interactively encrypt multiple files such that
|
||||
you will be able to decrypt them later by entering the same passphrase:
|
||||
|
||||
$ age-keygen -pq | age -p -o encrypted-identity.txt
|
||||
Public key: age1pq1cd[... 1950 more characters ...]
|
||||
Enter passphrase (leave empty to autogenerate a secure one):
|
||||
age: using autogenerated passphrase "eternal-erase-keen-suffer-fog-exclude-huge-scorpion-escape-scrub"
|
||||
$ age -r age1pq1cd[... 1950 more characters ...] file.txt > file.txt.age
|
||||
$ age -d -i encrypted-identity.txt file.txt.age > file.txt
|
||||
Enter passphrase for identity file "encrypted-identity.txt":
|
||||
|
||||
Finally, when using this plugin care should be taken not to let the password be
|
||||
persisted in the shell history or leaked to other users on multi-user systems.
|
||||
|
||||
## ENVIRONMENT
|
||||
|
||||
* `AGE_PASSPHRASE`:
|
||||
The passphrase to use for encryption or decryption.
|
||||
Mutually exclusive with `AGE_PASSPHRASE_FD`.
|
||||
|
||||
* `AGE_PASSPHRASE_FD`:
|
||||
A file descriptor number to read the passphrase from.
|
||||
Trailing newlines are stripped from the file contents.
|
||||
Mutually exclusive with `AGE_PASSPHRASE`.
|
||||
|
||||
* `AGE_PASSPHRASE_WORK_FACTOR`:
|
||||
The scrypt work factor to use when encrypting.
|
||||
Must be between 1 and 30. Default is 18.
|
||||
Higher values are more secure but slower.
|
||||
|
||||
* `AGE_PASSPHRASE_MAX_WORK_FACTOR`:
|
||||
The maximum scrypt work factor to accept when decrypting.
|
||||
Must be between 1 and 30. Default is 30.
|
||||
Can be used to avoid very slow decryptions.
|
||||
|
||||
## EXAMPLES
|
||||
|
||||
Encrypt a file with a passphrase:
|
||||
|
||||
$ AGE_PASSPHRASE=secret age -e -j batchpass file.txt > file.txt.age
|
||||
|
||||
Decrypt a file with a passphrase:
|
||||
|
||||
$ AGE_PASSPHRASE=secret age -d -j batchpass file.txt.age > file.txt
|
||||
|
||||
Read the passphrase from a file descriptor:
|
||||
|
||||
$ AGE_PASSPHRASE_FD=3 age -e -j batchpass file.txt 3< passphrase.txt > file.txt.age
|
||||
|
||||
## SEE ALSO
|
||||
|
||||
age(1)
|
||||
|
||||
## AUTHORS
|
||||
|
||||
Filippo Valsorda <age@filippo.io>
|
||||
49
doc/age.1
49
doc/age.1
@@ -1,6 +1,6 @@
|
||||
.\" generated with Ronn-NG/v0.9.1
|
||||
.\" http://github.com/apjanke/ronn-ng/tree/0.9.1
|
||||
.TH "AGE" "1" "April 2023" ""
|
||||
.TH "AGE" "1" "December 2025" ""
|
||||
.SH "NAME"
|
||||
\fBage\fR \- simple, modern, and secure file encryption
|
||||
.SH "SYNOPSIS"
|
||||
@@ -99,23 +99,39 @@ Decrypt using the data\-less \fIplugin\fR \fIPLUGIN\fR\.
|
||||
This is equivalent to using \fB\-i\fR/\fB\-\-identity\fR with a file that contains a single plugin \fBIDENTITY\fR that encodes no plugin\-specific data\.
|
||||
.SH "RECIPIENTS AND IDENTITIES"
|
||||
\fBRECIPIENTS\fR are public values, like a public key, that a file can be encrypted to\. \fBIDENTITIES\fR are private values, like a private key, that allow decrypting a file encrypted to the corresponding \fBRECIPIENT\fR\.
|
||||
.SS "Native X25519 keys"
|
||||
Native \fBage\fR key pairs are generated with age\-keygen(1), and provide small encodings and strong encryption based on X25519\. They are the recommended recipient type for most applications\.
|
||||
.SS "Native keys"
|
||||
Native \fBage\fR key pairs are generated with age\-keygen(1), and provide small encodings and strong encryption based on X25519 for classic keys, and X25519 + ML\-KEM\-768 for post\-quantum hybrid keys\. The post\-quantum hybrid keys are secure against future quantum computers and are the recommended recipient type for most applications\.
|
||||
.P
|
||||
A \fBRECIPIENT\fR encoding begins with \fBage1\fR and looks like the following:
|
||||
A hybrid \fBRECIPIENT\fR encoding begins with \fBage1pq1\fR and looks like the following:
|
||||
.IP "" 4
|
||||
.nf
|
||||
age1pq167[\|\.\|\.\|\. 1950 more characters \|\.\|\.\|\.]
|
||||
.fi
|
||||
.IP "" 0
|
||||
.P
|
||||
A hybrid \fBIDENTITY\fR encoding begins with \fBAGE\-SECRET\-KEY\-PQ\-1\fR and looks like the following:
|
||||
.IP "" 4
|
||||
.nf
|
||||
AGE\-SECRET\-KEY\-PQ\-1K30MYPZAHAXHR22YHH27EGDVLU0QNSUH3DSV7J7NR3X6D9LHXNWSDLTV4T
|
||||
.fi
|
||||
.IP "" 0
|
||||
.P
|
||||
A classic \fBRECIPIENT\fR encoding begins with \fBage1\fR and looks like the following:
|
||||
.IP "" 4
|
||||
.nf
|
||||
age1gde3ncmahlqd9gg50tanl99r960llztrhfapnmx853s4tjum03uqfssgdh
|
||||
.fi
|
||||
.IP "" 0
|
||||
.P
|
||||
An \fBIDENTITY\fR encoding begins with \fBAGE\-SECRET\-KEY\-1\fR and looks like the following:
|
||||
A classic \fBIDENTITY\fR encoding begins with \fBAGE\-SECRET\-KEY\-1\fR and looks like the following:
|
||||
.IP "" 4
|
||||
.nf
|
||||
AGE\-SECRET\-KEY\-1KTYK6RVLN5TAPE7VF6FQQSKZ9HWWCDSKUGXXNUQDWZ7XXT5YK5LSF3UTKQ
|
||||
.fi
|
||||
.IP "" 0
|
||||
.P
|
||||
A file can't be encrypted to both post\-quantum and classic keys, as that would defeat the post\-quantum security of the encryption\.
|
||||
.P
|
||||
An encrypted file can't be linked to the native recipient it's encrypted to without access to the corresponding identity\.
|
||||
.SS "SSH keys"
|
||||
As a convenience feature, \fBage\fR also supports encrypting to RSA or Ed25519 ssh(1) 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\.
|
||||
@@ -147,6 +163,12 @@ 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 \fBRECIPIENT\fR or even with a passphrase\. Similarly, a plugin can encrypt a file such that it can be decrypted without the use of any plugin\.
|
||||
.P
|
||||
Plugins for which the \fBIDENTITY\fR/\fBRECIPIENT\fR distinction doesn't make sense (such as a symmetric encryption plugin) may generate only an \fBIDENTITY\fR and instruct the user to perform encryption with the \fB\-e\fR/\fB\-\-encrypt\fR and \fB\-i\fR/\fB\-\-identity\fR 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 \fB\-j\fR flag\.
|
||||
.P
|
||||
\fBage\fR can natively encrypt to recipients starting with \fBage1tag1\fR (using P\-256 ECDH) or \fBage1tagpq1\fR (using the ML\-KEM\-768 + P\-256 post\-quantum hybrid)\. These are intended to be the public side of private keys held in hardware\.
|
||||
.P
|
||||
They are directly supported to avoid the need to install the plugin, which may be platform\-specific, on the encrypting side\.
|
||||
.P
|
||||
The tag reduces privacy, by allowing an observer to correlate files with a recipient (but not files amongst them without knowledge of the recipient), but this is also a desirable property for hardware keys that require user interaction for each decryption operation\.
|
||||
.SH "EXIT STATUS"
|
||||
\fBage\fR will exit 0 if and only if encryption or decryption are successful for the full length of the input\.
|
||||
.P
|
||||
@@ -156,13 +178,13 @@ Files encrypted with a stable version (not alpha, beta, or release candidate) of
|
||||
.P
|
||||
If decrypting older files poses a security risk, doing so might cause an error by default\. In this case, a flag will be provided to force the operation\.
|
||||
.SH "EXAMPLES"
|
||||
Generate a new identity, encrypt data, and decrypt:
|
||||
Generate a new post\-quantum identity, encrypt data, and decrypt:
|
||||
.IP "" 4
|
||||
.nf
|
||||
$ age\-keygen \-o key\.txt
|
||||
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
|
||||
$ age\-keygen \-pq \-o key\.txt
|
||||
Public key: age1pq167[\|\.\|\.\|\. 1950 more characters \|\.\|\.\|\.]
|
||||
|
||||
$ tar cvz ~/data | age \-r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p > data\.tar\.gz\.age
|
||||
$ tar cvz ~/data | age \-r age1pq167[\|\.\|\.\|\.] > data\.tar\.gz\.age
|
||||
|
||||
$ age \-d \-o data\.tar\.gz \-i key\.txt data\.tar\.gz\.age
|
||||
.fi
|
||||
@@ -171,8 +193,7 @@ $ age \-d \-o data\.tar\.gz \-i key\.txt data\.tar\.gz\.age
|
||||
Encrypt \fBexample\.jpg\fR to multiple recipients and output to \fBexample\.jpg\.age\fR:
|
||||
.IP "" 4
|
||||
.nf
|
||||
$ age \-o example\.jpg\.age \-r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p \e
|
||||
\-r age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg example\.jpg
|
||||
$ age \-o example\.jpg\.age \-r age1pq167[\|\.\|\.\|\.] \-r age1pq1e3[\|\.\|\.\|\.] example\.jpg
|
||||
.fi
|
||||
.IP "" 0
|
||||
.P
|
||||
@@ -181,9 +202,9 @@ Encrypt to a list of recipients:
|
||||
.nf
|
||||
$ cat > recipients\.txt
|
||||
# Alice
|
||||
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
|
||||
age1pq167[\|\.\|\.\|\. 1950 more characters \|\.\|\.\|\.]
|
||||
# Bob
|
||||
age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg
|
||||
age1pq1e3[\|\.\|\.\|\. 1950 more characters \|\.\|\.\|\.]
|
||||
|
||||
$ age \-R recipients\.txt example\.jpg > example\.jpg\.age
|
||||
.fi
|
||||
@@ -243,6 +264,6 @@ $ curl https://github\.com/benjojo\.keys | age \-R \- example\.jpg > example\.jp
|
||||
.fi
|
||||
.IP "" 0
|
||||
.SH "SEE ALSO"
|
||||
age\-keygen(1)
|
||||
age\-keygen(1), age\-inspect(1)
|
||||
.SH "AUTHORS"
|
||||
Filippo Valsorda \fIage@filippo\.io\fR
|
||||
|
||||
@@ -253,23 +253,39 @@ overhead per recipient, plus 16 bytes every 64KiB of plaintext.</p>
|
||||
to. <code>IDENTITIES</code> are private values, like a private key, that allow decrypting
|
||||
a file encrypted to the corresponding <code>RECIPIENT</code>.</p>
|
||||
|
||||
<h3 id="Native-X25519-keys">Native X25519 keys</h3>
|
||||
<h3 id="Native-keys">Native keys</h3>
|
||||
|
||||
<p>Native <code>age</code> key pairs are generated with <a class="man-ref" href="age-keygen.1.html">age-keygen<span class="s">(1)</span></a>, and provide small
|
||||
encodings and strong encryption based on X25519. They are the recommended
|
||||
recipient type for most applications.</p>
|
||||
encodings and strong encryption based on X25519 for classic keys, and X25519 +
|
||||
ML-KEM-768 for post-quantum hybrid keys. The post-quantum hybrid keys are secure
|
||||
against future quantum computers and are the recommended recipient type for most
|
||||
applications.</p>
|
||||
|
||||
<p>A <code>RECIPIENT</code> encoding begins with <code>age1</code> and looks like the following:</p>
|
||||
<p>A hybrid <code>RECIPIENT</code> encoding begins with <code>age1pq1</code> and looks like the following:</p>
|
||||
|
||||
<pre><code>age1pq167[... 1950 more characters ...]
|
||||
</code></pre>
|
||||
|
||||
<p>A hybrid <code>IDENTITY</code> encoding begins with <code>AGE-SECRET-KEY-PQ-1</code> and looks like
|
||||
the following:</p>
|
||||
|
||||
<pre><code>AGE-SECRET-KEY-PQ-1K30MYPZAHAXHR22YHH27EGDVLU0QNSUH3DSV7J7NR3X6D9LHXNWSDLTV4T
|
||||
</code></pre>
|
||||
|
||||
<p>A classic <code>RECIPIENT</code> encoding begins with <code>age1</code> and looks like the following:</p>
|
||||
|
||||
<pre><code>age1gde3ncmahlqd9gg50tanl99r960llztrhfapnmx853s4tjum03uqfssgdh
|
||||
</code></pre>
|
||||
|
||||
<p>An <code>IDENTITY</code> encoding begins with <code>AGE-SECRET-KEY-1</code> and looks like the
|
||||
<p>A classic <code>IDENTITY</code> encoding begins with <code>AGE-SECRET-KEY-1</code> and looks like the
|
||||
following:</p>
|
||||
|
||||
<pre><code>AGE-SECRET-KEY-1KTYK6RVLN5TAPE7VF6FQQSKZ9HWWCDSKUGXXNUQDWZ7XXT5YK5LSF3UTKQ
|
||||
</code></pre>
|
||||
|
||||
<p>A file can't be encrypted to both post-quantum and classic keys, as that would
|
||||
defeat the post-quantum security of the encryption.</p>
|
||||
|
||||
<p>An encrypted file can't be linked to the native recipient it's encrypted to
|
||||
without access to the corresponding identity.</p>
|
||||
|
||||
@@ -331,6 +347,20 @@ instruct the user to perform encryption with the <code>-e</code>/<code>--encrypt
|
||||
doesn't make sense (such as a password-encryption plugin) may instruct the user
|
||||
to use the <code>-j</code> flag.</p>
|
||||
|
||||
<h4 id="Tagged-recipients">Tagged recipients</h4>
|
||||
|
||||
<p><code>age</code> can natively encrypt to recipients starting with <code>age1tag1</code> (using P-256
|
||||
ECDH) or <code>age1tagpq1</code> (using the ML-KEM-768 + P-256 post-quantum hybrid). These
|
||||
are intended to be the public side of private keys held in hardware.</p>
|
||||
|
||||
<p>They are directly supported to avoid the need to install the plugin, which may
|
||||
be platform-specific, on the encrypting side.</p>
|
||||
|
||||
<p>The tag reduces privacy, by allowing an observer to correlate files with a
|
||||
recipient (but not files amongst them without knowledge of the recipient),
|
||||
but this is also a desirable property for hardware keys that require user
|
||||
interaction for each decryption operation.</p>
|
||||
|
||||
<h2 id="EXIT-STATUS">EXIT STATUS</h2>
|
||||
|
||||
<p><code>age</code> will exit 0 if and only if encryption or decryption are successful for the
|
||||
@@ -351,29 +381,28 @@ by default. In this case, a flag will be provided to force the operation.</p>
|
||||
|
||||
<h2 id="EXAMPLES">EXAMPLES</h2>
|
||||
|
||||
<p>Generate a new identity, encrypt data, and decrypt:</p>
|
||||
<p>Generate a new post-quantum identity, encrypt data, and decrypt:</p>
|
||||
|
||||
<pre><code>$ age-keygen -o key.txt
|
||||
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
|
||||
<pre><code>$ age-keygen -pq -o key.txt
|
||||
Public key: age1pq167[... 1950 more characters ...]
|
||||
|
||||
$ tar cvz ~/data | age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p > data.tar.gz.age
|
||||
$ tar cvz ~/data | age -r age1pq167[...] > data.tar.gz.age
|
||||
|
||||
$ age -d -o data.tar.gz -i key.txt data.tar.gz.age
|
||||
</code></pre>
|
||||
|
||||
<p>Encrypt <code>example.jpg</code> to multiple recipients and output to <code>example.jpg.age</code>:</p>
|
||||
|
||||
<pre><code>$ age -o example.jpg.age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p \
|
||||
-r age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg example.jpg
|
||||
<pre><code>$ age -o example.jpg.age -r age1pq167[...] -r age1pq1e3[...] example.jpg
|
||||
</code></pre>
|
||||
|
||||
<p>Encrypt to a list of recipients:</p>
|
||||
|
||||
<pre><code>$ cat > recipients.txt
|
||||
# Alice
|
||||
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
|
||||
age1pq167[... 1950 more characters ...]
|
||||
# Bob
|
||||
age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg
|
||||
age1pq1e3[... 1950 more characters ...]
|
||||
|
||||
$ age -R recipients.txt example.jpg > example.jpg.age
|
||||
</code></pre>
|
||||
@@ -424,7 +453,7 @@ $ age -d -i age-yubikey-identity-388178f3.txt secrets.txt.age
|
||||
|
||||
<h2 id="SEE-ALSO">SEE ALSO</h2>
|
||||
|
||||
<p><a class="man-ref" href="age-keygen.1.html">age-keygen<span class="s">(1)</span></a></p>
|
||||
<p><a class="man-ref" href="age-keygen.1.html">age-keygen<span class="s">(1)</span></a>, <a class="man-ref" href="age-inspect.1.html">age-inspect<span class="s">(1)</span></a></p>
|
||||
|
||||
<h2 id="AUTHORS">AUTHORS</h2>
|
||||
|
||||
@@ -432,7 +461,7 @@ $ age -d -i age-yubikey-identity-388178f3.txt secrets.txt.age
|
||||
|
||||
<ol class='man-decor man-foot man foot'>
|
||||
<li class='tl'></li>
|
||||
<li class='tc'>April 2023</li>
|
||||
<li class='tc'>December 2025</li>
|
||||
<li class='tr'>age(1)</li>
|
||||
</ol>
|
||||
|
||||
|
||||
@@ -148,21 +148,35 @@ overhead per recipient, plus 16 bytes every 64KiB of plaintext.
|
||||
to. `IDENTITIES` are private values, like a private key, that allow decrypting
|
||||
a file encrypted to the corresponding `RECIPIENT`.
|
||||
|
||||
### Native X25519 keys
|
||||
### Native keys
|
||||
|
||||
Native `age` key pairs are generated with age-keygen(1), and provide small
|
||||
encodings and strong encryption based on X25519. They are the recommended
|
||||
recipient type for most applications.
|
||||
encodings and strong encryption based on X25519 for classic keys, and X25519 +
|
||||
ML-KEM-768 for post-quantum hybrid keys. The post-quantum hybrid keys are secure
|
||||
against future quantum computers and are the recommended recipient type for most
|
||||
applications.
|
||||
|
||||
A `RECIPIENT` encoding begins with `age1` and looks like the following:
|
||||
A hybrid `RECIPIENT` encoding begins with `age1pq1` and looks like the following:
|
||||
|
||||
age1pq167[... 1950 more characters ...]
|
||||
|
||||
A hybrid `IDENTITY` encoding begins with `AGE-SECRET-KEY-PQ-1` and looks like
|
||||
the following:
|
||||
|
||||
AGE-SECRET-KEY-PQ-1K30MYPZAHAXHR22YHH27EGDVLU0QNSUH3DSV7J7NR3X6D9LHXNWSDLTV4T
|
||||
|
||||
A classic `RECIPIENT` encoding begins with `age1` and looks like the following:
|
||||
|
||||
age1gde3ncmahlqd9gg50tanl99r960llztrhfapnmx853s4tjum03uqfssgdh
|
||||
|
||||
An `IDENTITY` encoding begins with `AGE-SECRET-KEY-1` and looks like the
|
||||
A classic `IDENTITY` encoding begins with `AGE-SECRET-KEY-1` and looks like the
|
||||
following:
|
||||
|
||||
AGE-SECRET-KEY-1KTYK6RVLN5TAPE7VF6FQQSKZ9HWWCDSKUGXXNUQDWZ7XXT5YK5LSF3UTKQ
|
||||
|
||||
A file can't be encrypted to both post-quantum and classic keys, as that would
|
||||
defeat the post-quantum security of the encryption.
|
||||
|
||||
An encrypted file can't be linked to the native recipient it's encrypted to
|
||||
without access to the corresponding identity.
|
||||
|
||||
@@ -223,6 +237,20 @@ instruct the user to perform encryption with the `-e`/`--encrypt` and
|
||||
doesn't make sense (such as a password-encryption plugin) may instruct the user
|
||||
to use the `-j` flag.
|
||||
|
||||
#### Tagged recipients
|
||||
|
||||
`age` can natively encrypt to recipients starting with `age1tag1` (using P-256
|
||||
ECDH) or `age1tagpq1` (using the ML-KEM-768 + P-256 post-quantum hybrid). These
|
||||
are intended to be the public side of private keys held in hardware.
|
||||
|
||||
They are directly supported to avoid the need to install the plugin, which may
|
||||
be platform-specific, on the encrypting side.
|
||||
|
||||
The tag reduces privacy, by allowing an observer to correlate files with a
|
||||
recipient (but not files amongst them without knowledge of the recipient),
|
||||
but this is also a desirable property for hardware keys that require user
|
||||
interaction for each decryption operation.
|
||||
|
||||
## EXIT STATUS
|
||||
|
||||
`age` will exit 0 if and only if encryption or decryption are successful for the
|
||||
@@ -243,27 +271,26 @@ by default. In this case, a flag will be provided to force the operation.
|
||||
|
||||
## EXAMPLES
|
||||
|
||||
Generate a new identity, encrypt data, and decrypt:
|
||||
Generate a new post-quantum identity, encrypt data, and decrypt:
|
||||
|
||||
$ age-keygen -o key.txt
|
||||
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
|
||||
$ age-keygen -pq -o key.txt
|
||||
Public key: age1pq167[... 1950 more characters ...]
|
||||
|
||||
$ tar cvz ~/data | age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p > data.tar.gz.age
|
||||
$ tar cvz ~/data | age -r age1pq167[...] > data.tar.gz.age
|
||||
|
||||
$ age -d -o data.tar.gz -i key.txt data.tar.gz.age
|
||||
|
||||
Encrypt `example.jpg` to multiple recipients and output to `example.jpg.age`:
|
||||
|
||||
$ age -o example.jpg.age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p \
|
||||
-r age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg example.jpg
|
||||
$ age -o example.jpg.age -r age1pq167[...] -r age1pq1e3[...] example.jpg
|
||||
|
||||
Encrypt to a list of recipients:
|
||||
|
||||
$ cat > recipients.txt
|
||||
# Alice
|
||||
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
|
||||
age1pq167[... 1950 more characters ...]
|
||||
# Bob
|
||||
age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg
|
||||
age1pq1e3[... 1950 more characters ...]
|
||||
|
||||
$ age -R recipients.txt example.jpg > example.jpg.age
|
||||
|
||||
@@ -308,7 +335,7 @@ Encrypt to the SSH keys of a GitHub user:
|
||||
|
||||
## SEE ALSO
|
||||
|
||||
age-keygen(1)
|
||||
age-keygen(1), age-inspect(1)
|
||||
|
||||
## AUTHORS
|
||||
|
||||
|
||||
151
extra/age-plugin-pq/plugin-pq.go
Normal file
151
extra/age-plugin-pq/plugin-pq.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
|
||||
"filippo.io/age"
|
||||
"filippo.io/age/internal/bech32"
|
||||
"filippo.io/age/plugin"
|
||||
)
|
||||
|
||||
const usage = `Usage:
|
||||
age-plugin-pq -identity [-o OUTPUT] [INPUT]
|
||||
|
||||
Options:
|
||||
-identity Convert one or more native post-quantum identities from
|
||||
INPUT or from standard input to plugin identities.
|
||||
-o, --output OUTPUT Write the result to the file at path OUTPUT instead of
|
||||
standard output.
|
||||
|
||||
age-plugin-pq is an age plugin for post-quantum hybrid ML-KEM-768 + X25519
|
||||
recipients and identities. These are supported natively by age v1.3.0 and later,
|
||||
but this plugin can be placed in $PATH to add support to any version and
|
||||
implementation of age that supports plugins.
|
||||
|
||||
Recipients work out of the box, while identities need to be converted to plugin
|
||||
identities with -identity. If OUTPUT already exists, it is not overwritten.`
|
||||
|
||||
// Version can be set at link time to override debug.BuildInfo.Main.Version when
|
||||
// building manually without git history. It should look like "v1.2.3".
|
||||
var Version string
|
||||
|
||||
func main() {
|
||||
log.SetFlags(0)
|
||||
|
||||
p, err := plugin.New("pq")
|
||||
if err != nil {
|
||||
errorf("failed to create plugin: %v", err)
|
||||
}
|
||||
p.RegisterFlags(nil)
|
||||
|
||||
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
|
||||
|
||||
var outFlag string
|
||||
var versionFlag, identityFlag bool
|
||||
flag.BoolVar(&versionFlag, "version", false, "print the version")
|
||||
flag.BoolVar(&identityFlag, "identity", false, "convert identities to plugin identities")
|
||||
flag.StringVar(&outFlag, "o", "", "output to `FILE` (default stdout)")
|
||||
flag.StringVar(&outFlag, "output", "", "output to `FILE` (default stdout)")
|
||||
flag.Parse()
|
||||
|
||||
if versionFlag {
|
||||
if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
|
||||
Version = buildInfo.Main.Version
|
||||
}
|
||||
fmt.Println(Version)
|
||||
return
|
||||
}
|
||||
|
||||
if identityFlag {
|
||||
if len(flag.Args()) > 1 {
|
||||
errorf("too many arguments")
|
||||
}
|
||||
|
||||
out := os.Stdout
|
||||
if outFlag != "" {
|
||||
f, err := os.OpenFile(outFlag, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
||||
if err != nil {
|
||||
errorf("failed to open output file %q: %v", outFlag, err)
|
||||
}
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
errorf("failed to close output file %q: %v", outFlag, err)
|
||||
}
|
||||
}()
|
||||
out = f
|
||||
}
|
||||
if fi, err := out.Stat(); err == nil && fi.Mode().IsRegular() && fi.Mode().Perm()&0004 != 0 {
|
||||
warning("writing secret key to a world-readable file")
|
||||
}
|
||||
|
||||
in := os.Stdin
|
||||
if inFile := flag.Arg(0); inFile != "" && inFile != "-" {
|
||||
f, err := os.Open(inFile)
|
||||
if err != nil {
|
||||
errorf("failed to open input file %q: %v", inFile, err)
|
||||
}
|
||||
defer f.Close()
|
||||
in = f
|
||||
}
|
||||
|
||||
convert(in, out)
|
||||
return
|
||||
}
|
||||
|
||||
p.HandleRecipientEncoding(func(s string) (age.Recipient, error) {
|
||||
return age.ParseHybridRecipient(s)
|
||||
})
|
||||
p.HandleIdentity(func(data []byte) (age.Identity, error) {
|
||||
// Convert from a AGE-PLUGIN-PQ-1... payload to a
|
||||
// AGE-SECRET-KEY-PQ-1... identity encoding.
|
||||
s, err := bech32.Encode("AGE-SECRET-KEY-PQ-", data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return age.ParseHybridIdentity(s)
|
||||
})
|
||||
p.HandleIdentityAsRecipient(func(data []byte) (age.Recipient, error) {
|
||||
s, err := bech32.Encode("AGE-SECRET-KEY-PQ-", data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
i, err := age.ParseHybridIdentity(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return i.Recipient(), nil
|
||||
})
|
||||
os.Exit(p.Main())
|
||||
}
|
||||
|
||||
func convert(in io.Reader, out io.Writer) {
|
||||
ids, err := age.ParseIdentities(in)
|
||||
if err != nil {
|
||||
errorf("failed to parse identities: %v", err)
|
||||
}
|
||||
for i, id := range ids {
|
||||
hybridID, ok := id.(*age.HybridIdentity)
|
||||
if !ok {
|
||||
errorf("identity #%d is not a post-quantum hybrid identity", i+1)
|
||||
}
|
||||
_, data, err := bech32.Decode(hybridID.String())
|
||||
if err != nil {
|
||||
errorf("failed to decode identity #%d: %v", i+1, err)
|
||||
}
|
||||
fmt.Fprintln(out, plugin.EncodeIdentity("pq", data))
|
||||
}
|
||||
}
|
||||
|
||||
func errorf(format string, v ...any) {
|
||||
log.Printf("age-plugin-pq: error: "+format, v...)
|
||||
log.Fatalf("age-plugin-pq: report unexpected or unhelpful errors at https://filippo.io/age/report")
|
||||
}
|
||||
|
||||
func warning(msg string) {
|
||||
log.Printf("age-plugin-pq: warning: %s", msg)
|
||||
}
|
||||
51
extra/age-plugin-tag/plugin-tag.go
Normal file
51
extra/age-plugin-tag/plugin-tag.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
|
||||
"filippo.io/age"
|
||||
"filippo.io/age/plugin"
|
||||
"filippo.io/age/tag"
|
||||
)
|
||||
|
||||
const usage = `age-plugin-tag is an age plugin for P-256 tagged recipients. These are supported
|
||||
natively by age v1.3.0 and later, but this plugin can be placed in $PATH to add
|
||||
support to any version and implementation of age that supports plugins.
|
||||
|
||||
Usually, tagged recipients are the public side of private keys held in hardware,
|
||||
where the identity side is handled by a different plugin.`
|
||||
|
||||
// Version can be set at link time to override debug.BuildInfo.Main.Version when
|
||||
// building manually without git history. It should look like "v1.2.3".
|
||||
var Version string
|
||||
|
||||
func main() {
|
||||
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
|
||||
|
||||
p, err := plugin.New("tag")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
p.RegisterFlags(nil)
|
||||
|
||||
versionFlag := flag.Bool("version", false, "print the version")
|
||||
flag.Parse()
|
||||
|
||||
if *versionFlag {
|
||||
if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
|
||||
Version = buildInfo.Main.Version
|
||||
}
|
||||
fmt.Println(Version)
|
||||
return
|
||||
}
|
||||
|
||||
p.HandleRecipient(func(b []byte) (age.Recipient, error) {
|
||||
return tag.NewClassicRecipient(b)
|
||||
})
|
||||
|
||||
os.Exit(p.Main())
|
||||
}
|
||||
52
extra/age-plugin-tagpq/plugin-tagpq.go
Normal file
52
extra/age-plugin-tagpq/plugin-tagpq.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
|
||||
"filippo.io/age"
|
||||
"filippo.io/age/plugin"
|
||||
"filippo.io/age/tag"
|
||||
)
|
||||
|
||||
const usage = `age-plugin-tagpq is an age plugin for ML-KEM-768 + P-256 post-quantum hybrid
|
||||
tagged recipients. These are supported natively by age v1.3.0 and later, but
|
||||
this plugin can be placed in $PATH to add support to any version and
|
||||
implementation of age that supports plugins.
|
||||
|
||||
Usually, tagged recipients are the public side of private keys held in hardware,
|
||||
where the identity side is handled by a different plugin.`
|
||||
|
||||
// Version can be set at link time to override debug.BuildInfo.Main.Version when
|
||||
// building manually without git history. It should look like "v1.2.3".
|
||||
var Version string
|
||||
|
||||
func main() {
|
||||
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
|
||||
|
||||
p, err := plugin.New("tagpq")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
p.RegisterFlags(nil)
|
||||
|
||||
versionFlag := flag.Bool("version", false, "print the version")
|
||||
flag.Parse()
|
||||
|
||||
if *versionFlag {
|
||||
if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
|
||||
Version = buildInfo.Main.Version
|
||||
}
|
||||
fmt.Println(Version)
|
||||
return
|
||||
}
|
||||
|
||||
p.HandleRecipient(func(b []byte) (age.Recipient, error) {
|
||||
return tag.NewHybridRecipient(b)
|
||||
})
|
||||
|
||||
os.Exit(p.Main())
|
||||
}
|
||||
19
go.mod
19
go.mod
@@ -1,17 +1,22 @@
|
||||
module filippo.io/age
|
||||
|
||||
go 1.19
|
||||
go 1.24.0
|
||||
|
||||
// Release build version.
|
||||
toolchain go1.25.5
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0
|
||||
golang.org/x/crypto v0.24.0
|
||||
golang.org/x/sys v0.21.0
|
||||
golang.org/x/term v0.21.0
|
||||
filippo.io/hpke v0.4.0
|
||||
filippo.io/nistec v0.0.4
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/sys v0.38.0
|
||||
golang.org/x/term v0.37.0
|
||||
)
|
||||
|
||||
// Test dependencies.
|
||||
require (
|
||||
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805
|
||||
github.com/rogpeppe/go-internal v1.12.0
|
||||
golang.org/x/tools v0.22.0 // indirect
|
||||
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd
|
||||
github.com/rogpeppe/go-internal v1.14.1
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
)
|
||||
|
||||
28
go.sum
28
go.sum
@@ -1,14 +1,18 @@
|
||||
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0=
|
||||
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w=
|
||||
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M=
|
||||
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
|
||||
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
|
||||
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
|
||||
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
|
||||
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
|
||||
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
|
||||
filippo.io/nistec v0.0.4 h1:F14ZHT5htWlMnQVPndX9ro9arf56cBhQxq4LnDI491s=
|
||||
filippo.io/nistec v0.0.4/go.mod h1:PK/lw8I1gQT4hUML4QGaqljwdDaFcMyFKSXN7kjrtKI=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
|
||||
@@ -37,7 +37,7 @@ func polymod(values []byte) uint32 {
|
||||
top := chk >> 25
|
||||
chk = (chk & 0x1ffffff) << 5
|
||||
chk = chk ^ uint32(v)
|
||||
for i := 0; i < 5; i++ {
|
||||
for i := range 5 {
|
||||
bit := top >> i & 1
|
||||
if bit == 1 {
|
||||
chk ^= generator[i]
|
||||
|
||||
@@ -77,10 +77,7 @@ func (w *WrappedBase64Encoder) writeWrapped(p []byte) (int, error) {
|
||||
panic("age: internal error: non-empty WrappedBase64Encoder.buf")
|
||||
}
|
||||
for len(p) > 0 {
|
||||
toWrite := ColumnsPerLine - (w.written % ColumnsPerLine)
|
||||
if toWrite > len(p) {
|
||||
toWrite = len(p)
|
||||
}
|
||||
toWrite := min(ColumnsPerLine-(w.written%ColumnsPerLine), len(p))
|
||||
n, _ := w.buf.Write(p[:toWrite])
|
||||
w.written += n
|
||||
p = p[n:]
|
||||
@@ -201,7 +198,7 @@ func (r *StanzaReader) ReadStanza() (s *Stanza, err error) {
|
||||
b, err := DecodeString(strings.TrimSuffix(string(line), "\n"))
|
||||
if err != nil {
|
||||
if bytes.HasPrefix(line, footerPrefix) || bytes.HasPrefix(line, stanzaPrefix) {
|
||||
return nil, fmt.Errorf("malformed body line %q: stanza ended without a short line\nNote: this might be a file encrypted with an old beta version of age or rage. Use age v1.0.0-beta6 or rage to decrypt it.", line)
|
||||
return nil, fmt.Errorf("malformed body line %q: stanza ended without a short line\nnote: this might be a file encrypted with an old beta version of age or rage; use age v1.0.0-beta6 or rage to decrypt it", line)
|
||||
}
|
||||
return nil, errorf("malformed body line %q: %v", line, err)
|
||||
}
|
||||
@@ -228,7 +225,7 @@ func (e *ParseError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
func errorf(format string, a ...interface{}) error {
|
||||
func errorf(format string, a ...any) error {
|
||||
return &ParseError{fmt.Errorf(format, a...)}
|
||||
}
|
||||
|
||||
@@ -239,7 +236,9 @@ func Parse(input io.Reader) (*Header, io.Reader, error) {
|
||||
rr := bufio.NewReader(input)
|
||||
|
||||
line, err := rr.ReadString('\n')
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
return nil, nil, errorf("file is empty")
|
||||
} else if err != nil {
|
||||
return nil, nil, errorf("failed to read intro: %w", err)
|
||||
}
|
||||
if line != intro {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build go1.18
|
||||
// +build go1.18
|
||||
|
||||
package format_test
|
||||
|
||||
|
||||
127
internal/inspect/inspect.go
Normal file
127
internal/inspect/inspect.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"filippo.io/age/armor"
|
||||
"filippo.io/age/internal/format"
|
||||
"filippo.io/age/internal/stream"
|
||||
)
|
||||
|
||||
type Metadata struct {
|
||||
Version string `json:"version"`
|
||||
Postquantum string `json:"postquantum"` // "yes" or "no" or "unknown"
|
||||
Armor bool `json:"armor"`
|
||||
StanzaTypes []string `json:"stanza_types"`
|
||||
Sizes struct {
|
||||
Header int64 `json:"header"`
|
||||
Armor int64 `json:"armor"`
|
||||
Overhead int64 `json:"overhead"`
|
||||
// Currently, we don't do any padding, not MinPayload == MaxPayload and
|
||||
// MinPadding == MaxPadding == 0, but that might change in the future.
|
||||
MinPayload int64 `json:"min_payload"`
|
||||
MaxPayload int64 `json:"max_payload"`
|
||||
MinPadding int64 `json:"min_padding"`
|
||||
MaxPadding int64 `json:"max_padding"`
|
||||
} `json:"sizes"`
|
||||
}
|
||||
|
||||
func Inspect(r io.Reader, fileSize int64) (*Metadata, error) {
|
||||
data := &Metadata{
|
||||
Version: "age-encryption.org/v1",
|
||||
Postquantum: "unknown",
|
||||
}
|
||||
|
||||
tr := &trackReader{r: r}
|
||||
br := bufio.NewReader(tr)
|
||||
const maxWhitespace = 1024
|
||||
start, _ := br.Peek(maxWhitespace + len(armor.Header))
|
||||
if strings.HasPrefix(string(bytes.TrimSpace(start)), armor.Header) {
|
||||
r = armor.NewReader(br)
|
||||
data.Armor = true
|
||||
} else {
|
||||
r = br
|
||||
}
|
||||
|
||||
hdr, rest, err := format.Parse(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read header: %w", err)
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
if err := hdr.Marshal(buf); err != nil {
|
||||
return nil, fmt.Errorf("failed to re-serialize header: %w", err)
|
||||
}
|
||||
data.Sizes.Header = int64(buf.Len())
|
||||
|
||||
for _, s := range hdr.Recipients {
|
||||
data.StanzaTypes = append(data.StanzaTypes, s.Type)
|
||||
switch s.Type {
|
||||
case "X25519", "ssh-rsa", "ssh-ed25519", "age-encryption.org/p256tag", "piv-p256":
|
||||
data.Postquantum = "no"
|
||||
case "mlkem768x25519", "scrypt", "age-encryption.org/mlkem768p256tag":
|
||||
if data.Postquantum != "no" {
|
||||
data.Postquantum = "yes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If fileSize is not provided, or if it's the size of the armored file
|
||||
// (which can have LF or CRLF line endings, varying its size), read to
|
||||
// the end to determine it.
|
||||
if fileSize == -1 || data.Armor {
|
||||
n, err := io.Copy(io.Discard, rest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read rest of file: %w", err)
|
||||
}
|
||||
fileSize = data.Sizes.Header + n
|
||||
if !tr.done {
|
||||
panic("trackReader not done after io.Copy")
|
||||
}
|
||||
if tr.count != fileSize && !data.Armor {
|
||||
panic("trackReader count mismatch")
|
||||
}
|
||||
data.Sizes.Armor = tr.count - fileSize
|
||||
}
|
||||
data.Sizes.Overhead, err = streamOverhead(fileSize - data.Sizes.Header)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compute stream overhead: %w", err)
|
||||
}
|
||||
data.Sizes.MinPayload = fileSize - data.Sizes.Header - data.Sizes.Overhead
|
||||
data.Sizes.MaxPayload = data.Sizes.MinPayload
|
||||
return data, nil
|
||||
}
|
||||
|
||||
type trackReader struct {
|
||||
r io.Reader
|
||||
count int64
|
||||
done bool
|
||||
}
|
||||
|
||||
func (tr *trackReader) Read(p []byte) (int, error) {
|
||||
n, err := tr.r.Read(p)
|
||||
tr.count += int64(n)
|
||||
if err == io.EOF {
|
||||
tr.done = true
|
||||
} else if tr.done {
|
||||
panic("non-EOF read after EOF")
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func streamOverhead(payloadSize int64) (int64, error) {
|
||||
const streamNonceSize = 16
|
||||
if payloadSize < streamNonceSize {
|
||||
return 0, fmt.Errorf("encrypted size too small: %d", payloadSize)
|
||||
}
|
||||
encryptedSize := payloadSize - streamNonceSize
|
||||
plaintextSize, err := stream.PlaintextSize(encryptedSize)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return payloadSize - plaintextSize, nil
|
||||
}
|
||||
46
internal/inspect/inspect_test.go
Normal file
46
internal/inspect/inspect_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age/internal/stream"
|
||||
)
|
||||
|
||||
func TestStreamOverhead(t *testing.T) {
|
||||
tests := []struct {
|
||||
payloadSize int64
|
||||
want int64
|
||||
wantErr bool
|
||||
}{
|
||||
{payloadSize: 0, wantErr: true},
|
||||
{payloadSize: 15, wantErr: true},
|
||||
{payloadSize: 16, wantErr: true},
|
||||
{payloadSize: 16 + 15, wantErr: true},
|
||||
{payloadSize: 16 + 16, want: 16 + 16}, // empty plaintext
|
||||
{payloadSize: 16 + 1 + 16, want: 16 + 16},
|
||||
{payloadSize: 16 + stream.ChunkSize + 16, want: 16 + 16},
|
||||
{payloadSize: 16 + stream.ChunkSize + 16 + 1, wantErr: true},
|
||||
{payloadSize: 16 + stream.ChunkSize + 16 + 15, wantErr: true},
|
||||
{payloadSize: 16 + stream.ChunkSize + 16 + 16, wantErr: true}, // empty final chunk
|
||||
{payloadSize: 16 + stream.ChunkSize + 16 + 1 + 16, want: 16 + 16 + 16},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
name := "payloadSize=" + fmt.Sprint(tt.payloadSize)
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got, gotErr := streamOverhead(tt.payloadSize)
|
||||
if gotErr != nil {
|
||||
if !tt.wantErr {
|
||||
t.Errorf("streamOverhead() failed: %v", gotErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if tt.wantErr {
|
||||
t.Fatal("streamOverhead() succeeded unexpectedly")
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("streamOverhead() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,18 +6,45 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/cipher"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync/atomic"
|
||||
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
"golang.org/x/crypto/poly1305"
|
||||
)
|
||||
|
||||
const ChunkSize = 64 * 1024
|
||||
|
||||
type Reader struct {
|
||||
func EncryptedChunkCount(encryptedSize int64) (int64, error) {
|
||||
chunks := (encryptedSize + encChunkSize - 1) / encChunkSize
|
||||
|
||||
plaintextSize := encryptedSize - chunks*chacha20poly1305.Overhead
|
||||
expChunks := (plaintextSize + ChunkSize - 1) / ChunkSize
|
||||
// Empty plaintext, the only case that allows (and requires) an empty chunk.
|
||||
if plaintextSize == 0 {
|
||||
expChunks = 1
|
||||
}
|
||||
if expChunks != chunks {
|
||||
return 0, fmt.Errorf("invalid encrypted payload size: %d", encryptedSize)
|
||||
}
|
||||
|
||||
return chunks, nil
|
||||
}
|
||||
|
||||
func PlaintextSize(encryptedSize int64) (int64, error) {
|
||||
chunks, err := EncryptedChunkCount(encryptedSize)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
plaintextSize := encryptedSize - chunks*chacha20poly1305.Overhead
|
||||
return plaintextSize, nil
|
||||
}
|
||||
|
||||
type DecryptReader struct {
|
||||
a cipher.AEAD
|
||||
src io.Reader
|
||||
|
||||
@@ -29,22 +56,19 @@ type Reader struct {
|
||||
}
|
||||
|
||||
const (
|
||||
encChunkSize = ChunkSize + poly1305.TagSize
|
||||
encChunkSize = ChunkSize + chacha20poly1305.Overhead
|
||||
lastChunkFlag = 0x01
|
||||
)
|
||||
|
||||
func NewReader(key []byte, src io.Reader) (*Reader, error) {
|
||||
func NewDecryptReader(key []byte, src io.Reader) (*DecryptReader, error) {
|
||||
aead, err := chacha20poly1305.New(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Reader{
|
||||
a: aead,
|
||||
src: src,
|
||||
}, nil
|
||||
return &DecryptReader{a: aead, src: src}, nil
|
||||
}
|
||||
|
||||
func (r *Reader) Read(p []byte) (int, error) {
|
||||
func (r *DecryptReader) Read(p []byte) (int, error) {
|
||||
if len(r.unread) > 0 {
|
||||
n := copy(p, r.unread)
|
||||
r.unread = r.unread[n:]
|
||||
@@ -86,7 +110,7 @@ func (r *Reader) Read(p []byte) (int, error) {
|
||||
// readChunk reads the next chunk of ciphertext from r.src and makes it available
|
||||
// in r.unread. last is true if the chunk was marked as the end of the message.
|
||||
// readChunk must not be called again after returning a last chunk or an error.
|
||||
func (r *Reader) readChunk() (last bool, err error) {
|
||||
func (r *DecryptReader) readChunk() (last bool, err error) {
|
||||
if len(r.unread) != 0 {
|
||||
panic("stream: internal error: readChunk called with dirty buffer")
|
||||
}
|
||||
@@ -119,7 +143,7 @@ func (r *Reader) readChunk() (last bool, err error) {
|
||||
out, err = r.a.Open(outBuf, r.nonce[:], in, nil)
|
||||
}
|
||||
if err != nil {
|
||||
return false, errors.New("failed to decrypt and authenticate payload chunk")
|
||||
return false, errors.New("failed to decrypt and authenticate payload chunk, file may be corrupted or tampered with")
|
||||
}
|
||||
|
||||
incNonce(&r.nonce)
|
||||
@@ -131,12 +155,17 @@ func incNonce(nonce *[chacha20poly1305.NonceSize]byte) {
|
||||
for i := len(nonce) - 2; i >= 0; i-- {
|
||||
nonce[i]++
|
||||
if nonce[i] != 0 {
|
||||
break
|
||||
} else if i == 0 {
|
||||
// The counter is 88 bits, this is unreachable.
|
||||
panic("stream: chunk counter wrapped around")
|
||||
return
|
||||
}
|
||||
}
|
||||
// The counter is 88 bits, this is unreachable.
|
||||
panic("stream: chunk counter wrapped around")
|
||||
}
|
||||
|
||||
func nonceForChunk(chunkIndex int64) *[chacha20poly1305.NonceSize]byte {
|
||||
var nonce [chacha20poly1305.NonceSize]byte
|
||||
binary.BigEndian.PutUint64(nonce[3:11], uint64(chunkIndex))
|
||||
return &nonce
|
||||
}
|
||||
|
||||
func setLastChunkFlag(nonce *[chacha20poly1305.NonceSize]byte) {
|
||||
@@ -147,30 +176,23 @@ func nonceIsZero(nonce *[chacha20poly1305.NonceSize]byte) bool {
|
||||
return *nonce == [chacha20poly1305.NonceSize]byte{}
|
||||
}
|
||||
|
||||
type Writer struct {
|
||||
a cipher.AEAD
|
||||
dst io.Writer
|
||||
unwritten []byte // backed by buf
|
||||
buf [encChunkSize]byte
|
||||
nonce [chacha20poly1305.NonceSize]byte
|
||||
err error
|
||||
type EncryptWriter struct {
|
||||
a cipher.AEAD
|
||||
dst io.Writer
|
||||
buf bytes.Buffer
|
||||
nonce [chacha20poly1305.NonceSize]byte
|
||||
err error
|
||||
}
|
||||
|
||||
func NewWriter(key []byte, dst io.Writer) (*Writer, error) {
|
||||
func NewEncryptWriter(key []byte, dst io.Writer) (*EncryptWriter, error) {
|
||||
aead, err := chacha20poly1305.New(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w := &Writer{
|
||||
a: aead,
|
||||
dst: dst,
|
||||
}
|
||||
w.unwritten = w.buf[:0]
|
||||
return w, nil
|
||||
return &EncryptWriter{a: aead, dst: dst}, nil
|
||||
}
|
||||
|
||||
func (w *Writer) Write(p []byte) (n int, err error) {
|
||||
// TODO: consider refactoring with a bytes.Buffer.
|
||||
func (w *EncryptWriter) Write(p []byte) (n int, err error) {
|
||||
if w.err != nil {
|
||||
return 0, w.err
|
||||
}
|
||||
@@ -180,12 +202,13 @@ func (w *Writer) Write(p []byte) (n int, err error) {
|
||||
|
||||
total := len(p)
|
||||
for len(p) > 0 {
|
||||
freeBuf := w.buf[len(w.unwritten):ChunkSize]
|
||||
n := copy(freeBuf, p)
|
||||
n := min(len(p), ChunkSize-w.buf.Len())
|
||||
w.buf.Write(p[:n])
|
||||
p = p[n:]
|
||||
w.unwritten = w.unwritten[:len(w.unwritten)+n]
|
||||
|
||||
if len(w.unwritten) == ChunkSize && len(p) > 0 {
|
||||
// Only flush if there's a full chunk with bytes still to write, or we
|
||||
// can't know if this is the last chunk yet.
|
||||
if w.buf.Len() == ChunkSize && len(p) > 0 {
|
||||
if err := w.flushChunk(notLastChunk); err != nil {
|
||||
w.err = err
|
||||
return 0, err
|
||||
@@ -196,7 +219,7 @@ func (w *Writer) Write(p []byte) (n int, err error) {
|
||||
}
|
||||
|
||||
// Close flushes the last chunk. It does not close the underlying Writer.
|
||||
func (w *Writer) Close() error {
|
||||
func (w *EncryptWriter) Close() error {
|
||||
if w.err != nil {
|
||||
return w.err
|
||||
}
|
||||
@@ -215,17 +238,214 @@ const (
|
||||
notLastChunk = false
|
||||
)
|
||||
|
||||
func (w *Writer) flushChunk(last bool) error {
|
||||
if !last && len(w.unwritten) != ChunkSize {
|
||||
func (w *EncryptWriter) flushChunk(last bool) error {
|
||||
if !last && w.buf.Len() != ChunkSize {
|
||||
panic("stream: internal error: flush called with partial chunk")
|
||||
}
|
||||
|
||||
if last {
|
||||
setLastChunkFlag(&w.nonce)
|
||||
}
|
||||
buf := w.a.Seal(w.buf[:0], w.nonce[:], w.unwritten, nil)
|
||||
_, err := w.dst.Write(buf)
|
||||
w.unwritten = w.buf[:0]
|
||||
w.buf.Grow(chacha20poly1305.Overhead)
|
||||
ciphertext := w.a.Seal(w.buf.Bytes()[:0], w.nonce[:], w.buf.Bytes(), nil)
|
||||
_, err := w.dst.Write(ciphertext)
|
||||
incNonce(&w.nonce)
|
||||
w.buf.Reset()
|
||||
return err
|
||||
}
|
||||
|
||||
type EncryptReader struct {
|
||||
a cipher.AEAD
|
||||
src io.Reader
|
||||
|
||||
// The first ready bytes of buf are already encrypted. This may be less than
|
||||
// buf.Len(), because we need to over-read to know if a chunk is the last.
|
||||
ready int
|
||||
buf bytes.Buffer
|
||||
|
||||
nonce [chacha20poly1305.NonceSize]byte
|
||||
err error
|
||||
}
|
||||
|
||||
func NewEncryptReader(key []byte, src io.Reader) (*EncryptReader, error) {
|
||||
aead, err := chacha20poly1305.New(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &EncryptReader{a: aead, src: src}, nil
|
||||
}
|
||||
|
||||
func (r *EncryptReader) Read(p []byte) (int, error) {
|
||||
if r.ready > 0 {
|
||||
n, err := r.buf.Read(p[:min(len(p), r.ready)])
|
||||
r.ready -= n
|
||||
return n, err
|
||||
}
|
||||
if r.err != nil {
|
||||
return 0, r.err
|
||||
}
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
if err := r.feedBuffer(); err != nil {
|
||||
r.err = err
|
||||
return 0, err
|
||||
}
|
||||
|
||||
n, err := r.buf.Read(p[:min(len(p), r.ready)])
|
||||
r.ready -= n
|
||||
return n, err
|
||||
}
|
||||
|
||||
// feedBuffer reads and encrypts the next chunk from r.src and appends it to
|
||||
// r.buf. It sets r.ready to the number of newly available bytes in r.buf.
|
||||
func (r *EncryptReader) feedBuffer() error {
|
||||
if r.ready > 0 {
|
||||
panic("stream: internal error: feedBuffer called with dirty buffer")
|
||||
}
|
||||
|
||||
// CopyN will use r.buf.ReadFrom/WriteTo to fill the buffer directly.
|
||||
// We need ChunkSize + 1 bytes to determine if this is the last chunk.
|
||||
_, err := io.CopyN(&r.buf, r.src, int64(ChunkSize-r.buf.Len()+1))
|
||||
if err != nil && err != io.EOF {
|
||||
return err
|
||||
}
|
||||
|
||||
if last := r.buf.Len() <= ChunkSize; last {
|
||||
setLastChunkFlag(&r.nonce)
|
||||
|
||||
// After Grow, we know r.buf.Bytes() has enough capacity for the
|
||||
// overhead. We encrypt in place and then do a Write to include the
|
||||
// overhead in the buffer.
|
||||
r.buf.Grow(chacha20poly1305.Overhead)
|
||||
plaintext := r.buf.Bytes()
|
||||
r.a.Seal(plaintext[:0], r.nonce[:], plaintext, nil)
|
||||
incNonce(&r.nonce)
|
||||
r.buf.Write(plaintext[len(plaintext) : len(plaintext)+chacha20poly1305.Overhead])
|
||||
r.ready = r.buf.Len()
|
||||
|
||||
r.err = io.EOF
|
||||
return nil
|
||||
}
|
||||
|
||||
// Same, but accounting for the tail byte which will remain unencrypted and
|
||||
// needs to be shifted past the overhead.
|
||||
if r.buf.Len() != ChunkSize+1 {
|
||||
panic("stream: internal error: unexpected buffer length")
|
||||
}
|
||||
tailByte := r.buf.Bytes()[ChunkSize]
|
||||
r.buf.Grow(chacha20poly1305.Overhead)
|
||||
plaintext := r.buf.Bytes()[:ChunkSize]
|
||||
r.a.Seal(plaintext[:0], r.nonce[:], plaintext, nil)
|
||||
incNonce(&r.nonce)
|
||||
r.buf.Write(plaintext[len(plaintext)+1 : len(plaintext)+chacha20poly1305.Overhead])
|
||||
r.buf.WriteByte(tailByte)
|
||||
r.ready = ChunkSize + chacha20poly1305.Overhead
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type DecryptReaderAt struct {
|
||||
a cipher.AEAD
|
||||
src io.ReaderAt
|
||||
size int64
|
||||
chunks int64
|
||||
cache atomic.Pointer[cachedChunk]
|
||||
}
|
||||
|
||||
type cachedChunk struct {
|
||||
off int64
|
||||
data []byte
|
||||
}
|
||||
|
||||
func NewDecryptReaderAt(key []byte, src io.ReaderAt, size int64) (*DecryptReaderAt, error) {
|
||||
aead, err := chacha20poly1305.New(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check that size is valid by decrypting the final chunk.
|
||||
chunks, err := EncryptedChunkCount(size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
finalChunkIndex := chunks - 1
|
||||
finalChunkOff := finalChunkIndex * encChunkSize
|
||||
finalChunkSize := size - finalChunkOff
|
||||
finalChunk := make([]byte, finalChunkSize)
|
||||
if _, err := src.ReadAt(finalChunk, finalChunkOff); err != nil {
|
||||
return nil, fmt.Errorf("failed to read final chunk: %w", err)
|
||||
}
|
||||
nonce := nonceForChunk(finalChunkIndex)
|
||||
setLastChunkFlag(nonce)
|
||||
plaintext, err := aead.Open(finalChunk[:0], nonce[:], finalChunk, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt and authenticate final chunk: %w", err)
|
||||
}
|
||||
cache := &cachedChunk{off: finalChunkOff, data: plaintext}
|
||||
|
||||
plaintextSize := size - chunks*chacha20poly1305.Overhead
|
||||
r := &DecryptReaderAt{a: aead, src: src, size: plaintextSize, chunks: chunks}
|
||||
r.cache.Store(cache)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *DecryptReaderAt) ReadAt(p []byte, off int64) (n int, err error) {
|
||||
if off < 0 || off > r.size {
|
||||
return 0, fmt.Errorf("offset out of range [0:%d]: %d", r.size, off)
|
||||
}
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var cacheUpdate *cachedChunk
|
||||
chunk := make([]byte, encChunkSize)
|
||||
for len(p) > 0 && off < r.size {
|
||||
chunkIndex := off / ChunkSize
|
||||
chunkOff := chunkIndex * encChunkSize
|
||||
encSize := r.size + r.chunks*chacha20poly1305.Overhead
|
||||
chunkSize := min(encSize-chunkOff, encChunkSize)
|
||||
|
||||
cached := r.cache.Load()
|
||||
var plaintext []byte
|
||||
if cached != nil && cached.off == chunkOff {
|
||||
plaintext = cached.data
|
||||
cacheUpdate = nil
|
||||
} else {
|
||||
nn, err := r.src.ReadAt(chunk[:chunkSize], chunkOff)
|
||||
if err == io.EOF {
|
||||
if int64(nn) != chunkSize {
|
||||
err = io.ErrUnexpectedEOF
|
||||
} else {
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return n, fmt.Errorf("failed to read chunk at offset %d: %w", chunkOff, err)
|
||||
}
|
||||
nonce := nonceForChunk(chunkIndex)
|
||||
if chunkIndex == r.chunks-1 {
|
||||
setLastChunkFlag(nonce)
|
||||
}
|
||||
plaintext, err = r.a.Open(chunk[:0], nonce[:], chunk[:chunkSize], nil)
|
||||
if err != nil {
|
||||
return n, fmt.Errorf("failed to decrypt and authenticate chunk at offset %d: %w", chunkOff, err)
|
||||
}
|
||||
cacheUpdate = &cachedChunk{off: chunkOff, data: plaintext}
|
||||
}
|
||||
|
||||
plainChunkOff := int(off - chunkIndex*ChunkSize)
|
||||
copySize := min(len(plaintext)-plainChunkOff, len(p))
|
||||
copy(p, plaintext[plainChunkOff:plainChunkOff+copySize])
|
||||
p = p[copySize:]
|
||||
off += int64(copySize)
|
||||
n += copySize
|
||||
}
|
||||
if cacheUpdate != nil {
|
||||
r.cache.Store(cacheUpdate)
|
||||
}
|
||||
if off == r.size {
|
||||
return n, io.EOF
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
"testing/iotest"
|
||||
|
||||
"filippo.io/age/internal/stream"
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
@@ -17,12 +19,18 @@ import (
|
||||
const cs = stream.ChunkSize
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
for _, stepSize := range []int{512, 600, 1000, cs} {
|
||||
for _, length := range []int{0, 1000, cs, cs + 100} {
|
||||
t.Run(fmt.Sprintf("len=%d,step=%d", length, stepSize),
|
||||
func(t *testing.T) { testRoundTrip(t, stepSize, length) })
|
||||
for _, length := range []int{0, 1000, cs - 1, cs, cs + 1, cs + 100, 2 * cs, 2*cs + 500} {
|
||||
for _, stepSize := range []int{512, 600, 1000, cs - 1, cs, cs + 1} {
|
||||
t.Run(fmt.Sprintf("len=%d,step=%d", length, stepSize), func(t *testing.T) {
|
||||
testRoundTrip(t, stepSize, length)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
length, stepSize := 2*cs+500, 1
|
||||
t.Run(fmt.Sprintf("len=%d,step=%d", length, stepSize), func(t *testing.T) {
|
||||
testRoundTrip(t, stepSize, length)
|
||||
})
|
||||
}
|
||||
|
||||
func testRoundTrip(t *testing.T, stepSize, length int) {
|
||||
@@ -30,64 +38,915 @@ func testRoundTrip(t *testing.T, stepSize, length int) {
|
||||
if _, err := rand.Read(src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ciphertext []byte
|
||||
|
||||
t.Run("EncryptWriter", func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
w, err := stream.NewEncryptWriter(key, buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var n int
|
||||
for n < length {
|
||||
b := min(length-n, stepSize)
|
||||
nn, err := w.Write(src[n : n+b])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nn != b {
|
||||
t.Errorf("Write returned %d, expected %d", nn, b)
|
||||
}
|
||||
n += nn
|
||||
|
||||
nn, err = w.Write(src[n:n])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nn != 0 {
|
||||
t.Errorf("Write returned %d, expected 0", nn)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Error("Close returned an error:", err)
|
||||
}
|
||||
|
||||
ciphertext = buf.Bytes()
|
||||
})
|
||||
|
||||
t.Run("DecryptReader", func(t *testing.T) {
|
||||
r, err := stream.NewDecryptReader(key, bytes.NewReader(ciphertext))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var n int
|
||||
readBuf := make([]byte, stepSize)
|
||||
for n < length {
|
||||
nn, err := r.Read(readBuf)
|
||||
if err != nil {
|
||||
t.Fatalf("Read error at index %d: %v", n, err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(readBuf[:nn], src[n:n+nn]) {
|
||||
t.Errorf("wrong data at indexes %d - %d", n, n+nn)
|
||||
}
|
||||
|
||||
n += nn
|
||||
}
|
||||
|
||||
t.Run("TestReader", func(t *testing.T) {
|
||||
if length > 1000 && testing.Short() {
|
||||
t.Skip("skipping slow iotest.TestReader on long input")
|
||||
}
|
||||
r, _ := stream.NewDecryptReader(key, bytes.NewReader(ciphertext))
|
||||
if err := iotest.TestReader(r, src); err != nil {
|
||||
t.Error("iotest.TestReader error on DecryptReader:", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("DecryptReaderAt", func(t *testing.T) {
|
||||
rAt, err := stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), int64(len(ciphertext)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr := io.NewSectionReader(rAt, 0, int64(len(ciphertext)))
|
||||
|
||||
var n int
|
||||
readBuf := make([]byte, stepSize)
|
||||
for n < length {
|
||||
nn, err := rr.Read(readBuf)
|
||||
if n+nn == length && err == io.EOF {
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAt error at index %d: %v", n, err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(readBuf[:nn], src[n:n+nn]) {
|
||||
t.Errorf("wrong data at indexes %d - %d", n, n+nn)
|
||||
}
|
||||
|
||||
n += nn
|
||||
}
|
||||
|
||||
t.Run("TestReader", func(t *testing.T) {
|
||||
if length > 1000 && testing.Short() {
|
||||
t.Skip("skipping slow iotest.TestReader on long input")
|
||||
}
|
||||
rr := io.NewSectionReader(rAt, 0, int64(len(src)))
|
||||
if err := iotest.TestReader(rr, src); err != nil {
|
||||
t.Error("iotest.TestReader error on DecryptReaderAt:", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("EncryptReader", func(t *testing.T) {
|
||||
er, err := stream.NewEncryptReader(key, bytes.NewReader(src))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var n int
|
||||
readBuf := make([]byte, stepSize)
|
||||
for {
|
||||
nn, err := er.Read(readBuf)
|
||||
if nn == 0 && err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
t.Fatalf("EncryptReader Read error at index %d: %v", n, err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(readBuf[:nn], ciphertext[n:n+nn]) {
|
||||
t.Errorf("EncryptReader wrong data at indexes %d - %d", n, n+nn)
|
||||
}
|
||||
|
||||
n += nn
|
||||
}
|
||||
if n != len(ciphertext) {
|
||||
t.Errorf("EncryptReader read %d bytes, expected %d", n, len(ciphertext))
|
||||
}
|
||||
|
||||
t.Run("TestReader", func(t *testing.T) {
|
||||
if length > 1000 && testing.Short() {
|
||||
t.Skip("skipping slow iotest.TestReader on long input")
|
||||
}
|
||||
er, _ := stream.NewEncryptReader(key, bytes.NewReader(src))
|
||||
if err := iotest.TestReader(er, ciphertext); err != nil {
|
||||
t.Error("iotest.TestReader error on EncryptReader:", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// trackingReaderAt wraps an io.ReaderAt and tracks whether ReadAt was called.
|
||||
type trackingReaderAt struct {
|
||||
r io.ReaderAt
|
||||
called bool
|
||||
}
|
||||
|
||||
func (t *trackingReaderAt) ReadAt(p []byte, off int64) (int, error) {
|
||||
t.called = true
|
||||
return t.r.ReadAt(p, off)
|
||||
}
|
||||
|
||||
func (t *trackingReaderAt) reset() {
|
||||
t.called = false
|
||||
}
|
||||
|
||||
func TestDecryptReaderAt(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w, err := stream.NewWriter(key, buf)
|
||||
if err != nil {
|
||||
// Create plaintext spanning exactly 3 chunks: 2 full chunks + partial third
|
||||
// Chunk 0: [0, cs)
|
||||
// Chunk 1: [cs, 2*cs)
|
||||
// Chunk 2: [2*cs, 2*cs+500)
|
||||
plaintextSize := 2*cs + 500
|
||||
plaintext := make([]byte, plaintextSize)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var n int
|
||||
for n < length {
|
||||
b := length - n
|
||||
if b > stepSize {
|
||||
b = stepSize
|
||||
}
|
||||
nn, err := w.Write(src[n : n+b])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nn != b {
|
||||
t.Errorf("Write returned %d, expected %d", nn, b)
|
||||
}
|
||||
n += nn
|
||||
|
||||
nn, err = w.Write(src[n:n])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nn != 0 {
|
||||
t.Errorf("Write returned %d, expected 0", nn)
|
||||
}
|
||||
// Encrypt
|
||||
buf := &bytes.Buffer{}
|
||||
w, err := stream.NewEncryptWriter(key, buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
t.Error("Close returned an error:", err)
|
||||
t.Fatal(err)
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
|
||||
t.Logf("buffer size: %d", buf.Len())
|
||||
// Create tracking ReaderAt
|
||||
tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)}
|
||||
|
||||
r, err := stream.NewReader(key, buf)
|
||||
// Create DecryptReaderAt (this reads and caches the final chunk)
|
||||
ra, err := stream.NewDecryptReaderAt(key, tracker, int64(len(ciphertext)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tracker.reset()
|
||||
|
||||
n = 0
|
||||
readBuf := make([]byte, stepSize)
|
||||
for n < length {
|
||||
nn, err := r.Read(readBuf)
|
||||
if err != nil {
|
||||
t.Fatalf("Read error at index %d: %v", n, err)
|
||||
// Helper to check reads
|
||||
checkRead := func(name string, off int64, size int, wantN int, wantEOF bool, wantSrcRead bool) {
|
||||
t.Helper()
|
||||
tracker.reset()
|
||||
p := make([]byte, size)
|
||||
n, err := ra.ReadAt(p, off)
|
||||
|
||||
if wantEOF {
|
||||
if err != io.EOF {
|
||||
t.Errorf("%s: got err=%v, want EOF", name, err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("%s: got err=%v, want nil", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !bytes.Equal(readBuf[:nn], src[n:n+nn]) {
|
||||
t.Errorf("wrong data at indexes %d - %d", n, n+nn)
|
||||
if n != wantN {
|
||||
t.Errorf("%s: got n=%d, want %d", name, n, wantN)
|
||||
}
|
||||
|
||||
n += nn
|
||||
if tracker.called != wantSrcRead {
|
||||
t.Errorf("%s: src.ReadAt called=%v, want %v", name, tracker.called, wantSrcRead)
|
||||
}
|
||||
|
||||
// Verify data correctness
|
||||
if n > 0 && off >= 0 && off < int64(plaintextSize) {
|
||||
end := int(off) + n
|
||||
if end > plaintextSize {
|
||||
end = plaintextSize
|
||||
}
|
||||
if !bytes.Equal(p[:n], plaintext[off:end]) {
|
||||
t.Errorf("%s: data mismatch", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test 1: Read from final chunk (cached by constructor)
|
||||
checkRead("final chunk (cached)", int64(2*cs+100), 100, 100, false, false)
|
||||
|
||||
// Test 2: Read spanning second and third chunk
|
||||
checkRead("span chunks 1-2", int64(cs+cs-50), 100, 100, false, true)
|
||||
|
||||
// Test 3: Read from final chunk again (cached from test 2)
|
||||
// When reading across chunks 1-2 in test 2, the loop processes chunk 1 then chunk 2,
|
||||
// so chunk 2 ends up in the cache.
|
||||
checkRead("final chunk after span", int64(2*cs+200), 100, 100, false, false)
|
||||
|
||||
// Test 4: Read from final chunk again (now cached)
|
||||
checkRead("final chunk (cached again)", int64(2*cs+50), 50, 50, false, false)
|
||||
|
||||
// Test 5: Read from first chunk (not cached)
|
||||
checkRead("first chunk", 0, 100, 100, false, true)
|
||||
|
||||
// Test 6: Read from first chunk again (now cached)
|
||||
checkRead("first chunk (cached)", 50, 100, 100, false, false)
|
||||
|
||||
// Test 7: Read spanning all chunks
|
||||
tracker.reset()
|
||||
p := make([]byte, plaintextSize)
|
||||
n, err := ra.ReadAt(p, 0)
|
||||
if err != io.EOF {
|
||||
t.Errorf("span all: got err=%v, want EOF", err)
|
||||
}
|
||||
if n != plaintextSize {
|
||||
t.Errorf("span all: got n=%d, want %d", n, plaintextSize)
|
||||
}
|
||||
if !bytes.Equal(p, plaintext) {
|
||||
t.Errorf("span all: data mismatch")
|
||||
}
|
||||
|
||||
// Test 8: Read beyond the end (offset > size)
|
||||
tracker.reset()
|
||||
p = make([]byte, 100)
|
||||
n, err = ra.ReadAt(p, int64(plaintextSize+100))
|
||||
if err == nil {
|
||||
t.Error("beyond end: expected error, got nil")
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("beyond end: got n=%d, want 0", n)
|
||||
}
|
||||
|
||||
// Test 9: Read with off = size (should return 0, EOF)
|
||||
tracker.reset()
|
||||
p = make([]byte, 100)
|
||||
n, err = ra.ReadAt(p, int64(plaintextSize))
|
||||
if err != io.EOF {
|
||||
t.Errorf("off=size: got err=%v, want EOF", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("off=size: got n=%d, want 0", n)
|
||||
}
|
||||
|
||||
// Test 10: Read spanning last chunk and beyond
|
||||
tracker.reset()
|
||||
p = make([]byte, 1000) // request more than available
|
||||
n, err = ra.ReadAt(p, int64(2*cs+400))
|
||||
if err != io.EOF {
|
||||
t.Errorf("span last+beyond: got err=%v, want EOF", err)
|
||||
}
|
||||
wantN := 500 - 400 // only 100 bytes available from offset 2*cs+400
|
||||
if n != wantN {
|
||||
t.Errorf("span last+beyond: got n=%d, want %d", n, wantN)
|
||||
}
|
||||
if !bytes.Equal(p[:n], plaintext[2*cs+400:]) {
|
||||
t.Error("span last+beyond: data mismatch")
|
||||
}
|
||||
|
||||
// Test 11: Read spanning second+last chunk and beyond
|
||||
tracker.reset()
|
||||
p = make([]byte, cs+1000) // request more than available
|
||||
n, err = ra.ReadAt(p, int64(cs+100))
|
||||
if err != io.EOF {
|
||||
t.Errorf("span 1-2+beyond: got err=%v, want EOF", err)
|
||||
}
|
||||
wantN = plaintextSize - (cs + 100)
|
||||
if n != wantN {
|
||||
t.Errorf("span 1-2+beyond: got n=%d, want %d", n, wantN)
|
||||
}
|
||||
if !bytes.Equal(p[:n], plaintext[cs+100:]) {
|
||||
t.Error("span 1-2+beyond: data mismatch")
|
||||
}
|
||||
|
||||
// Test 12: Negative offset
|
||||
tracker.reset()
|
||||
p = make([]byte, 100)
|
||||
n, err = ra.ReadAt(p, -1)
|
||||
if err == nil {
|
||||
t.Error("negative offset: expected error, got nil")
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("negative offset: got n=%d, want 0", n)
|
||||
}
|
||||
|
||||
// Test 13: Zero-length read in the middle
|
||||
tracker.reset()
|
||||
p = make([]byte, 0)
|
||||
n, err = ra.ReadAt(p, 100)
|
||||
if err != nil {
|
||||
t.Errorf("zero-length middle: got err=%v, want nil", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("zero-length middle: got n=%d, want 0", n)
|
||||
}
|
||||
|
||||
// Test 14: Zero-length read at end
|
||||
tracker.reset()
|
||||
p = make([]byte, 0)
|
||||
n, err = ra.ReadAt(p, int64(plaintextSize))
|
||||
if err != nil {
|
||||
t.Errorf("zero-length end: got err=%v, want nil", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("zero-length end: got n=%d, want 0", n)
|
||||
}
|
||||
|
||||
// Test 15: Read exactly one chunk at chunk boundary
|
||||
checkRead("exact chunk at boundary", int64(cs), cs, cs, false, true)
|
||||
|
||||
// Test 16: Read one byte at each chunk boundary
|
||||
checkRead("one byte at start", 0, 1, 1, false, true)
|
||||
checkRead("one byte at cs-1", int64(cs-1), 1, 1, false, false) // cached from test 15
|
||||
checkRead("one byte at cs", int64(cs), 1, 1, false, true)
|
||||
checkRead("one byte at 2*cs-1", int64(2*cs-1), 1, 1, false, false) // same chunk
|
||||
checkRead("one byte at 2*cs", int64(2*cs), 1, 1, false, true)
|
||||
checkRead("last byte", int64(plaintextSize-1), 1, 1, true, false) // same chunk, EOF because we reach end
|
||||
|
||||
// Test 17: Read crossing exactly one chunk boundary
|
||||
checkRead("cross boundary 0-1", int64(cs-50), 100, 100, false, true)
|
||||
checkRead("cross boundary 1-2", int64(2*cs-50), 100, 100, false, true)
|
||||
}
|
||||
|
||||
func TestDecryptReaderAtEmpty(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create empty encrypted file
|
||||
buf := &bytes.Buffer{}
|
||||
w, err := stream.NewEncryptWriter(key, buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
|
||||
tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)}
|
||||
ra, err := stream.NewDecryptReaderAt(key, tracker, int64(len(ciphertext)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tracker.reset()
|
||||
|
||||
// Test 1: Read from empty file at offset 0
|
||||
p := make([]byte, 100)
|
||||
n, err := ra.ReadAt(p, 0)
|
||||
if err != io.EOF {
|
||||
t.Errorf("empty read: got err=%v, want EOF", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("empty read: got n=%d, want 0", n)
|
||||
}
|
||||
|
||||
// Test 2: Zero-length read from empty file
|
||||
p = make([]byte, 0)
|
||||
n, err = ra.ReadAt(p, 0)
|
||||
if err != nil {
|
||||
t.Errorf("empty zero-length: got err=%v, want nil", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("empty zero-length: got n=%d, want 0", n)
|
||||
}
|
||||
|
||||
// Test 3: Read beyond empty file
|
||||
p = make([]byte, 100)
|
||||
n, err = ra.ReadAt(p, 1)
|
||||
if err == nil {
|
||||
t.Error("empty beyond: expected error, got nil")
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("empty beyond: got n=%d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptReaderAtSingleChunk(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Single chunk, not full
|
||||
plaintext := make([]byte, 1000)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
w, err := stream.NewEncryptWriter(key, buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
|
||||
tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)}
|
||||
ra, err := stream.NewDecryptReaderAt(key, tracker, int64(len(ciphertext)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tracker.reset()
|
||||
|
||||
// All reads should use cache (final chunk = only chunk)
|
||||
p := make([]byte, 100)
|
||||
n, err := ra.ReadAt(p, 0)
|
||||
if err != nil {
|
||||
t.Errorf("single chunk start: got err=%v, want nil", err)
|
||||
}
|
||||
if n != 100 {
|
||||
t.Errorf("single chunk start: got n=%d, want 100", n)
|
||||
}
|
||||
if tracker.called {
|
||||
t.Error("single chunk start: unexpected src.ReadAt call")
|
||||
}
|
||||
if !bytes.Equal(p[:n], plaintext[:100]) {
|
||||
t.Error("single chunk start: data mismatch")
|
||||
}
|
||||
|
||||
// Read at end
|
||||
n, err = ra.ReadAt(p, 900)
|
||||
if err != io.EOF {
|
||||
t.Errorf("single chunk end: got err=%v, want EOF", err)
|
||||
}
|
||||
if n != 100 {
|
||||
t.Errorf("single chunk end: got n=%d, want 100", n)
|
||||
}
|
||||
if tracker.called {
|
||||
t.Error("single chunk end: unexpected src.ReadAt call")
|
||||
}
|
||||
if !bytes.Equal(p[:n], plaintext[900:]) {
|
||||
t.Error("single chunk end: data mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptReaderAtFullChunks(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Exactly 2 full chunks
|
||||
plaintext := make([]byte, 2*cs)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
w, err := stream.NewEncryptWriter(key, buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
|
||||
tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)}
|
||||
ra, err := stream.NewDecryptReaderAt(key, tracker, int64(len(ciphertext)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tracker.reset()
|
||||
|
||||
// Read last byte of second chunk (cached)
|
||||
p := make([]byte, 1)
|
||||
n, err := ra.ReadAt(p, int64(2*cs-1))
|
||||
if err != io.EOF {
|
||||
t.Errorf("last byte: got err=%v, want EOF", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("last byte: got n=%d, want 1", n)
|
||||
}
|
||||
if tracker.called {
|
||||
t.Error("last byte: unexpected src.ReadAt call (should be cached)")
|
||||
}
|
||||
if p[0] != plaintext[2*cs-1] {
|
||||
t.Error("last byte: data mismatch")
|
||||
}
|
||||
|
||||
// Read at exactly the boundary between chunks
|
||||
p = make([]byte, 100)
|
||||
n, err = ra.ReadAt(p, int64(cs-50))
|
||||
if err != nil {
|
||||
t.Errorf("boundary: got err=%v, want nil", err)
|
||||
}
|
||||
if n != 100 {
|
||||
t.Errorf("boundary: got n=%d, want 100", n)
|
||||
}
|
||||
if !bytes.Equal(p, plaintext[cs-50:cs+50]) {
|
||||
t.Error("boundary: data mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptReaderAtWrongKey(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
plaintext := make([]byte, 1000)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
w, err := stream.NewEncryptWriter(key, buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
|
||||
// Try to decrypt with wrong key
|
||||
wrongKey := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(wrongKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = stream.NewDecryptReaderAt(wrongKey, bytes.NewReader(ciphertext), int64(len(ciphertext)))
|
||||
if err == nil {
|
||||
t.Error("wrong key: expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptReaderAtInvalidSize(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
plaintext := make([]byte, 1000)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
w, err := stream.NewEncryptWriter(key, buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
|
||||
// Wrong size (too small)
|
||||
_, err = stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), int64(len(ciphertext)-1))
|
||||
if err == nil {
|
||||
t.Error("wrong size (small): expected error, got nil")
|
||||
}
|
||||
|
||||
// Wrong size (too large)
|
||||
_, err = stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), int64(len(ciphertext)+1))
|
||||
if err == nil {
|
||||
t.Error("wrong size (large): expected error, got nil")
|
||||
}
|
||||
|
||||
// Size that would imply empty final chunk (invalid)
|
||||
// This would be: one full encrypted chunk + just overhead
|
||||
invalidSize := int64(cs + chacha20poly1305.Overhead + chacha20poly1305.Overhead)
|
||||
_, err = stream.NewDecryptReaderAt(key, bytes.NewReader(make([]byte, invalidSize)), invalidSize)
|
||||
if err == nil {
|
||||
t.Error("invalid size (empty final chunk): expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptReaderAtTruncated(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
plaintext := make([]byte, 2*cs+500)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
w, err := stream.NewEncryptWriter(key, buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
|
||||
// Truncate ciphertext but lie about size
|
||||
truncated := ciphertext[:len(ciphertext)-100]
|
||||
_, err = stream.NewDecryptReaderAt(key, bytes.NewReader(truncated), int64(len(ciphertext)))
|
||||
if err == nil {
|
||||
t.Error("truncated: expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptReaderAtTruncatedChunk(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create 4 chunks: 3 full + 1 partial
|
||||
plaintext := make([]byte, 3*cs+500)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
w, err := stream.NewEncryptWriter(key, buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
|
||||
// Truncate to 3 chunks (remove the actual final chunk)
|
||||
// The third chunk was NOT encrypted with the last chunk flag,
|
||||
// so decryption should fail when we try to use it as the final chunk.
|
||||
encChunkSize := cs + 16 // ChunkSize + Overhead
|
||||
truncatedSize := int64(3 * encChunkSize)
|
||||
truncated := ciphertext[:truncatedSize]
|
||||
|
||||
_, err = stream.NewDecryptReaderAt(key, bytes.NewReader(truncated), truncatedSize)
|
||||
if err == nil {
|
||||
t.Error("truncated at chunk boundary: expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptReaderAtConcurrent(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create plaintext spanning 3 chunks: 2 full + partial
|
||||
plaintextSize := 2*cs + 500
|
||||
plaintext := make([]byte, plaintextSize)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Encrypt
|
||||
buf := &bytes.Buffer{}
|
||||
w, err := stream.NewEncryptWriter(key, buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ciphertext := buf.Bytes()
|
||||
|
||||
ra, err := stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), int64(len(ciphertext)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("same chunk", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
const goroutines = 10
|
||||
const iterations = 100
|
||||
errc := make(chan error, goroutines)
|
||||
|
||||
for g := range goroutines {
|
||||
go func(id int) {
|
||||
for i := range iterations {
|
||||
off := int64((id*iterations + i) % 500)
|
||||
p := make([]byte, 100)
|
||||
n, err := ra.ReadAt(p, off)
|
||||
if err != nil {
|
||||
errc <- fmt.Errorf("goroutine %d iter %d: %v", id, i, err)
|
||||
return
|
||||
}
|
||||
if n != 100 {
|
||||
errc <- fmt.Errorf("goroutine %d iter %d: n=%d, want 100", id, i, n)
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(p, plaintext[off:off+100]) {
|
||||
errc <- fmt.Errorf("goroutine %d iter %d: data mismatch", id, i)
|
||||
return
|
||||
}
|
||||
}
|
||||
errc <- nil
|
||||
}(g)
|
||||
}
|
||||
|
||||
for range goroutines {
|
||||
if err := <-errc; err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different chunks", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
const goroutines = 10
|
||||
const iterations = 100
|
||||
errc := make(chan error, goroutines)
|
||||
|
||||
for g := range goroutines {
|
||||
go func(id int) {
|
||||
for i := range iterations {
|
||||
// Each goroutine reads from a different chunk based on id
|
||||
chunkIdx := id % 3
|
||||
off := int64(chunkIdx*cs + (i % 400))
|
||||
size := 100
|
||||
if off+int64(size) > int64(plaintextSize) {
|
||||
size = plaintextSize - int(off)
|
||||
}
|
||||
p := make([]byte, size)
|
||||
n, err := ra.ReadAt(p, off)
|
||||
if n == size && err == io.EOF {
|
||||
err = nil // EOF at end is acceptable
|
||||
}
|
||||
if err != nil {
|
||||
errc <- fmt.Errorf("goroutine %d iter %d: off=%d: %v", id, i, off, err)
|
||||
return
|
||||
}
|
||||
if n != size {
|
||||
errc <- fmt.Errorf("goroutine %d iter %d: n=%d, want %d", id, i, n, size)
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(p[:n], plaintext[off:off+int64(n)]) {
|
||||
errc <- fmt.Errorf("goroutine %d iter %d: data mismatch", id, i)
|
||||
return
|
||||
}
|
||||
}
|
||||
errc <- nil
|
||||
}(g)
|
||||
}
|
||||
|
||||
for range goroutines {
|
||||
if err := <-errc; err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("across chunks", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
const goroutines = 10
|
||||
const iterations = 100
|
||||
errc := make(chan error, goroutines)
|
||||
|
||||
for g := range goroutines {
|
||||
go func(id int) {
|
||||
for i := range iterations {
|
||||
// Read across chunk boundaries
|
||||
boundary := (id%2 + 1) * cs // either cs or 2*cs
|
||||
off := int64(boundary - 50 + (i % 30))
|
||||
size := 100
|
||||
if off+int64(size) > int64(plaintextSize) {
|
||||
size = plaintextSize - int(off)
|
||||
}
|
||||
if size <= 0 {
|
||||
continue
|
||||
}
|
||||
p := make([]byte, size)
|
||||
n, err := ra.ReadAt(p, off)
|
||||
if n == size && err == io.EOF {
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
errc <- fmt.Errorf("goroutine %d iter %d: off=%d size=%d: %v", id, i, off, size, err)
|
||||
return
|
||||
}
|
||||
if n != size {
|
||||
errc <- fmt.Errorf("goroutine %d iter %d: n=%d, want %d", id, i, n, size)
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(p[:n], plaintext[off:off+int64(n)]) {
|
||||
errc <- fmt.Errorf("goroutine %d iter %d: data mismatch", id, i)
|
||||
return
|
||||
}
|
||||
}
|
||||
errc <- nil
|
||||
}(g)
|
||||
}
|
||||
|
||||
for range goroutines {
|
||||
if err := <-errc; err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDecryptReaderAtCorrupted(t *testing.T) {
|
||||
key := make([]byte, chacha20poly1305.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
plaintext := make([]byte, 2*cs+500)
|
||||
if _, err := rand.Read(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
w, err := stream.NewEncryptWriter(key, buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ciphertext := bytes.Clone(buf.Bytes())
|
||||
|
||||
// Corrupt final chunk - should fail in constructor
|
||||
corruptedFinal := bytes.Clone(ciphertext)
|
||||
corruptedFinal[len(corruptedFinal)-10] ^= 0xFF
|
||||
_, err = stream.NewDecryptReaderAt(key, bytes.NewReader(corruptedFinal), int64(len(corruptedFinal)))
|
||||
if err == nil {
|
||||
t.Error("corrupted final: expected error, got nil")
|
||||
}
|
||||
|
||||
// Corrupt first chunk - should fail on read
|
||||
corruptedFirst := bytes.Clone(ciphertext)
|
||||
corruptedFirst[10] ^= 0xFF
|
||||
ra, err := stream.NewDecryptReaderAt(key, bytes.NewReader(corruptedFirst), int64(len(corruptedFirst)))
|
||||
if err != nil {
|
||||
t.Fatalf("corrupted first constructor: unexpected error: %v", err)
|
||||
}
|
||||
p := make([]byte, 100)
|
||||
_, err = ra.ReadAt(p, 0)
|
||||
if err == nil {
|
||||
t.Error("corrupted first read: expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
122
internal/term/term.go
Normal file
122
internal/term/term.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package term
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// enableVirtualTerminalProcessing tries to enable virtual terminal processing
|
||||
// on Windows. If it fails, avoid using escape sequences to prevent weird
|
||||
// characters being printed to the console.
|
||||
var enableVirtualTerminalProcessing func(out *os.File) error
|
||||
|
||||
// clearLine clears the current line on the terminal, or opens a new line if
|
||||
// terminal escape codes don't work.
|
||||
func clearLine(out *os.File) {
|
||||
const (
|
||||
CUI = "\033[" // Control Sequence Introducer
|
||||
CPL = CUI + "F" // Cursor Previous Line
|
||||
EL = CUI + "K" // Erase in Line
|
||||
)
|
||||
|
||||
// First, open a new line, which is guaranteed to work everywhere. Then, try
|
||||
// to erase the line above with escape codes, if possible.
|
||||
//
|
||||
// (We use CRLF instead of LF to work around an apparent bug in WSL2's
|
||||
// handling of CONOUT$. Only when running a Windows binary from WSL2, the
|
||||
// cursor would not go back to the start of the line with a simple LF.
|
||||
// Honestly, it's impressive CONIN$ and CONOUT$ work at all inside WSL2.)
|
||||
fmt.Fprintf(out, "\r\n")
|
||||
if enableVirtualTerminalProcessing == nil || enableVirtualTerminalProcessing(out) == nil {
|
||||
fmt.Fprintf(out, CPL+EL)
|
||||
}
|
||||
}
|
||||
|
||||
// WithTerminal runs f with the terminal input and output files, if available.
|
||||
// WithTerminal does not open a non-terminal stdin, so the caller does not need
|
||||
// to check if stdin is in use.
|
||||
func WithTerminal(f func(in, out *os.File) error) error {
|
||||
if runtime.GOOS == "windows" {
|
||||
in, err := os.OpenFile("CONIN$", os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
return f(in, out)
|
||||
} else if tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0); err == nil {
|
||||
defer tty.Close()
|
||||
return f(tty, tty)
|
||||
} else if term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
return f(os.Stdin, os.Stdin)
|
||||
} else {
|
||||
return fmt.Errorf("standard input is not a terminal, and /dev/tty is not available: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ReadSecret reads a value from the terminal with no echo. The prompt is ephemeral.
|
||||
func ReadSecret(prompt string) (s []byte, err error) {
|
||||
err = WithTerminal(func(in, out *os.File) error {
|
||||
fmt.Fprintf(out, "%s ", prompt)
|
||||
defer clearLine(out)
|
||||
s, err = term.ReadPassword(int(in.Fd()))
|
||||
return err
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ReadPublic reads a value from the terminal. The prompt is ephemeral.
|
||||
func ReadPublic(prompt string) (s []byte, err error) {
|
||||
err = WithTerminal(func(in, out *os.File) error {
|
||||
fmt.Fprintf(out, "%s ", prompt)
|
||||
defer clearLine(out)
|
||||
|
||||
oldState, err := term.MakeRaw(int(in.Fd()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer term.Restore(int(in.Fd()), oldState)
|
||||
|
||||
t := term.NewTerminal(in, "")
|
||||
line, err := t.ReadLine()
|
||||
s = []byte(line)
|
||||
return err
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ReadCharacter reads a single character from the terminal with no echo. The
|
||||
// prompt is ephemeral.
|
||||
func ReadCharacter(prompt string) (c byte, err error) {
|
||||
err = WithTerminal(func(in, out *os.File) error {
|
||||
fmt.Fprintf(out, "%s ", prompt)
|
||||
defer clearLine(out)
|
||||
|
||||
oldState, err := term.MakeRaw(int(in.Fd()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer term.Restore(int(in.Fd()), oldState)
|
||||
|
||||
b := make([]byte, 1)
|
||||
if _, err := in.Read(b); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c = b[0]
|
||||
return nil
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// IsTerminal returns whether the given file is a terminal.
|
||||
func IsTerminal(f *os.File) bool {
|
||||
return term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
48
internal/term/term_windows.go
Normal file
48
internal/term/term_windows.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright 2022 The age Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package term
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func init() {
|
||||
enableVirtualTerminalProcessing = func(out *os.File) error {
|
||||
// Some instances of the Windows Console (e.g., cmd.exe and Windows PowerShell)
|
||||
// do not have the virtual terminal processing enabled, which is necessary to
|
||||
// make terminal escape sequences work. For this reason the clearLine function
|
||||
// may not properly work. Here we enable the virtual terminal processing, if
|
||||
// possible.
|
||||
//
|
||||
// See https://learn.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences.
|
||||
|
||||
const (
|
||||
ENABLE_PROCESSED_OUTPUT uint32 = 0x1
|
||||
ENABLE_VIRTUAL_TERMINAL_PROCESSING uint32 = 0x4
|
||||
)
|
||||
|
||||
kernel32DLL := windows.NewLazySystemDLL("Kernel32.dll")
|
||||
setConsoleMode := kernel32DLL.NewProc("SetConsoleMode")
|
||||
|
||||
var mode uint32
|
||||
if err := syscall.GetConsoleMode(syscall.Handle(out.Fd()), &mode); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mode |= ENABLE_PROCESSED_OUTPUT
|
||||
mode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING
|
||||
|
||||
// If the SetConsoleMode function fails, the return value is zero.
|
||||
// See https://learn.microsoft.com/en-us/windows/console/setconsolemode#return-value.
|
||||
if ret, _, _ := setConsoleMode.Call(out.Fd(), uintptr(mode)); ret == 0 {
|
||||
return errors.New("SetConsoleMode failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
54
parse.go
54
parse.go
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// ParseIdentities parses a file with one or more private key encodings, one per
|
||||
@@ -16,10 +17,10 @@ import (
|
||||
//
|
||||
// This is the same syntax as the private key files accepted by the CLI, except
|
||||
// the CLI also accepts SSH private keys, which are not recommended for the
|
||||
// average application.
|
||||
// average application, and plugins, which involve invoking external programs.
|
||||
//
|
||||
// Currently, all returned values are of type *X25519Identity, but different
|
||||
// types might be returned in the future.
|
||||
// Currently, all returned values are of type *[X25519Identity] or
|
||||
// *[HybridIdentity], but different types might be returned in the future.
|
||||
func ParseIdentities(f io.Reader) ([]Identity, error) {
|
||||
const privateKeySizeLimit = 1 << 24 // 16 MiB
|
||||
var ids []Identity
|
||||
@@ -31,30 +32,45 @@ func ParseIdentities(f io.Reader) ([]Identity, error) {
|
||||
if strings.HasPrefix(line, "#") || line == "" {
|
||||
continue
|
||||
}
|
||||
i, err := ParseX25519Identity(line)
|
||||
if !utf8.ValidString(line) {
|
||||
return nil, fmt.Errorf("identities file is not valid UTF-8")
|
||||
}
|
||||
i, err := parseIdentity(line)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error at line %d: %v", n, err)
|
||||
}
|
||||
ids = append(ids, i)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("failed to read secret keys file: %v", err)
|
||||
return nil, fmt.Errorf("failed to read identities file: %v", err)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("no secret keys found")
|
||||
return nil, fmt.Errorf("no identities found")
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func parseIdentity(arg string) (Identity, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(arg, "AGE-SECRET-KEY-1"):
|
||||
return ParseX25519Identity(arg)
|
||||
case strings.HasPrefix(arg, "AGE-SECRET-KEY-PQ-1"):
|
||||
return ParseHybridIdentity(arg)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown identity type: %q", arg)
|
||||
}
|
||||
}
|
||||
|
||||
// ParseRecipients parses a file with one or more public key encodings, one per
|
||||
// line. Empty lines and lines starting with "#" are ignored.
|
||||
//
|
||||
// This is the same syntax as the recipients files accepted by the CLI, except
|
||||
// the CLI also accepts SSH recipients, which are not recommended for the
|
||||
// average application.
|
||||
// average application, tagged recipients, which have different privacy
|
||||
// properties, and plugins, which involve invoking external programs.
|
||||
//
|
||||
// Currently, all returned values are of type *X25519Recipient, but different
|
||||
// types might be returned in the future.
|
||||
// Currently, all returned values are of type *[X25519Recipient] or
|
||||
// *[HybridRecipient] but different types might be returned in the future.
|
||||
func ParseRecipients(f io.Reader) ([]Recipient, error) {
|
||||
const recipientFileSizeLimit = 1 << 24 // 16 MiB
|
||||
var recs []Recipient
|
||||
@@ -66,11 +82,12 @@ func ParseRecipients(f io.Reader) ([]Recipient, error) {
|
||||
if strings.HasPrefix(line, "#") || line == "" {
|
||||
continue
|
||||
}
|
||||
r, err := ParseX25519Recipient(line)
|
||||
if !utf8.ValidString(line) {
|
||||
return nil, fmt.Errorf("recipients file is not valid UTF-8")
|
||||
}
|
||||
r, err := parseRecipient(line)
|
||||
if err != nil {
|
||||
// Hide the error since it might unintentionally leak the contents
|
||||
// of confidential files.
|
||||
return nil, fmt.Errorf("malformed recipient at line %d", n)
|
||||
return nil, fmt.Errorf("error at line %d: %v", n, err)
|
||||
}
|
||||
recs = append(recs, r)
|
||||
}
|
||||
@@ -82,3 +99,14 @@ func ParseRecipients(f io.Reader) ([]Recipient, error) {
|
||||
}
|
||||
return recs, nil
|
||||
}
|
||||
|
||||
func parseRecipient(arg string) (Recipient, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(arg, "age1pq1"):
|
||||
return ParseHybridRecipient(arg)
|
||||
case strings.HasPrefix(arg, "age1"):
|
||||
return ParseX25519Recipient(arg)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown recipient type: %q", arg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,18 +4,19 @@
|
||||
// license that can be found in the LICENSE file or at
|
||||
// https://developers.google.com/open-source/licenses/bsd
|
||||
|
||||
// Package plugin implements the age plugin protocol.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
mathrand "math/rand/v2"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
exec "golang.org/x/sys/execabs"
|
||||
@@ -53,6 +54,15 @@ func (r *Recipient) Name() string {
|
||||
return r.name
|
||||
}
|
||||
|
||||
// String returns the recipient encoding string ("age1name1...") or
|
||||
// "<identity-based recipient>" if r was created by [Identity.Recipient].
|
||||
func (r *Recipient) String() string {
|
||||
if r.identity {
|
||||
return "<identity-based recipient>"
|
||||
}
|
||||
return r.encoding
|
||||
}
|
||||
|
||||
func (r *Recipient) Wrap(fileKey []byte) (stanzas []*age.Stanza, err error) {
|
||||
stanzas, _, err = r.WrapWithLabels(fileKey)
|
||||
return
|
||||
@@ -67,7 +77,7 @@ func (r *Recipient) WrapWithLabels(fileKey []byte) (stanzas []*age.Stanza, label
|
||||
|
||||
conn, err := openClientConnection(r.name, "recipient-v1")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("couldn't start plugin: %v", err)
|
||||
return nil, nil, fmt.Errorf("couldn't start plugin: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
@@ -79,7 +89,7 @@ func (r *Recipient) WrapWithLabels(fileKey []byte) (stanzas []*age.Stanza, label
|
||||
if err := writeStanza(conn, addType, r.encoding); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := writeStanza(conn, fmt.Sprintf("grease-%x", rand.Int())); err != nil {
|
||||
if _, err := writeGrease(conn); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := writeStanzaWithBody(conn, "wrap-file-key", fileKey); err != nil {
|
||||
@@ -179,6 +189,9 @@ func NewIdentity(s string, ui *ClientUI) (*Identity, error) {
|
||||
|
||||
func NewIdentityWithoutData(name string, ui *ClientUI) (*Identity, error) {
|
||||
s := EncodeIdentity(name, nil)
|
||||
if s == "" {
|
||||
return nil, fmt.Errorf("invalid plugin name: %q", name)
|
||||
}
|
||||
return &Identity{
|
||||
name: name, encoding: s, ui: ui,
|
||||
}, nil
|
||||
@@ -191,6 +204,11 @@ func (i *Identity) Name() string {
|
||||
return i.name
|
||||
}
|
||||
|
||||
// String returns the identity encoding string ("AGE-PLUGIN-NAME-1...").
|
||||
func (i *Identity) String() string {
|
||||
return i.encoding
|
||||
}
|
||||
|
||||
// Recipient returns a Recipient wrapping this identity. When that Recipient is
|
||||
// used to encrypt a file key, the identity encoding is provided as-is to the
|
||||
// plugin, which is expected to support encrypting to identities.
|
||||
@@ -212,7 +230,7 @@ func (i *Identity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) {
|
||||
|
||||
conn, err := openClientConnection(i.name, "identity-v1")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("couldn't start plugin: %v", err)
|
||||
return nil, fmt.Errorf("couldn't start plugin: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
@@ -220,7 +238,7 @@ func (i *Identity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) {
|
||||
if err := writeStanza(conn, "add-identity", i.encoding); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeStanza(conn, fmt.Sprintf("grease-%x", rand.Int())); err != nil {
|
||||
if _, err := writeGrease(conn); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, rs := range stanzas {
|
||||
@@ -382,16 +400,35 @@ type clientConnection struct {
|
||||
cmd *exec.Cmd
|
||||
io.Reader // stdout
|
||||
io.Writer // stdin
|
||||
stderr bytes.Buffer
|
||||
close func()
|
||||
}
|
||||
|
||||
// NotFoundError is returned by [Recipient.Wrap] and [Identity.Unwrap] when the
|
||||
// plugin binary cannot be found.
|
||||
type NotFoundError struct {
|
||||
// Name is the plugin (not binary) name.
|
||||
Name string
|
||||
// Err is the underlying error, usually an [exec.Error] wrapping
|
||||
// [exec.ErrNotFound].
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *NotFoundError) Error() string {
|
||||
return fmt.Sprintf("%q plugin not found: %v", e.Name, e.Err)
|
||||
}
|
||||
|
||||
func (e *NotFoundError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
var testOnlyPluginPath string
|
||||
|
||||
func openClientConnection(name, protocol string) (*clientConnection, error) {
|
||||
path := "age-plugin-" + name
|
||||
if testOnlyPluginPath != "" {
|
||||
path = filepath.Join(testOnlyPluginPath, path)
|
||||
} else if strings.ContainsRune(name, os.PathSeparator) {
|
||||
return nil, fmt.Errorf("invalid plugin name: %q", name)
|
||||
}
|
||||
cmd := exec.Command(path, "--age-plugin="+protocol)
|
||||
|
||||
@@ -426,6 +463,9 @@ func openClientConnection(name, protocol string) (*clientConnection, error) {
|
||||
cmd.Dir = os.TempDir()
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return nil, &NotFoundError{Name: name, Err: err}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -449,3 +489,18 @@ func writeStanzaWithBody(conn io.Writer, t string, body []byte) error {
|
||||
s := &format.Stanza{Type: t, Body: body}
|
||||
return s.Marshal(conn)
|
||||
}
|
||||
|
||||
func writeGrease(conn io.Writer) (sent bool, err error) {
|
||||
if mathrand.IntN(3) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
s := &format.Stanza{Type: fmt.Sprintf("grease-%x", mathrand.Int())}
|
||||
for i := 0; i < mathrand.IntN(3); i++ {
|
||||
s.Args = append(s.Args, fmt.Sprintf("%d", mathrand.IntN(100)))
|
||||
}
|
||||
if mathrand.IntN(2) == 0 {
|
||||
s.Body = make([]byte, mathrand.IntN(100))
|
||||
rand.Read(s.Body)
|
||||
}
|
||||
return true, s.Marshal(conn)
|
||||
}
|
||||
|
||||
@@ -7,11 +7,14 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
@@ -20,63 +23,50 @@ import (
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
switch filepath.Base(os.Args[0]) {
|
||||
// TODO: deduplicate from cmd/age TestMain.
|
||||
case "age-plugin-test":
|
||||
switch os.Args[1] {
|
||||
case "--age-plugin=recipient-v1":
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
scanner.Scan() // add-recipient
|
||||
scanner.Scan() // body
|
||||
scanner.Scan() // grease
|
||||
scanner.Scan() // body
|
||||
scanner.Scan() // wrap-file-key
|
||||
scanner.Scan() // body
|
||||
fileKey := scanner.Text()
|
||||
scanner.Scan() // extension-labels
|
||||
scanner.Scan() // body
|
||||
scanner.Scan() // done
|
||||
scanner.Scan() // body
|
||||
os.Stdout.WriteString("-> recipient-stanza 0 test\n")
|
||||
os.Stdout.WriteString(fileKey + "\n")
|
||||
scanner.Scan() // ok
|
||||
scanner.Scan() // body
|
||||
os.Stdout.WriteString("-> done\n\n")
|
||||
os.Exit(0)
|
||||
default:
|
||||
panic(os.Args[1])
|
||||
}
|
||||
p, _ := New("test")
|
||||
p.HandleRecipient(func(data []byte) (age.Recipient, error) {
|
||||
return testRecipient{}, nil
|
||||
})
|
||||
os.Exit(p.Main())
|
||||
case "age-plugin-testpqc":
|
||||
switch os.Args[1] {
|
||||
case "--age-plugin=recipient-v1":
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
scanner.Scan() // add-recipient
|
||||
scanner.Scan() // body
|
||||
scanner.Scan() // grease
|
||||
scanner.Scan() // body
|
||||
scanner.Scan() // wrap-file-key
|
||||
scanner.Scan() // body
|
||||
fileKey := scanner.Text()
|
||||
scanner.Scan() // extension-labels
|
||||
scanner.Scan() // body
|
||||
scanner.Scan() // done
|
||||
scanner.Scan() // body
|
||||
os.Stdout.WriteString("-> recipient-stanza 0 test\n")
|
||||
os.Stdout.WriteString(fileKey + "\n")
|
||||
scanner.Scan() // ok
|
||||
scanner.Scan() // body
|
||||
os.Stdout.WriteString("-> labels postquantum\n\n")
|
||||
scanner.Scan() // ok
|
||||
scanner.Scan() // body
|
||||
os.Stdout.WriteString("-> done\n\n")
|
||||
os.Exit(0)
|
||||
default:
|
||||
panic(os.Args[1])
|
||||
}
|
||||
p, _ := New("testpqc")
|
||||
p.HandleRecipient(func(data []byte) (age.Recipient, error) {
|
||||
return testPQCRecipient{}, nil
|
||||
})
|
||||
os.Exit(p.Main())
|
||||
case "age-plugin-error":
|
||||
p, _ := New("error")
|
||||
p.HandleRecipient(func(data []byte) (age.Recipient, error) {
|
||||
return nil, errors.New("oh my, an error occurred")
|
||||
})
|
||||
p.HandleIdentity(func(data []byte) (age.Identity, error) {
|
||||
return nil, errors.New("oh my, an error occurred")
|
||||
})
|
||||
os.Exit(p.Main())
|
||||
default:
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
}
|
||||
|
||||
type testRecipient struct{}
|
||||
|
||||
func (testRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
|
||||
return []*age.Stanza{{Type: "test", Body: fileKey}}, nil
|
||||
}
|
||||
|
||||
type testPQCRecipient struct{}
|
||||
|
||||
var _ age.RecipientWithLabels = testPQCRecipient{}
|
||||
|
||||
func (testPQCRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
|
||||
return []*age.Stanza{{Type: "test", Body: fileKey}}, nil
|
||||
}
|
||||
|
||||
func (testPQCRecipient) WrapWithLabels(fileKey []byte) ([]*age.Stanza, []string, error) {
|
||||
return []*age.Stanza{{Type: "test", Body: fileKey}}, []string{"postquantum"}, nil
|
||||
}
|
||||
|
||||
func TestLabels(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows support is TODO")
|
||||
@@ -131,3 +121,107 @@ func TestLabels(t *testing.T) {
|
||||
t.Errorf("expected one pqc and one normal to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotFound(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows support is TODO")
|
||||
}
|
||||
|
||||
r := EncodeRecipient("nonexistentplugin", nil)
|
||||
t.Log(r)
|
||||
testPluginRecipient, err := NewRecipient(r, &ClientUI{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var e *NotFoundError
|
||||
if _, err := age.Encrypt(io.Discard, testPluginRecipient); err == nil {
|
||||
t.Errorf("expected error for nonexistent plugin")
|
||||
} else if !errors.As(err, &e) {
|
||||
t.Errorf("expected NotFoundError, got %T: %v", err, err)
|
||||
} else if e.Name != "nonexistentplugin" {
|
||||
t.Errorf("expected NotFoundError.Name to be nonexistentplugin, got %q", e.Name)
|
||||
} else if !errors.Is(err, exec.ErrNotFound) {
|
||||
t.Errorf("expected error to wrap exec.ErrNotFound, got: %v", err)
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
id, err := age.GenerateHybridIdentity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w, err := age.Encrypt(buf, id.Recipient())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Close()
|
||||
|
||||
i := EncodeIdentity("nonexistentplugin", nil)
|
||||
t.Log(i)
|
||||
testPluginIdentity, err := NewIdentity(i, &ClientUI{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := age.Decrypt(buf, testPluginIdentity); err == nil {
|
||||
t.Errorf("expected error for nonexistent plugin")
|
||||
} else if errors.As(err, new(*age.NoIdentityMatchError)) {
|
||||
t.Errorf("expected NotFoundError, got NoIdentityMatchError: %v", err)
|
||||
} else if !errors.As(err, &e) {
|
||||
t.Errorf("expected NotFoundError, got %T: %v", err, err)
|
||||
} else if e.Name != "nonexistentplugin" {
|
||||
t.Errorf("expected NotFoundError.Name to be nonexistentplugin, got %q", e.Name)
|
||||
} else if !errors.Is(err, exec.ErrNotFound) {
|
||||
t.Errorf("expected error to wrap exec.ErrNotFound, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginError(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows support is TODO")
|
||||
}
|
||||
temp := t.TempDir()
|
||||
testOnlyPluginPath = temp
|
||||
t.Cleanup(func() { testOnlyPluginPath = "" })
|
||||
ex, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Link(ex, filepath.Join(temp, "age-plugin-error")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(filepath.Join(temp, "age-plugin-error"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := EncodeRecipient("error", nil)
|
||||
testPluginRecipient, err := NewRecipient(r, &ClientUI{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := age.Encrypt(io.Discard, testPluginRecipient); err == nil {
|
||||
t.Errorf("expected error from plugin")
|
||||
} else if !strings.Contains(err.Error(), "oh my, an error occurred") {
|
||||
t.Errorf("expected plugin error, got: %v", err)
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
id, err := age.GenerateHybridIdentity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w, err := age.Encrypt(buf, id.Recipient())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Close()
|
||||
|
||||
i := EncodeIdentity("error", nil)
|
||||
testPluginIdentity, err := NewIdentity(i, &ClientUI{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := age.Decrypt(buf, testPluginIdentity); err == nil {
|
||||
t.Errorf("expected error from plugin")
|
||||
} else if !strings.Contains(err.Error(), "oh my, an error occurred") {
|
||||
t.Errorf("expected plugin error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,21 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/mlkem"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"filippo.io/age/internal/bech32"
|
||||
"filippo.io/hpke"
|
||||
)
|
||||
|
||||
// EncodeIdentity encodes a plugin identity string for a plugin with the given
|
||||
// name. If the name is invalid, it returns an empty string.
|
||||
func EncodeIdentity(name string, data []byte) string {
|
||||
if !validPluginName(name) {
|
||||
return ""
|
||||
}
|
||||
s, _ := bech32.Encode("AGE-PLUGIN-"+strings.ToUpper(name)+"-", data)
|
||||
return s
|
||||
}
|
||||
@@ -30,12 +36,18 @@ func ParseIdentity(s string) (name string, data []byte, err error) {
|
||||
}
|
||||
name = strings.TrimSuffix(strings.TrimPrefix(hrp, "AGE-PLUGIN-"), "-")
|
||||
name = strings.ToLower(name)
|
||||
if !validPluginName(name) {
|
||||
return "", nil, fmt.Errorf("invalid plugin name: %q", name)
|
||||
}
|
||||
return name, data, nil
|
||||
}
|
||||
|
||||
// EncodeRecipient encodes a plugin recipient string for a plugin with the given
|
||||
// name. If the name is invalid, it returns an empty string.
|
||||
func EncodeRecipient(name string, data []byte) string {
|
||||
if !validPluginName(name) {
|
||||
return ""
|
||||
}
|
||||
s, _ := bech32.Encode("age1"+strings.ToLower(name), data)
|
||||
return s
|
||||
}
|
||||
@@ -51,5 +63,46 @@ func ParseRecipient(s string) (name string, data []byte, err error) {
|
||||
return "", nil, fmt.Errorf("not a plugin recipient: %v", err)
|
||||
}
|
||||
name = strings.TrimPrefix(hrp, "age1")
|
||||
if !validPluginName(name) {
|
||||
return "", nil, fmt.Errorf("invalid plugin name: %q", name)
|
||||
}
|
||||
return name, data, nil
|
||||
}
|
||||
|
||||
func validPluginName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
allowed := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+-._"
|
||||
for _, r := range name {
|
||||
if !strings.ContainsRune(allowed, r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// EncodeX25519Recipient encodes a native X25519 recipient from a
|
||||
// [crypto/ecdh.X25519] public key. It's meant for plugins that implement
|
||||
// identities that are compatible with native recipients.
|
||||
func EncodeX25519Recipient(pk *ecdh.PublicKey) (string, error) {
|
||||
if pk.Curve() != ecdh.X25519() {
|
||||
return "", fmt.Errorf("wrong ecdh Curve")
|
||||
}
|
||||
return bech32.Encode("age", pk.Bytes())
|
||||
}
|
||||
|
||||
// EncodeHybridRecipient encodes a native MLKEM768-X25519 recipient from a
|
||||
// [crypto/mlkem.EncapsulationKey768] and a [crypto/ecdh.X25519] public key.
|
||||
// It's meant for plugins that implement identities that are compatible with
|
||||
// native recipients.
|
||||
func EncodeHybridRecipient(pq *mlkem.EncapsulationKey768, t *ecdh.PublicKey) (string, error) {
|
||||
if t.Curve() != ecdh.X25519() {
|
||||
return "", fmt.Errorf("wrong ecdh Curve")
|
||||
}
|
||||
pk, err := hpke.NewHybridPublicKey(pq, t)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create hybrid public key: %v", err)
|
||||
}
|
||||
return bech32.Encode("age1pq", pk.Bytes())
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
// Copyright 2023 The age Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build go1.20
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"fmt"
|
||||
|
||||
"filippo.io/age/internal/bech32"
|
||||
)
|
||||
|
||||
// EncodeX25519Recipient encodes a native X25519 recipient from a
|
||||
// [crypto/ecdh.X25519] public key. It's meant for plugins that implement
|
||||
// identities that are compatible with native recipients.
|
||||
func EncodeX25519Recipient(pk *ecdh.PublicKey) (string, error) {
|
||||
if pk.Curve() != ecdh.X25519() {
|
||||
return "", fmt.Errorf("wrong ecdh Curve")
|
||||
}
|
||||
return bech32.Encode("age", pk.Bytes())
|
||||
}
|
||||
43
plugin/example_test.go
Normal file
43
plugin/example_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package plugin_test
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"filippo.io/age"
|
||||
"filippo.io/age/plugin"
|
||||
)
|
||||
|
||||
type Recipient struct{}
|
||||
|
||||
func (r *Recipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
func NewRecipient(data []byte) (*Recipient, error) {
|
||||
return &Recipient{}, nil
|
||||
}
|
||||
|
||||
type Identity struct{}
|
||||
|
||||
func (i *Identity) Unwrap(s []*age.Stanza) ([]byte, error) {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
func NewIdentity(data []byte) (*Identity, error) {
|
||||
return &Identity{}, nil
|
||||
}
|
||||
|
||||
func ExamplePlugin_main() {
|
||||
p, err := plugin.New("example")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
p.HandleRecipient(func(data []byte) (age.Recipient, error) {
|
||||
return NewRecipient(data)
|
||||
})
|
||||
p.HandleIdentity(func(data []byte) (age.Identity, error) {
|
||||
return NewIdentity(data)
|
||||
})
|
||||
os.Exit(p.Main())
|
||||
}
|
||||
676
plugin/plugin.go
Normal file
676
plugin/plugin.go
Normal file
@@ -0,0 +1,676 @@
|
||||
// Package plugin implements the age plugin protocol.
|
||||
//
|
||||
// [Recipient] and [Indentity] are plugin clients, that execute plugin binaries to
|
||||
// perform encryption and decryption operations.
|
||||
//
|
||||
// [Plugin] is a framework for writing age plugins, that exposes an [age.Recipient]
|
||||
// and/or [age.Identity] implementation as a plugin binary.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"filippo.io/age"
|
||||
"filippo.io/age/internal/format"
|
||||
)
|
||||
|
||||
// TODO: add plugin test framework.
|
||||
|
||||
// Plugin is a framework for writing age plugins. It allows exposing regular
|
||||
// [age.Recipient] and [age.Identity] implementations as plugins, and handles
|
||||
// all the protocol details.
|
||||
type Plugin struct {
|
||||
name string
|
||||
fs *flag.FlagSet
|
||||
sm *string
|
||||
|
||||
recipient func([]byte) (age.Recipient, error)
|
||||
idAsRecipient func([]byte) (age.Recipient, error)
|
||||
identity func([]byte) (age.Identity, error)
|
||||
|
||||
stdin io.Reader
|
||||
stdout, stderr io.Writer
|
||||
|
||||
sr *format.StanzaReader
|
||||
// broken is set if the protocol broke down during an interaction function
|
||||
// called by a Recipient or Identity.
|
||||
broken bool
|
||||
}
|
||||
|
||||
// New creates a new Plugin with the given name.
|
||||
//
|
||||
// For example, a plugin named "frood" would be invoked as "age-plugin-frood".
|
||||
func New(name string) (*Plugin, error) {
|
||||
return &Plugin{name: name, stdin: os.Stdin,
|
||||
stdout: os.Stdout, stderr: os.Stderr}, nil
|
||||
}
|
||||
|
||||
// Name returns the name of the plugin.
|
||||
func (p *Plugin) Name() string {
|
||||
return p.name
|
||||
}
|
||||
|
||||
// RegisterFlags registers the plugin's flags with the given [flag.FlagSet], or
|
||||
// with the default [flag.CommandLine] if fs is nil. It must be called before
|
||||
// [flag.Parse] and [Plugin.Main].
|
||||
//
|
||||
// This allows the plugin to expose additional flags when invoked manually, for
|
||||
// example to implement a keygen mode.
|
||||
func (p *Plugin) RegisterFlags(fs *flag.FlagSet) {
|
||||
if fs == nil {
|
||||
fs = flag.CommandLine
|
||||
}
|
||||
p.fs = fs
|
||||
p.sm = fs.String("age-plugin", "", "age-plugin state machine")
|
||||
}
|
||||
|
||||
// HandleRecipient registers a function to parse recipients of the form
|
||||
// age1name1... into [age.Recipient] values. data is the decoded Bech32 payload.
|
||||
//
|
||||
// If the returned Recipient implements [age.RecipientWithLabels], Plugin will
|
||||
// use it and enforce consistency across every returned stanza in an execution.
|
||||
// If the client supports labels, they will be passed through the protocol.
|
||||
//
|
||||
// It must be called before [Plugin.Main], and can be called at most once.
|
||||
// Otherwise, it panics.
|
||||
func (p *Plugin) HandleRecipient(f func(data []byte) (age.Recipient, error)) {
|
||||
if p.recipient != nil {
|
||||
panic("HandleRecipient called twice")
|
||||
}
|
||||
p.recipient = f
|
||||
}
|
||||
|
||||
// HandleIdentityAsRecipient registers a function to parse identities of the
|
||||
// form AGE-PLUGIN-NAME-1... into [age.Recipient] values, for when identities
|
||||
// are used as recipients. data is the decoded Bech32 payload.
|
||||
//
|
||||
// If the returned Recipient implements [age.RecipientWithLabels], Plugin will
|
||||
// use it and enforce consistency across every returned stanza in an execution.
|
||||
// If the client supports labels, they will be passed through the protocol.
|
||||
//
|
||||
// It must be called before [Plugin.Main], and can be called at most once.
|
||||
// Otherwise, it panics.
|
||||
func (p *Plugin) HandleIdentityAsRecipient(f func(data []byte) (age.Recipient, error)) {
|
||||
if p.idAsRecipient != nil {
|
||||
panic("HandleIdentityAsRecipient called twice")
|
||||
}
|
||||
p.idAsRecipient = f
|
||||
}
|
||||
|
||||
// HandleIdentity registers a function to parse identities of the form
|
||||
// AGE-PLUGIN-NAME-1... into [age.Identity] values. data is the decoded Bech32
|
||||
// payload.
|
||||
//
|
||||
// It must be called before [Plugin.Main], and can be called at most once.
|
||||
// Otherwise, it panics.
|
||||
func (p *Plugin) HandleIdentity(f func(data []byte) (age.Identity, error)) {
|
||||
if p.identity != nil {
|
||||
panic("HandleIdentity called twice")
|
||||
}
|
||||
p.identity = f
|
||||
}
|
||||
|
||||
// HandleRecipientEncoding is like [Plugin.HandleRecipient] but provides the
|
||||
// full recipient encoding string to the callback.
|
||||
//
|
||||
// It allows using functions like ParseRecipient directly.
|
||||
func (p *Plugin) HandleRecipientEncoding(f func(recipient string) (age.Recipient, error)) {
|
||||
p.HandleRecipient(func(data []byte) (age.Recipient, error) {
|
||||
return f(EncodeRecipient(p.name, data))
|
||||
})
|
||||
}
|
||||
|
||||
// HandleIdentityEncodingAsRecipient is like [Plugin.HandleIdentityAsRecipient] but
|
||||
// provides the full identity encoding string to the callback.
|
||||
func (p *Plugin) HandleIdentityEncodingAsRecipient(f func(identity string) (age.Recipient, error)) {
|
||||
p.HandleIdentityAsRecipient(func(data []byte) (age.Recipient, error) {
|
||||
return f(EncodeIdentity(p.name, data))
|
||||
})
|
||||
}
|
||||
|
||||
// HandleIdentityEncoding is like [Plugin.HandleIdentity] but provides the
|
||||
// full identity encoding string to the callback.
|
||||
//
|
||||
// It allows using functions like ParseIdentity directly.
|
||||
func (p *Plugin) HandleIdentityEncoding(f func(identity string) (age.Identity, error)) {
|
||||
p.HandleIdentity(func(data []byte) (age.Identity, error) {
|
||||
return f(EncodeIdentity(p.name, data))
|
||||
})
|
||||
}
|
||||
|
||||
// Main runs the plugin protocol. It returns an exit code to pass to os.Exit.
|
||||
//
|
||||
// It automatically calls [Plugin.RegisterFlags] and [flag.Parse] if they were
|
||||
// not called before.
|
||||
func (p *Plugin) Main() int {
|
||||
if p.fs == nil {
|
||||
p.RegisterFlags(nil)
|
||||
}
|
||||
if !p.fs.Parsed() {
|
||||
p.fs.Parse(os.Args[1:])
|
||||
}
|
||||
if *p.sm == "recipient-v1" {
|
||||
return p.RecipientV1()
|
||||
}
|
||||
if *p.sm == "identity-v1" {
|
||||
return p.IdentityV1()
|
||||
}
|
||||
fmt.Fprintf(p.stderr, "unknown state machine %q", *p.sm)
|
||||
return 4
|
||||
}
|
||||
|
||||
// SetIO sets the plugin's input and output streams, which default to
|
||||
// stdin/stdout/stderr.
|
||||
//
|
||||
// It must be called before [Plugin.Main].
|
||||
func (p *Plugin) SetIO(stdin io.Reader, stdout, stderr io.Writer) {
|
||||
p.stdin = stdin
|
||||
p.stdout = stdout
|
||||
p.stderr = stderr
|
||||
}
|
||||
|
||||
// RecipientV1 implements the recipient-v1 state machine. It returns an exit
|
||||
// code to pass to os.Exit.
|
||||
//
|
||||
// Most plugins should call [Plugin.Main] instead of this method.
|
||||
func (p *Plugin) RecipientV1() int {
|
||||
if p.recipient == nil && p.idAsRecipient == nil {
|
||||
return p.fatalf("recipient-v1 not supported")
|
||||
}
|
||||
|
||||
var recipientStrings, identityStrings []string
|
||||
var fileKeys [][]byte
|
||||
var supportsLabels bool
|
||||
|
||||
p.sr = format.NewStanzaReader(bufio.NewReader(p.stdin))
|
||||
ReadLoop:
|
||||
for {
|
||||
s, err := p.sr.ReadStanza()
|
||||
if err != nil {
|
||||
return p.fatalf("failed to read stanza: %v", err)
|
||||
}
|
||||
|
||||
switch s.Type {
|
||||
case "add-recipient":
|
||||
if err := expectStanzaWithNoBody(s, 1); err != nil {
|
||||
return p.fatalf("%v", err)
|
||||
}
|
||||
recipientStrings = append(recipientStrings, s.Args[0])
|
||||
case "add-identity":
|
||||
if err := expectStanzaWithNoBody(s, 1); err != nil {
|
||||
return p.fatalf("%v", err)
|
||||
}
|
||||
identityStrings = append(identityStrings, s.Args[0])
|
||||
case "extension-labels":
|
||||
if err := expectStanzaWithNoBody(s, 0); err != nil {
|
||||
return p.fatalf("%v", err)
|
||||
}
|
||||
supportsLabels = true
|
||||
case "wrap-file-key":
|
||||
if err := expectStanzaWithBody(s, 0); err != nil {
|
||||
return p.fatalf("%v", err)
|
||||
}
|
||||
fileKeys = append(fileKeys, s.Body)
|
||||
case "done":
|
||||
if err := expectStanzaWithNoBody(s, 0); err != nil {
|
||||
return p.fatalf("%v", err)
|
||||
}
|
||||
break ReadLoop
|
||||
default:
|
||||
// Unsupported stanzas in uni-directional phases are ignored.
|
||||
}
|
||||
}
|
||||
|
||||
if len(recipientStrings)+len(identityStrings) == 0 {
|
||||
return p.fatalf("no recipients or identities provided")
|
||||
}
|
||||
if len(fileKeys) == 0 {
|
||||
return p.fatalf("no file keys provided")
|
||||
}
|
||||
|
||||
var recipients, identities []age.Recipient
|
||||
for i, s := range recipientStrings {
|
||||
name, data, err := ParseRecipient(s)
|
||||
if err != nil {
|
||||
return p.recipientError(i, err)
|
||||
}
|
||||
if name != p.name {
|
||||
return p.recipientError(i, fmt.Errorf("unsupported plugin name: %q", name))
|
||||
}
|
||||
if p.recipient == nil {
|
||||
return p.recipientError(i, fmt.Errorf("recipient encodings not supported"))
|
||||
}
|
||||
r, err := p.recipient(data)
|
||||
if err != nil {
|
||||
return p.recipientError(i, err)
|
||||
}
|
||||
recipients = append(recipients, r)
|
||||
}
|
||||
for i, s := range identityStrings {
|
||||
name, data, err := ParseIdentity(s)
|
||||
if err != nil {
|
||||
return p.identityError(i, err)
|
||||
}
|
||||
if name != p.name {
|
||||
return p.identityError(i, fmt.Errorf("unsupported plugin name: %q", name))
|
||||
}
|
||||
if p.idAsRecipient == nil {
|
||||
return p.identityError(i, fmt.Errorf("identity encodings not supported"))
|
||||
}
|
||||
r, err := p.idAsRecipient(data)
|
||||
if err != nil {
|
||||
return p.identityError(i, err)
|
||||
}
|
||||
identities = append(identities, r)
|
||||
}
|
||||
|
||||
// Technically labels should be per-file key, but the client-side protocol
|
||||
// extension shipped like this, and it doesn't feel worth making a v2.
|
||||
var labels []string
|
||||
|
||||
stanzas := make([][]*age.Stanza, len(fileKeys))
|
||||
for i, fk := range fileKeys {
|
||||
for j, r := range recipients {
|
||||
ss, ll, err := wrapWithLabels(r, fk)
|
||||
if p.broken {
|
||||
return 2
|
||||
} else if err != nil {
|
||||
return p.recipientError(j, err)
|
||||
}
|
||||
if i == 0 && j == 0 {
|
||||
labels = ll
|
||||
} else if err := checkLabels(ll, labels); err != nil {
|
||||
return p.recipientError(j, err)
|
||||
}
|
||||
stanzas[i] = append(stanzas[i], ss...)
|
||||
}
|
||||
for j, r := range identities {
|
||||
ss, ll, err := wrapWithLabels(r, fk)
|
||||
if p.broken {
|
||||
return 2
|
||||
} else if err != nil {
|
||||
return p.identityError(j, err)
|
||||
}
|
||||
if i == 0 && j == 0 && len(recipients) == 0 {
|
||||
labels = ll
|
||||
} else if err := checkLabels(ll, labels); err != nil {
|
||||
return p.identityError(j, err)
|
||||
}
|
||||
stanzas[i] = append(stanzas[i], ss...)
|
||||
}
|
||||
}
|
||||
|
||||
if sent, err := writeGrease(p.stdout); err != nil {
|
||||
return p.fatalf("failed to write grease: %v", err)
|
||||
} else if sent {
|
||||
if err := expectUnsupported(p.sr); err != nil {
|
||||
return p.fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if supportsLabels {
|
||||
if err := writeStanza(p.stdout, "labels", labels...); err != nil {
|
||||
return p.fatalf("failed to write labels stanza: %v", err)
|
||||
}
|
||||
if err := expectOk(p.sr); err != nil {
|
||||
return p.fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for i, ss := range stanzas {
|
||||
for _, s := range ss {
|
||||
if err := (&format.Stanza{Type: "recipient-stanza",
|
||||
Args: append([]string{fmt.Sprint(i), s.Type}, s.Args...),
|
||||
Body: s.Body}).Marshal(p.stdout); err != nil {
|
||||
return p.fatalf("failed to write recipient-stanza: %v", err)
|
||||
}
|
||||
if err := expectOk(p.sr); err != nil {
|
||||
return p.fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
if sent, err := writeGrease(p.stdout); err != nil {
|
||||
return p.fatalf("failed to write grease: %v", err)
|
||||
} else if sent {
|
||||
if err := expectUnsupported(p.sr); err != nil {
|
||||
return p.fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeStanza(p.stdout, "done"); err != nil {
|
||||
return p.fatalf("failed to write done stanza: %v", err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func wrapWithLabels(r age.Recipient, fileKey []byte) ([]*age.Stanza, []string, error) {
|
||||
if r, ok := r.(age.RecipientWithLabels); ok {
|
||||
return r.WrapWithLabels(fileKey)
|
||||
}
|
||||
s, err := r.Wrap(fileKey)
|
||||
return s, nil, err
|
||||
}
|
||||
|
||||
func checkLabels(ll, labels []string) error {
|
||||
if !slicesEqual(ll, labels) {
|
||||
return fmt.Errorf("labels %q do not match previous recipients %q", ll, labels)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IdentityV1 implements the identity-v1 state machine. It returns an exit code
|
||||
// to pass to os.Exit.
|
||||
//
|
||||
// Most plugins should call [Plugin.Main] instead of this method.
|
||||
func (p *Plugin) IdentityV1() int {
|
||||
if p.identity == nil {
|
||||
return p.fatalf("identity-v1 not supported")
|
||||
}
|
||||
|
||||
var files [][]*age.Stanza
|
||||
var identityStrings []string
|
||||
|
||||
p.sr = format.NewStanzaReader(bufio.NewReader(p.stdin))
|
||||
ReadLoop:
|
||||
for {
|
||||
s, err := p.sr.ReadStanza()
|
||||
if err != nil {
|
||||
return p.fatalf("failed to read stanza: %v", err)
|
||||
}
|
||||
|
||||
switch s.Type {
|
||||
case "add-identity":
|
||||
if err := expectStanzaWithNoBody(s, 1); err != nil {
|
||||
return p.fatalf("%v", err)
|
||||
}
|
||||
identityStrings = append(identityStrings, s.Args[0])
|
||||
case "recipient-stanza":
|
||||
if len(s.Args) < 2 {
|
||||
return p.fatalf("recipient-stanza stanza has %d arguments, want >=2", len(s.Args))
|
||||
}
|
||||
i, err := strconv.Atoi(s.Args[0])
|
||||
if err != nil {
|
||||
return p.fatalf("failed to parse recipient-stanza stanza argument: %v", err)
|
||||
}
|
||||
ss := &age.Stanza{Type: s.Args[1], Args: s.Args[2:], Body: s.Body}
|
||||
switch i {
|
||||
case len(files):
|
||||
files = append(files, []*age.Stanza{ss})
|
||||
case len(files) - 1:
|
||||
files[len(files)-1] = append(files[len(files)-1], ss)
|
||||
default:
|
||||
return p.fatalf("unexpected file index %d, previous was %d", i, len(files)-1)
|
||||
}
|
||||
case "done":
|
||||
if err := expectStanzaWithNoBody(s, 0); err != nil {
|
||||
return p.fatalf("%v", err)
|
||||
}
|
||||
break ReadLoop
|
||||
default:
|
||||
// Unsupported stanzas in uni-directional phases are ignored.
|
||||
}
|
||||
}
|
||||
|
||||
if len(identityStrings) == 0 {
|
||||
return p.fatalf("no identities provided")
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return p.fatalf("no stanzas provided")
|
||||
}
|
||||
|
||||
var identities []age.Identity
|
||||
for i, s := range identityStrings {
|
||||
name, data, err := ParseIdentity(s)
|
||||
if err != nil {
|
||||
return p.identityError(i, err)
|
||||
}
|
||||
if name != p.name {
|
||||
return p.identityError(i, fmt.Errorf("unsupported plugin name: %q", name))
|
||||
}
|
||||
if p.identity == nil {
|
||||
return p.identityError(i, fmt.Errorf("identity encodings not supported"))
|
||||
}
|
||||
r, err := p.identity(data)
|
||||
if err != nil {
|
||||
return p.identityError(i, err)
|
||||
}
|
||||
identities = append(identities, r)
|
||||
}
|
||||
|
||||
for i, ss := range files {
|
||||
if sent, err := writeGrease(p.stdout); err != nil {
|
||||
return p.fatalf("failed to write grease: %v", err)
|
||||
} else if sent {
|
||||
if err := expectUnsupported(p.sr); err != nil {
|
||||
return p.fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: there should be a mechanism to let the plugin decide the order
|
||||
// in which identities are tried.
|
||||
for _, id := range identities {
|
||||
fk, err := id.Unwrap(ss)
|
||||
if p.broken {
|
||||
return 2
|
||||
} else if errors.Is(err, age.ErrIncorrectIdentity) {
|
||||
continue
|
||||
} else if err != nil {
|
||||
if err := p.writeError([]string{"stanza", fmt.Sprint(i), "0"}, err); err != nil {
|
||||
return p.fatalf("%v", err)
|
||||
}
|
||||
// Note that we don't exit here, as the protocol allows
|
||||
// continuing with other files.
|
||||
break
|
||||
}
|
||||
|
||||
s := &format.Stanza{Type: "file-key", Args: []string{fmt.Sprint(i)}, Body: fk}
|
||||
if err := s.Marshal(p.stdout); err != nil {
|
||||
return p.fatalf("failed to write file-key: %v", err)
|
||||
}
|
||||
if err := expectOk(p.sr); err != nil {
|
||||
return p.fatalf("%v", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeStanza(p.stdout, "done"); err != nil {
|
||||
return p.fatalf("failed to write done stanza: %v", err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// DisplayMessage requests that the client display a message to the user. The
|
||||
// message should start with a lowercase letter and have no final period.
|
||||
// DisplayMessage returns an error if the client can't display the message, and
|
||||
// may return before the message has been displayed to the user.
|
||||
//
|
||||
// It must only be called by a Wrap or Unwrap method invoked by [Plugin.Main].
|
||||
func (p *Plugin) DisplayMessage(message string) error {
|
||||
if err := writeStanzaWithBody(p.stdout, "msg", []byte(message)); err != nil {
|
||||
return p.fatalInteractf("failed to write msg stanza: %v", err)
|
||||
}
|
||||
s, err := readOkOrFail(p.sr)
|
||||
if err != nil {
|
||||
return p.fatalInteractf("%v", err)
|
||||
}
|
||||
if s.Type == "fail" {
|
||||
return fmt.Errorf("client failed to display message")
|
||||
}
|
||||
if err := expectStanzaWithNoBody(s, 0); err != nil {
|
||||
return p.fatalInteractf("%v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequestValue requests a secret or public input from the user through the
|
||||
// client, with the provided prompt. It returns an error if the client can't
|
||||
// request the input or if the user dismisses the prompt.
|
||||
//
|
||||
// It must only be called by a Wrap or Unwrap method invoked by [Plugin.Main].
|
||||
func (p *Plugin) RequestValue(prompt string, secret bool) (string, error) {
|
||||
t := "request-public"
|
||||
if secret {
|
||||
t = "request-secret"
|
||||
}
|
||||
if err := writeStanzaWithBody(p.stdout, t, []byte(prompt)); err != nil {
|
||||
return "", p.fatalInteractf("failed to write stanza: %v", err)
|
||||
}
|
||||
s, err := readOkOrFail(p.sr)
|
||||
if err != nil {
|
||||
return "", p.fatalInteractf("%v", err)
|
||||
}
|
||||
if s.Type == "fail" {
|
||||
return "", fmt.Errorf("client failed to request value")
|
||||
}
|
||||
if err := expectStanzaWithBody(s, 0); err != nil {
|
||||
return "", p.fatalInteractf("%v", err)
|
||||
}
|
||||
return string(s.Body), nil
|
||||
}
|
||||
|
||||
// Confirm requests a confirmation from the user through the client, with the
|
||||
// provided prompt. The yes and no value are the choices provided to the user.
|
||||
// no may be empty. The return value choseYes indicates whether the user
|
||||
// selected the yes or no option. Confirm returns an error if the client can't
|
||||
// request the confirmation.
|
||||
//
|
||||
// It must only be called by a Wrap or Unwrap method invoked by [Plugin.Main].
|
||||
func (p *Plugin) Confirm(prompt, yes, no string) (choseYes bool, err error) {
|
||||
args := []string{format.EncodeToString([]byte(yes))}
|
||||
if no != "" {
|
||||
args = append(args, format.EncodeToString([]byte(no)))
|
||||
}
|
||||
s := &format.Stanza{Type: "confirm", Args: args, Body: []byte(prompt)}
|
||||
if err := s.Marshal(p.stdout); err != nil {
|
||||
return false, p.fatalInteractf("failed to write confirm stanza: %v", err)
|
||||
}
|
||||
s, err = readOkOrFail(p.sr)
|
||||
if err != nil {
|
||||
return false, p.fatalInteractf("%v", err)
|
||||
}
|
||||
if s.Type == "fail" {
|
||||
return false, fmt.Errorf("client failed to request confirmation")
|
||||
}
|
||||
if err := expectStanzaWithNoBody(s, 1); err != nil {
|
||||
return false, p.fatalInteractf("%v", err)
|
||||
}
|
||||
return s.Args[0] == "yes", nil
|
||||
}
|
||||
|
||||
// fatalInteractf prints the error to stderr and sets the broken flag, so the
|
||||
// Wrap/Unwrap caller can exit with an error.
|
||||
func (p *Plugin) fatalInteractf(format string, args ...any) error {
|
||||
p.broken = true
|
||||
fmt.Fprintf(p.stderr, format, args...)
|
||||
return fmt.Errorf(format, args...)
|
||||
}
|
||||
|
||||
func (p *Plugin) fatalf(format string, args ...any) int {
|
||||
fmt.Fprintf(p.stderr, format, args...)
|
||||
return 1
|
||||
}
|
||||
|
||||
func expectStanzaWithNoBody(s *format.Stanza, wantArgs int) error {
|
||||
if len(s.Args) != wantArgs {
|
||||
return fmt.Errorf("%s stanza has %d arguments, want %d", s.Type, len(s.Args), wantArgs)
|
||||
}
|
||||
if len(s.Body) != 0 {
|
||||
return fmt.Errorf("%s stanza has %d bytes of body, want 0", s.Type, len(s.Body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func expectStanzaWithBody(s *format.Stanza, wantArgs int) error {
|
||||
if len(s.Args) != wantArgs {
|
||||
return fmt.Errorf("%s stanza has %d arguments, want %d", s.Type, len(s.Args), wantArgs)
|
||||
}
|
||||
if len(s.Body) == 0 {
|
||||
return fmt.Errorf("%s stanza has 0 bytes of body, want >0", s.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) recipientError(idx int, err error) int {
|
||||
if err := p.writeError([]string{"recipient", fmt.Sprint(idx)}, err); err != nil {
|
||||
return p.fatalf("%v", err)
|
||||
}
|
||||
return 3
|
||||
}
|
||||
|
||||
func (p *Plugin) identityError(idx int, err error) int {
|
||||
if err := p.writeError([]string{"identity", fmt.Sprint(idx)}, err); err != nil {
|
||||
return p.fatalf("%v", err)
|
||||
}
|
||||
return 3
|
||||
}
|
||||
|
||||
func expectOk(sr *format.StanzaReader) error {
|
||||
ok, err := sr.ReadStanza()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read OK stanza: %v", err)
|
||||
}
|
||||
if ok.Type != "ok" {
|
||||
return fmt.Errorf("expected OK stanza, got %q", ok.Type)
|
||||
}
|
||||
return expectStanzaWithNoBody(ok, 0)
|
||||
}
|
||||
|
||||
func readOkOrFail(sr *format.StanzaReader) (*format.Stanza, error) {
|
||||
s, err := sr.ReadStanza()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response stanza: %v", err)
|
||||
}
|
||||
switch s.Type {
|
||||
case "fail":
|
||||
if err := expectStanzaWithNoBody(s, 0); err != nil {
|
||||
return nil, fmt.Errorf("%v", err)
|
||||
}
|
||||
return s, nil
|
||||
case "ok":
|
||||
return s, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("expected ok or fail stanza, got %q", s.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func expectUnsupported(sr *format.StanzaReader) error {
|
||||
unsupported, err := sr.ReadStanza()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read unsupported stanza: %v", err)
|
||||
}
|
||||
if unsupported.Type != "unsupported" {
|
||||
return fmt.Errorf("expected unsupported stanza, got %q", unsupported.Type)
|
||||
}
|
||||
return expectStanzaWithNoBody(unsupported, 0)
|
||||
}
|
||||
|
||||
func (p *Plugin) writeError(args []string, err error) error {
|
||||
s := &format.Stanza{Type: "error", Args: args}
|
||||
s.Body = []byte(err.Error())
|
||||
if err := s.Marshal(p.stdout); err != nil {
|
||||
return fmt.Errorf("failed to write error stanza: %v", err)
|
||||
}
|
||||
if err := expectOk(p.sr); err != nil {
|
||||
return fmt.Errorf("%v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func slicesEqual(s1, s2 []string) bool {
|
||||
if len(s1) != len(s2) {
|
||||
return false
|
||||
}
|
||||
for i := range s1 {
|
||||
if s1[i] != s2[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
78
plugin/tui.go
Normal file
78
plugin/tui.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"filippo.io/age/internal/term"
|
||||
)
|
||||
|
||||
// NewTerminalUI returns a [ClientUI] that uses the terminal to request inputs,
|
||||
// and the provided functions to display messages and errors.
|
||||
//
|
||||
// The terminal is reached directly through /dev/tty or CONIN$/CONOUT$,
|
||||
// bypassing standard input and output, so this UI can be used even when
|
||||
// standard input or output are redirected.
|
||||
func NewTerminalUI(printf, warningf func(format string, v ...any)) *ClientUI {
|
||||
return &ClientUI{
|
||||
DisplayMessage: func(name, message string) error {
|
||||
printf("%s plugin: %s", name, message)
|
||||
return nil
|
||||
},
|
||||
RequestValue: func(name, message string, isSecret bool) (s string, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
warningf("could not read value for age-plugin-%s: %v", name, err)
|
||||
}
|
||||
}()
|
||||
if isSecret {
|
||||
secret, err := term.ReadSecret(message)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(secret), nil
|
||||
} else {
|
||||
public, err := term.ReadPublic(message)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(public), nil
|
||||
}
|
||||
},
|
||||
Confirm: func(name, message, yes, no string) (choseYes bool, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
warningf("could not read value for age-plugin-%s: %v", name, err)
|
||||
}
|
||||
}()
|
||||
if no == "" {
|
||||
message += fmt.Sprintf(" (press enter for %q)", yes)
|
||||
_, err := term.ReadSecret(message)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
message += fmt.Sprintf(" (press [1] for %q or [2] for %q)", yes, no)
|
||||
for {
|
||||
selection, err := term.ReadCharacter(message)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch selection {
|
||||
case '1':
|
||||
return true, nil
|
||||
case '2':
|
||||
return false, nil
|
||||
case '\x03': // CTRL-C
|
||||
return false, errors.New("user cancelled prompt")
|
||||
default:
|
||||
warningf("reading value for age-plugin-%s: invalid selection %q", name, selection)
|
||||
}
|
||||
}
|
||||
},
|
||||
WaitTimer: func(name string) {
|
||||
printf("waiting on %s plugin...", name)
|
||||
},
|
||||
}
|
||||
}
|
||||
181
pq.go
Normal file
181
pq.go
Normal file
@@ -0,0 +1,181 @@
|
||||
// Copyright 2025 The age Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package age
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"filippo.io/age/internal/bech32"
|
||||
"filippo.io/age/internal/format"
|
||||
"filippo.io/hpke"
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
)
|
||||
|
||||
const pqLabel = "age-encryption.org/mlkem768x25519"
|
||||
|
||||
// HybridRecipient is the standard age public key. Messages encrypted to
|
||||
// this recipient can be decrypted with the corresponding [HybridIdentity].
|
||||
//
|
||||
// This recipient is safe against future cryptographically-relevant quantum
|
||||
// computers, and can only be used along with other post-quantum recipients.
|
||||
//
|
||||
// This recipient is anonymous, in the sense that an attacker can't tell from
|
||||
// the message alone if it is encrypted to a certain recipient.
|
||||
type HybridRecipient struct {
|
||||
pk hpke.PublicKey
|
||||
}
|
||||
|
||||
var _ Recipient = &HybridRecipient{}
|
||||
|
||||
// newHybridRecipient returns a new [HybridRecipient] from a raw HPKE public key.
|
||||
func newHybridRecipient(publicKey []byte) (*HybridRecipient, error) {
|
||||
pk, err := hpke.MLKEM768X25519().NewPublicKey(publicKey)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid MLKEM768-X25519 public key")
|
||||
}
|
||||
return &HybridRecipient{pk: pk}, nil
|
||||
}
|
||||
|
||||
// ParseHybridRecipient returns a new [HybridRecipient] from a Bech32 public key
|
||||
// encoding with the "age1pq1" prefix.
|
||||
func ParseHybridRecipient(s string) (*HybridRecipient, error) {
|
||||
t, k, err := bech32.Decode(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("malformed recipient %q: %v", s, err)
|
||||
}
|
||||
if t != "age1pq" {
|
||||
return nil, fmt.Errorf("malformed recipient %q: invalid type %q", s, t)
|
||||
}
|
||||
r, err := newHybridRecipient(k)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("malformed recipient %q: %v", s, err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *HybridRecipient) Wrap(fileKey []byte) ([]*Stanza, error) {
|
||||
s, _, err := r.WrapWithLabels(fileKey)
|
||||
return s, err
|
||||
}
|
||||
|
||||
// WrapWithLabels implements [RecipientWithLabels], returning a single
|
||||
// "postquantum" label. This ensures a HybridRecipient can't be mixed with other
|
||||
// recipients that would defeat its post-quantum security.
|
||||
//
|
||||
// To unsafely bypass this restriction, wrap HybridRecipient in a [Recipient]
|
||||
// type that doesn't expose WrapWithLabels.
|
||||
func (r *HybridRecipient) WrapWithLabels(fileKey []byte) ([]*Stanza, []string, error) {
|
||||
enc, s, err := hpke.NewSender(r.pk, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte(pqLabel))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to set up HPKE sender: %v", err)
|
||||
}
|
||||
ct, err := s.Seal(nil, fileKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to encrypt file key: %v", err)
|
||||
}
|
||||
|
||||
l := &Stanza{
|
||||
Type: "mlkem768x25519",
|
||||
Args: []string{format.EncodeToString(enc)},
|
||||
Body: ct,
|
||||
}
|
||||
|
||||
return []*Stanza{l}, []string{"postquantum"}, nil
|
||||
}
|
||||
|
||||
// String returns the Bech32 public key encoding of r.
|
||||
func (r *HybridRecipient) String() string {
|
||||
s, _ := bech32.Encode("age1pq", r.pk.Bytes())
|
||||
return s
|
||||
}
|
||||
|
||||
// HybridIdentity is the standard age private key, which can decrypt messages
|
||||
// encrypted to the corresponding [HybridRecipient].
|
||||
type HybridIdentity struct {
|
||||
k hpke.PrivateKey
|
||||
}
|
||||
|
||||
var _ Identity = &HybridIdentity{}
|
||||
|
||||
// newHybridIdentity returns a new [HybridIdentity] from a raw HPKE private key.
|
||||
func newHybridIdentity(secretKey []byte) (*HybridIdentity, error) {
|
||||
k, err := hpke.MLKEM768X25519().NewPrivateKey(secretKey)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid MLKEM768-X25519 secret key")
|
||||
}
|
||||
return &HybridIdentity{k: k}, nil
|
||||
}
|
||||
|
||||
// GenerateHybridIdentity randomly generates a new [HybridIdentity].
|
||||
func GenerateHybridIdentity() (*HybridIdentity, error) {
|
||||
k, err := hpke.MLKEM768X25519().GenerateKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate post-quantum identity: %v", err)
|
||||
}
|
||||
return &HybridIdentity{k: k}, nil
|
||||
}
|
||||
|
||||
// ParseHybridIdentity returns a new [HybridIdentity] from a Bech32 private key
|
||||
// encoding with the "AGE-SECRET-KEY-PQ-1" prefix.
|
||||
func ParseHybridIdentity(s string) (*HybridIdentity, error) {
|
||||
t, k, err := bech32.Decode(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("malformed secret key: %v", err)
|
||||
}
|
||||
if t != "AGE-SECRET-KEY-PQ-" {
|
||||
return nil, fmt.Errorf("malformed secret key: unknown type %q", t)
|
||||
}
|
||||
r, err := newHybridIdentity(k)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("malformed secret key: %v", err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (i *HybridIdentity) Unwrap(stanzas []*Stanza) ([]byte, error) {
|
||||
return multiUnwrap(i.unwrap, stanzas)
|
||||
}
|
||||
|
||||
func (i *HybridIdentity) unwrap(block *Stanza) ([]byte, error) {
|
||||
if block.Type != "mlkem768x25519" {
|
||||
return nil, ErrIncorrectIdentity
|
||||
}
|
||||
if len(block.Args) != 1 {
|
||||
return nil, errors.New("invalid mlkem768x25519 recipient block")
|
||||
}
|
||||
enc, err := format.DecodeString(block.Args[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse mlkem768x25519 recipient: %v", err)
|
||||
}
|
||||
if len(block.Body) != fileKeySize+chacha20poly1305.Overhead {
|
||||
return nil, errIncorrectCiphertextSize
|
||||
}
|
||||
|
||||
r, err := hpke.NewRecipient(enc, i.k, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte(pqLabel))
|
||||
if err != nil {
|
||||
// MLKEM768-X25519 does implicit rejection, so a mismatched key does not
|
||||
// hit this error path, but is only detected later when trying to open.
|
||||
return nil, fmt.Errorf("invalid mlkem768x25519 recipient: %v", err)
|
||||
}
|
||||
fileKey, err := r.Open(nil, block.Body)
|
||||
if err != nil {
|
||||
return nil, ErrIncorrectIdentity
|
||||
}
|
||||
return fileKey, nil
|
||||
}
|
||||
|
||||
// Recipient returns the public [HybridRecipient] value corresponding to i.
|
||||
func (i *HybridIdentity) Recipient() *HybridRecipient {
|
||||
return &HybridRecipient{pk: i.k.PublicKey()}
|
||||
}
|
||||
|
||||
// String returns the Bech32 private key encoding of i.
|
||||
func (i *HybridIdentity) String() string {
|
||||
b, _ := i.k.Bytes()
|
||||
s, _ := bech32.Encode("AGE-SECRET-KEY-PQ-", b)
|
||||
return strings.ToUpper(s)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ package age_test
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
@@ -49,6 +50,67 @@ func TestX25519RoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHybridRoundTrip(t *testing.T) {
|
||||
i, err := age.GenerateHybridIdentity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := i.Recipient()
|
||||
|
||||
if r1, err := age.ParseHybridRecipient(r.String()); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if r1.String() != r.String() {
|
||||
t.Errorf("recipient did not round-trip through parsing: got %q, want %q", r1, r)
|
||||
}
|
||||
if i1, err := age.ParseHybridIdentity(i.String()); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if i1.String() != i.String() {
|
||||
t.Errorf("identity did not round-trip through parsing: got %q, want %q", i1, i)
|
||||
}
|
||||
|
||||
fileKey := make([]byte, 16)
|
||||
if _, err := rand.Read(fileKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stanzas, err := r.Wrap(fileKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out, err := i.Unwrap(stanzas)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(fileKey, out) {
|
||||
t.Errorf("invalid output: %x, expected %x", out, fileKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHybridMixingRestrictions(t *testing.T) {
|
||||
x25519, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hybrid, err := age.GenerateHybridIdentity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Hybrid recipients can be used together.
|
||||
if _, err := age.Encrypt(io.Discard, hybrid.Recipient(), hybrid.Recipient()); err != nil {
|
||||
t.Errorf("expected two hybrid recipients to work, got %v", err)
|
||||
}
|
||||
|
||||
// Hybrid and X25519 recipients cannot be mixed.
|
||||
if _, err := age.Encrypt(io.Discard, hybrid.Recipient(), x25519.Recipient()); err == nil {
|
||||
t.Error("expected hybrid mixed with X25519 to fail")
|
||||
}
|
||||
if _, err := age.Encrypt(io.Discard, x25519.Recipient(), hybrid.Recipient()); err == nil {
|
||||
t.Error("expected X25519 mixed with hybrid to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScryptRoundTrip(t *testing.T) {
|
||||
password := "twitch.tv/filosottile"
|
||||
|
||||
|
||||
16
scrypt.go
16
scrypt.go
@@ -27,7 +27,7 @@ const scryptLabel = "age-encryption.org/v1/scrypt"
|
||||
// for the same file.
|
||||
//
|
||||
// Its use is not recommended for automated systems, which should prefer
|
||||
// X25519Recipient.
|
||||
// [HybridRecipient] or [X25519Recipient].
|
||||
type ScryptRecipient struct {
|
||||
password []byte
|
||||
workFactor int
|
||||
@@ -150,14 +150,20 @@ func (i *ScryptIdentity) Unwrap(stanzas []*Stanza) ([]byte, error) {
|
||||
return nil, errors.New("an scrypt recipient must be the only one")
|
||||
}
|
||||
}
|
||||
return multiUnwrap(i.unwrap, stanzas)
|
||||
for _, s := range stanzas {
|
||||
if s.Type != "scrypt" {
|
||||
continue
|
||||
}
|
||||
return i.unwrap(s)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: file is not passphrase-encrypted", ErrIncorrectIdentity)
|
||||
}
|
||||
|
||||
var digitsRe = regexp.MustCompile(`^[1-9][0-9]*$`)
|
||||
|
||||
func (i *ScryptIdentity) unwrap(block *Stanza) ([]byte, error) {
|
||||
if block.Type != "scrypt" {
|
||||
return nil, ErrIncorrectIdentity
|
||||
return nil, errors.New("internal error: unwrap called on non-scrypt stanza")
|
||||
}
|
||||
if len(block.Args) != 2 {
|
||||
return nil, errors.New("invalid scrypt recipient block")
|
||||
@@ -200,7 +206,9 @@ func (i *ScryptIdentity) unwrap(block *Stanza) ([]byte, error) {
|
||||
if err == errIncorrectCiphertextSize {
|
||||
return nil, errors.New("invalid scrypt recipient block: incorrect file key size")
|
||||
} else if err != nil {
|
||||
return nil, ErrIncorrectIdentity
|
||||
// Wrap [ErrIncorrectIdentity] so that multiple passphrases can be tried
|
||||
// in sequence by passing multiple [ScryptIdentity] values to [Decrypt].
|
||||
return nil, fmt.Errorf("%w: incorrect passphrase", ErrIncorrectIdentity)
|
||||
}
|
||||
return fileKey, nil
|
||||
}
|
||||
|
||||
61
tag/internal/age-plugin-tagtest/plugin-tagtest.go
Normal file
61
tag/internal/age-plugin-tagtest/plugin-tagtest.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Command age-plugin-tagtest is a that decrypts files encrypted to fixed
|
||||
// age1tag1... or age1tagpq1... recipients for testing purposes.
|
||||
//
|
||||
// It can be used with the "-j" flag:
|
||||
//
|
||||
// go install ./tag/internal/age-plugin-tagtest
|
||||
// age -d -j tagtest file.age
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"filippo.io/age"
|
||||
"filippo.io/age/plugin"
|
||||
"filippo.io/age/tag/internal/tagtest"
|
||||
)
|
||||
|
||||
const classicRecipient = "age1tag1qwe0kafsjrar4txm6heqnhpfuggzr0gvznz7fvygxrlq90u5mq2pysxtw6h"
|
||||
|
||||
const hybridRecipient = "age1tagpq14h4z7cks9sxftfc8tq4xektt4854ur9rv76tvujdvtzk2fmyywkvh9z2emz3x4epvhz7qdt2v7uksyyq2cdzf3k04ny0g5sc3u4heqh3r9v4cnwhfjw0a2azpgmnk9xk02wvywt5szcq6q3jvwsjxvkn3tsk52vqjczdcvc398ym4j6cvqas4w99gkgt7ur3fmt4g873phr23tgxw3f7wgsz9zxz7m8cp27vpq3h5vc8nssjemtr2etmtmqkg4fzn2u9x9zvtysuya5yrytgx482ftx9864h8a6pprarxd3d0qe8nw2at5ekg3tsahtef7kawasxjamyckw2ans6v933vuypcfrra32f89r2v72mka9hhc55s49xe2khfsq7w9r2zynuzfx4fg6v7jjncsc87rw2yy8qp8hr27edus6zw5xd6m3hax2nxhl2dys9792z3wp5c034sfkrxe86guj7pfdh7sytzrufl9euhuhyf9w6c7z2nwf7v5v8f2s4gplgvfx4jj4le22k2qn242qkqkcwx7llfyrct7jm2wcv0ytypeh6h93ezgtd7q6zr428qze3dec5jxlc5xxjetyephp42fljft3s02p0570kjwyfeyjcnks2vglkvyus5g9l4z6m0gf8wu22ygfm40028txwjlxvvgnn7c36z783c6tmc9k5nef8nucj6u3ustff5vhtnzhnscsvsz79wzrkv3sujtntx4wezucy6lp49flmnyydn3khk2xsesw0ekn4u44nzqw2g2rjyrrl7crshlzttgpe0jvqycjzp9kmtz23t3yu0w9j4n344nnrf88k2jqqfpjxte38pcn0epr879pqsuvajxrkmsas89pvfrzwcewneujn08guj5pvvrtn5hzzg2y6u4wwjqqxx4x8w65yc4dchf750dft8kgcttt2f6j0j5v8s7tkaua78tte7artdfar544vl0rau79h95mc4ghp887z82s6rq93txpkvan86n963kagqkldngnkjcn28zdrh38vdxj002zqs9mx7zjvg3ynzdfhfakkynt9fyqpaxpsdrsqrycuhw5ykwgjz6wldef7xtu6p689234hstxe7v8e5422f2dy8ystn57z3fvy9yfrm4t3lye6ejk5n6x8zqexmql7lx965xcxuuy38xzyt8j9qprnwgfqgx54l4tnjdpzdde6xgmwtnpkfwvyr7rkgnavvjn6a3e56wtvjx3evmhjjxvukpq5zqrj0s4sntkz3yeszs5dty8q0q6m7dgp6mjpvaer0c4343g72eycfqzkjupeaemh0n8e935hqs8fh3jgk7fzyxctzuqlx6d2q9jaf8r9wu4sjxj5w6ppw7m9c3hxrzpcv2uek3kxnndgf2hd99q9v2ux8pjkv29ntslvnvhy09dvcy9578rt89gf4cj4cu79zjxtlj3dpct6rjme02zj3qspsade96njkkufu9zuq2lk3qwvddpxjkqm2hnpqwck54zug7ctvkgvk325lwkg4q5rf73zkgys5e9y8jqc96ntdyl4r78lgtw4k5uljk5ttf46s3gc0rq0jwmddnxt875twwq92505zh3zkse5ag2dhjjxyfzkn7xv3j0kv9r3jzpvgep8fq6z8mar509u4fvnhvthp2ah0r45lsyq0mm6fwkcs30v8k9wzvgt6uvcty6qsjvarjs3htym69zu43m4jd3k4tllrr8c05v6p6spuhup4hkk2p9fp9lxafe3pntcn4nk83gzhjjpcjwyg7jcyz5uancu0fakgz27up7ymzp2xv3sqyqewkkqynskw9qkvysrncxj0cy7dt6q8dsseuwmc2urfmcvkykf82wfa54t85hqx8gywhmhzunm2x0d66a4pwl0xl78fhkces5dpq8pfnp35m5a3u8vdam64zx5s5x9cmnrx3zr066f4f8hlecqnq2fd5quw79ljg3q5nvs6ggmm4gkc"
|
||||
|
||||
func init() {
|
||||
c := tagtest.NewClassicIdentity("age-plugin-tagtest").Recipient().String()
|
||||
if c != classicRecipient {
|
||||
log.Fatalf("unexpected classic recipient: %s", c)
|
||||
}
|
||||
h := tagtest.NewHybridIdentity("age-plugin-tagtest").Recipient().String()
|
||||
if h != hybridRecipient {
|
||||
log.Fatalf("unexpected hybrid recipient: %s", h)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
p, err := plugin.New("tagtest")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
p.HandleIdentity(func(b []byte) (age.Identity, error) {
|
||||
if len(b) != 0 {
|
||||
return nil, fmt.Errorf("unexpected identity data")
|
||||
}
|
||||
return &tagtestIdentity{}, nil
|
||||
})
|
||||
os.Exit(p.Main())
|
||||
}
|
||||
|
||||
type tagtestIdentity struct{}
|
||||
|
||||
func (i *tagtestIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) {
|
||||
classic := tagtest.NewClassicIdentity("age-plugin-tagtest")
|
||||
if key, err := classic.Unwrap(ss); err == nil {
|
||||
return key, nil
|
||||
} else if !errors.Is(err, age.ErrIncorrectIdentity) {
|
||||
return nil, err
|
||||
}
|
||||
hybrid := tagtest.NewHybridIdentity("age-plugin-tagtest")
|
||||
return hybrid.Unwrap(ss)
|
||||
}
|
||||
152
tag/internal/tagtest/tagtest.go
Normal file
152
tag/internal/tagtest/tagtest.go
Normal file
@@ -0,0 +1,152 @@
|
||||
// Copyright 2025 The age Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package tagtest
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
|
||||
"filippo.io/age"
|
||||
"filippo.io/age/internal/format"
|
||||
"filippo.io/age/tag"
|
||||
"filippo.io/hpke"
|
||||
"filippo.io/nistec"
|
||||
)
|
||||
|
||||
type ClassicIdentity struct {
|
||||
k hpke.PrivateKey
|
||||
}
|
||||
|
||||
var _ age.Identity = &ClassicIdentity{}
|
||||
|
||||
func NewClassicIdentity(seed string) *ClassicIdentity {
|
||||
k, err := hpke.DHKEM(ecdh.P256()).DeriveKeyPair([]byte(seed))
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to generate key: %v", err))
|
||||
}
|
||||
return &ClassicIdentity{k: k}
|
||||
}
|
||||
|
||||
func (i *ClassicIdentity) Recipient() *tag.Recipient {
|
||||
uncompressed := i.k.PublicKey().Bytes()
|
||||
p, err := nistec.NewP256Point().SetBytes(uncompressed)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to parse public key: %v", err))
|
||||
}
|
||||
r, err := tag.NewClassicRecipient(p.BytesCompressed())
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to create recipient: %v", err))
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (i *ClassicIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) {
|
||||
for _, s := range ss {
|
||||
if s.Type != "p256tag" {
|
||||
continue
|
||||
}
|
||||
if len(s.Args) != 2 {
|
||||
return nil, fmt.Errorf("malformed stanza")
|
||||
}
|
||||
tagArg, err := format.DecodeString(s.Args[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("malformed tag: %v", err)
|
||||
}
|
||||
if len(tagArg) != 4 {
|
||||
return nil, fmt.Errorf("invalid tag length: %d", len(tagArg))
|
||||
}
|
||||
enc, err := format.DecodeString(s.Args[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("malformed encapsulated key: %v", err)
|
||||
}
|
||||
if len(enc) != 65 {
|
||||
return nil, fmt.Errorf("invalid encapsulated key length: %d", len(enc))
|
||||
}
|
||||
if len(s.Body) != 32 {
|
||||
return nil, fmt.Errorf("invalid encrypted file key length: %d", len(s.Body))
|
||||
}
|
||||
|
||||
expTag, err := i.Recipient().Tag(enc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compute tag: %v", err)
|
||||
}
|
||||
if subtle.ConstantTimeCompare(tagArg, expTag[:4]) != 1 {
|
||||
return nil, age.ErrIncorrectIdentity
|
||||
}
|
||||
|
||||
r, err := hpke.NewRecipient(enc, i.k, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte("age-encryption.org/p256tag"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unwrap file key: %v", err)
|
||||
}
|
||||
return r.Open(nil, s.Body)
|
||||
}
|
||||
return nil, age.ErrIncorrectIdentity
|
||||
}
|
||||
|
||||
type HybridIdentity struct {
|
||||
k hpke.PrivateKey
|
||||
}
|
||||
|
||||
var _ age.Identity = &HybridIdentity{}
|
||||
|
||||
func NewHybridIdentity(seed string) *HybridIdentity {
|
||||
k, err := hpke.MLKEM768P256().DeriveKeyPair([]byte(seed))
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to generate key: %v", err))
|
||||
}
|
||||
return &HybridIdentity{k: k}
|
||||
}
|
||||
|
||||
func (i *HybridIdentity) Recipient() *tag.Recipient {
|
||||
r, err := tag.NewHybridRecipient(i.k.PublicKey().Bytes())
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to create recipient: %v", err))
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (i *HybridIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) {
|
||||
for _, s := range ss {
|
||||
if s.Type != "mlkem768p256tag" {
|
||||
continue
|
||||
}
|
||||
if len(s.Args) != 2 {
|
||||
return nil, fmt.Errorf("malformed stanza")
|
||||
}
|
||||
tagArg, err := format.DecodeString(s.Args[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("malformed tag: %v", err)
|
||||
}
|
||||
if len(tagArg) != 4 {
|
||||
return nil, fmt.Errorf("invalid tag length: %d", len(tagArg))
|
||||
}
|
||||
enc, err := format.DecodeString(s.Args[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("malformed encapsulated key: %v", err)
|
||||
}
|
||||
if len(enc) != 1153 {
|
||||
return nil, fmt.Errorf("invalid encapsulated key length: %d", len(enc))
|
||||
}
|
||||
if len(s.Body) != 32 {
|
||||
return nil, fmt.Errorf("invalid encrypted file key length: %d", len(s.Body))
|
||||
}
|
||||
|
||||
expTag, err := i.Recipient().Tag(enc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compute tag: %v", err)
|
||||
}
|
||||
if subtle.ConstantTimeCompare(tagArg, expTag[:4]) != 1 {
|
||||
return nil, age.ErrIncorrectIdentity
|
||||
}
|
||||
|
||||
r, err := hpke.NewRecipient(enc, i.k, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte("age-encryption.org/mlkem768p256tag"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unwrap file key: %v", err)
|
||||
}
|
||||
return r.Open(nil, s.Body)
|
||||
}
|
||||
return nil, age.ErrIncorrectIdentity
|
||||
}
|
||||
189
tag/tag.go
Normal file
189
tag/tag.go
Normal file
@@ -0,0 +1,189 @@
|
||||
// Copyright 2025 The age Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package tag implements tagged P-256 or hybrid P-256 + ML-KEM-768 recipients,
|
||||
// which can be used with identities stored on hardware keys, usually supported
|
||||
// by dedicated plugins.
|
||||
//
|
||||
// The tag reduces privacy, by allowing an observer to correlate files with a
|
||||
// recipient (but not files amongst them without knowledge of the recipient),
|
||||
// but this is also a desirable property for hardware keys that require user
|
||||
// interaction for each decryption operation.
|
||||
package tag
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/hkdf"
|
||||
"crypto/mlkem"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"filippo.io/age"
|
||||
"filippo.io/age/internal/format"
|
||||
"filippo.io/age/plugin"
|
||||
"filippo.io/hpke"
|
||||
"filippo.io/nistec"
|
||||
)
|
||||
|
||||
// Recipient is a tagged P-256 or hybrid P-256 + ML-KEM-768 recipient.
|
||||
//
|
||||
// The latter recipient is safe against future cryptographically-relevant
|
||||
// quantum computers, and can only be used along with other post-quantum
|
||||
// recipients.
|
||||
type Recipient struct {
|
||||
pk hpke.PublicKey
|
||||
}
|
||||
|
||||
var _ age.Recipient = &Recipient{}
|
||||
|
||||
// ParseRecipient returns a new [Recipient] from a Bech32 public key
|
||||
// encoding with the "age1tag1" or "age1tagpq1" prefix.
|
||||
func ParseRecipient(s string) (*Recipient, error) {
|
||||
t, k, err := plugin.ParseRecipient(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("malformed recipient %q: %v", s, err)
|
||||
}
|
||||
switch t {
|
||||
case "tag":
|
||||
r, err := NewClassicRecipient(k)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("malformed recipient %q: %v", s, err)
|
||||
}
|
||||
return r, nil
|
||||
case "tagpq":
|
||||
r, err := NewHybridRecipient(k)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("malformed recipient %q: %v", s, err)
|
||||
}
|
||||
return r, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("malformed recipient %q: invalid type %q", s, t)
|
||||
}
|
||||
}
|
||||
|
||||
const compressedPointSize = 1 + 32
|
||||
const uncompressedPointSize = 1 + 32 + 32
|
||||
|
||||
// NewClassicRecipient returns a new P-256 [Recipient] from a raw public key.
|
||||
func NewClassicRecipient(publicKey []byte) (*Recipient, error) {
|
||||
if len(publicKey) != compressedPointSize {
|
||||
return nil, fmt.Errorf("invalid tag recipient public key size %d", len(publicKey))
|
||||
}
|
||||
p, err := nistec.NewP256Point().SetBytes(publicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid tag recipient public key: %v", err)
|
||||
}
|
||||
k, err := hpke.DHKEM(ecdh.P256()).NewPublicKey(p.Bytes())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid tag recipient public key: %v", err)
|
||||
}
|
||||
return &Recipient{k}, nil
|
||||
}
|
||||
|
||||
// NewHybridRecipient returns a new hybrid P-256 + ML-KEM-768 [Recipient] from
|
||||
// raw concatenated public keys.
|
||||
func NewHybridRecipient(publicKey []byte) (*Recipient, error) {
|
||||
k, err := hpke.MLKEM768P256().NewPublicKey(publicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid tagpq recipient public key: %v", err)
|
||||
}
|
||||
return &Recipient{k}, nil
|
||||
}
|
||||
|
||||
// Hybrid reports whether r is a hybrid P-256 + ML-KEM-768 recipient.
|
||||
func (r *Recipient) Hybrid() bool {
|
||||
return r.pk.KEM().ID() == hpke.MLKEM768P256().ID()
|
||||
}
|
||||
|
||||
func (r *Recipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
|
||||
s, _, err := r.WrapWithLabels(fileKey)
|
||||
return s, err
|
||||
}
|
||||
|
||||
// Tag computes the 4-byte tag for the given ciphertext enc.
|
||||
//
|
||||
// This is a low-level method exposed for use by plugins that implement
|
||||
// identities compatible with tagged recipients.
|
||||
func (r *Recipient) Tag(enc []byte) ([]byte, error) {
|
||||
label, tagRecipient := "age-encryption.org/p256tag", r.Bytes()
|
||||
if r.Hybrid() {
|
||||
label = "age-encryption.org/mlkem768p256tag"
|
||||
// In hybrid mode, the tag is computed over just the P-256 part.
|
||||
tagRecipient = tagRecipient[mlkem.EncapsulationKeySize768:]
|
||||
if len(enc) != mlkem.CiphertextSize768+uncompressedPointSize {
|
||||
return nil, fmt.Errorf("invalid ciphertext size")
|
||||
}
|
||||
} else if len(enc) != uncompressedPointSize {
|
||||
return nil, fmt.Errorf("invalid ciphertext size")
|
||||
}
|
||||
rh := sha256.Sum256(tagRecipient)
|
||||
tag, err := hkdf.Extract(sha256.New, append(slices.Clip(enc), rh[:4]...), []byte(label))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compute tag: %v", err)
|
||||
}
|
||||
return tag[:4], nil
|
||||
}
|
||||
|
||||
// WrapWithLabels implements [age.RecipientWithLabels], returning a single
|
||||
// "postquantum" label if r is a hybrid P-256 + ML-KEM-768 recipient. This
|
||||
// ensures a hybrid Recipient can't be mixed with other recipients that would
|
||||
// defeat its post-quantum security.
|
||||
//
|
||||
// To unsafely bypass this restriction, wrap Recipient in an [age.Recipient]
|
||||
// type that doesn't expose WrapWithLabels.
|
||||
func (r *Recipient) WrapWithLabels(fileKey []byte) ([]*age.Stanza, []string, error) {
|
||||
label, arg := "age-encryption.org/p256tag", "p256tag"
|
||||
if r.Hybrid() {
|
||||
label, arg = "age-encryption.org/mlkem768p256tag", "mlkem768p256tag"
|
||||
}
|
||||
|
||||
enc, s, err := hpke.NewSender(r.pk, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte(label))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to set up HPKE sender: %v", err)
|
||||
}
|
||||
ct, err := s.Seal(nil, fileKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to encrypt file key: %v", err)
|
||||
}
|
||||
|
||||
tag, err := r.Tag(enc)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to compute tag: %v", err)
|
||||
}
|
||||
|
||||
l := &age.Stanza{
|
||||
Type: arg,
|
||||
Args: []string{
|
||||
format.EncodeToString(tag[:4]),
|
||||
format.EncodeToString(enc),
|
||||
},
|
||||
Body: ct,
|
||||
}
|
||||
|
||||
if r.Hybrid() {
|
||||
return []*age.Stanza{l}, []string{"postquantum"}, nil
|
||||
}
|
||||
return []*age.Stanza{l}, nil, nil
|
||||
}
|
||||
|
||||
// Bytes returns the raw recipient encoding.
|
||||
func (r *Recipient) Bytes() []byte {
|
||||
if r.Hybrid() {
|
||||
return r.pk.Bytes()
|
||||
}
|
||||
p, err := nistec.NewP256Point().SetBytes(r.pk.Bytes())
|
||||
if err != nil {
|
||||
panic("internal error: invalid P-256 public key")
|
||||
}
|
||||
return p.BytesCompressed()
|
||||
}
|
||||
|
||||
// String returns the Bech32 public key encoding of r.
|
||||
func (r *Recipient) String() string {
|
||||
if r.Hybrid() {
|
||||
return plugin.EncodeRecipient("tagpq", r.Bytes())
|
||||
}
|
||||
return plugin.EncodeRecipient("tag", r.Bytes())
|
||||
}
|
||||
140
tag/tag_test.go
Normal file
140
tag/tag_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// Copyright 2025 The age Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package tag_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"filippo.io/age/tag"
|
||||
"filippo.io/age/tag/internal/tagtest"
|
||||
)
|
||||
|
||||
func TestClassicRoundTrip(t *testing.T) {
|
||||
i := tagtest.NewClassicIdentity("test")
|
||||
r := i.Recipient()
|
||||
|
||||
if r.Hybrid() {
|
||||
t.Error("classic recipient incorrectly reports as hybrid")
|
||||
}
|
||||
|
||||
r1, err := tag.ParseRecipient(r.String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r1.String() != r.String() {
|
||||
t.Errorf("recipient did not round-trip through parsing: got %q, want %q", r1.String(), r.String())
|
||||
}
|
||||
if r1.Hybrid() {
|
||||
t.Error("parsed classic recipient incorrectly reports as hybrid")
|
||||
}
|
||||
|
||||
plaintext := []byte("hello world")
|
||||
|
||||
encrypted := &bytes.Buffer{}
|
||||
w, err := age.Encrypt(encrypted, r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
decrypted, err := age.Decrypt(encrypted, i)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := io.ReadAll(decrypted)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(plaintext, out) {
|
||||
t.Errorf("invalid output: %q, expected %q", out, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHybridRoundTrip(t *testing.T) {
|
||||
i := tagtest.NewHybridIdentity("test")
|
||||
r := i.Recipient()
|
||||
|
||||
if !r.Hybrid() {
|
||||
t.Error("hybrid recipient incorrectly reports as classic")
|
||||
}
|
||||
|
||||
r1, err := tag.ParseRecipient(r.String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r1.String() != r.String() {
|
||||
t.Errorf("recipient did not round-trip through parsing: got %q, want %q", r1.String(), r.String())
|
||||
}
|
||||
if !r1.Hybrid() {
|
||||
t.Error("parsed hybrid recipient incorrectly reports as classic")
|
||||
}
|
||||
|
||||
plaintext := []byte("hello world")
|
||||
|
||||
encrypted := &bytes.Buffer{}
|
||||
w, err := age.Encrypt(encrypted, r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
decrypted, err := age.Decrypt(encrypted, i)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := io.ReadAll(decrypted)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(plaintext, out) {
|
||||
t.Errorf("invalid output: %q, expected %q", out, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagHybridMixingRestrictions(t *testing.T) {
|
||||
x25519, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tagHybrid := tagtest.NewHybridIdentity("test").Recipient()
|
||||
|
||||
// Hybrid tag recipients can be used together with hybrid recipients.
|
||||
hybrid, err := age.GenerateHybridIdentity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := age.Encrypt(io.Discard, tagHybrid, hybrid.Recipient()); err != nil {
|
||||
t.Errorf("expected hybrid tag + hybrid to work, got %v", err)
|
||||
}
|
||||
|
||||
// Hybrid tag and X25519 recipients cannot be mixed.
|
||||
if _, err := age.Encrypt(io.Discard, tagHybrid, x25519.Recipient()); err == nil {
|
||||
t.Error("expected hybrid tag mixed with X25519 to fail")
|
||||
}
|
||||
if _, err := age.Encrypt(io.Discard, x25519.Recipient(), tagHybrid); err == nil {
|
||||
t.Error("expected X25519 mixed with hybrid tag to fail")
|
||||
}
|
||||
|
||||
// Classic tag and X25519 recipients can be mixed (both are non-PQ).
|
||||
tagClassic := tagtest.NewClassicIdentity("test").Recipient()
|
||||
if _, err := age.Encrypt(io.Discard, tagClassic, x25519.Recipient()); err != nil {
|
||||
t.Errorf("expected classic tag + X25519 to work, got %v", err)
|
||||
}
|
||||
}
|
||||
6
testdata/example.zip.age
vendored
Normal file
6
testdata/example.zip.age
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
age-encryption.org/v1
|
||||
-> X25519 5CD81lZA72aQi0v6EnniOGkwaswpZ0AxCZNdiUVzP04
|
||||
ol9DvdkiZWeRI4vMKRBVNxowDKwir4UPqYinSM5zqUI
|
||||
--- 2tyNGCaPoT6UnuOy7sQJf1eXn4pb7z2ukSgTDIxrJxU
|
||||
W
|
||||
ï¡‚dtUb¸T ³¹0°ãÇ(àyKAP‚dr1‡M~ï ÄóÝkX>—ªËÌÑcÜ<63>[ƒ$à9»,
Gß{턚Fý‰ÂÁtkåÌ*}â9˜T®ÅÄÞ±®hLÐ<4C>çÙWƒ-Rdª˜üË£‡÷ÒH¿SdUà‹ <E280B9>¬˜’…ÄšFsÈìÀ2y+)æô]/ðÞ,k=Æ8(XŒ”íRÇAÖ×01RÃëY®Š»ƒ·ŽÌ •k4N§†6ÓÚt«v¬çÀb<62>êc„Þ_F0dêvÚx‹î
×ÛÏ$Xø~
/
|
||||
227
testkit_test.go
227
testkit_test.go
@@ -3,12 +3,12 @@
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
//go:build go1.18
|
||||
// +build go1.18
|
||||
|
||||
package age_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"filippo.io/age"
|
||||
"filippo.io/age/armor"
|
||||
"filippo.io/age/internal/format"
|
||||
"filippo.io/age/internal/inspect"
|
||||
"filippo.io/age/internal/stream"
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
"golang.org/x/crypto/hkdf"
|
||||
@@ -55,6 +56,7 @@ type vector struct {
|
||||
}
|
||||
|
||||
func parseVector(t *testing.T, test []byte) *vector {
|
||||
var z bool
|
||||
v := &vector{file: test}
|
||||
for {
|
||||
line, rest, ok := bytes.Cut(v.file, []byte("\n"))
|
||||
@@ -92,7 +94,11 @@ func parseVector(t *testing.T, test []byte) *vector {
|
||||
}
|
||||
v.fileKey = (*[16]byte)(h)
|
||||
case "identity":
|
||||
var i age.Identity
|
||||
i, err := age.ParseX25519Identity(value)
|
||||
if err != nil {
|
||||
i, err = age.ParseHybridIdentity(value)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -105,20 +111,45 @@ func parseVector(t *testing.T, test []byte) *vector {
|
||||
v.identities = append(v.identities, i)
|
||||
case "armored":
|
||||
v.armored = true
|
||||
case "compressed":
|
||||
if value != "zlib" {
|
||||
t.Fatal("invalid test file: unknown compression:", value)
|
||||
}
|
||||
z = true
|
||||
case "comment":
|
||||
t.Log(value)
|
||||
default:
|
||||
t.Fatal("invalid test file: unknown header key:", key)
|
||||
}
|
||||
}
|
||||
if z {
|
||||
r, err := zlib.NewReader(bytes.NewReader(v.file))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v.file = b
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func TestVectors(t *testing.T) {
|
||||
forEachVector(t, testVector)
|
||||
forEachVector(t, func(t *testing.T, v *vector) {
|
||||
var plaintext []byte
|
||||
t.Run("Decrypt", func(t *testing.T) { plaintext = testDecrypt(t, v) })
|
||||
t.Run("DecryptReaderAt", func(t *testing.T) { testDecryptReaderAt(t, v, plaintext) })
|
||||
t.Run("Inspect", func(t *testing.T) { testInspect(t, v, plaintext) })
|
||||
t.Run("RoundTrip", func(t *testing.T) { testVectorRoundTrip(t, v) })
|
||||
})
|
||||
}
|
||||
|
||||
func testVector(t *testing.T, v *vector) {
|
||||
func testDecrypt(t *testing.T, v *vector) []byte {
|
||||
var in io.Reader = bytes.NewReader(v.file)
|
||||
if v.armored {
|
||||
in = armor.NewReader(in)
|
||||
@@ -127,25 +158,25 @@ func testVector(t *testing.T, v *vector) {
|
||||
if err != nil && strings.HasSuffix(err.Error(), "bad header MAC") {
|
||||
if v.expect == "HMAC failure" {
|
||||
t.Log(err)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
t.Fatalf("expected %s, got HMAC error", v.expect)
|
||||
} else if e := new(armor.Error); errors.As(err, &e) {
|
||||
if v.expect == "armor failure" {
|
||||
t.Log(err)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
t.Fatalf("expected %s, got: %v", v.expect, err)
|
||||
} else if _, ok := err.(*age.NoIdentityMatchError); ok {
|
||||
if v.expect == "no match" {
|
||||
t.Log(err)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
t.Fatalf("expected %s, got: %v", v.expect, err)
|
||||
} else if err != nil {
|
||||
if v.expect == "header failure" {
|
||||
t.Log(err)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
t.Fatalf("expected %s, got: %v", v.expect, err)
|
||||
} else if v.expect != "success" && v.expect != "payload failure" &&
|
||||
@@ -163,27 +194,109 @@ func testVector(t *testing.T, v *vector) {
|
||||
}
|
||||
}
|
||||
if v.payloadHash != nil && sha256.Sum256(out) != *v.payloadHash {
|
||||
t.Error("partial payload hash mismatch")
|
||||
t.Errorf("partial payload hash mismatch, read %d bytes", len(out))
|
||||
}
|
||||
return
|
||||
return out
|
||||
} else if v.expect != "success" {
|
||||
t.Fatalf("expected %s, got success", v.expect)
|
||||
}
|
||||
if sha256.Sum256(out) != *v.payloadHash {
|
||||
t.Error("payload hash mismatch")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestVectorsRoundTrip checks that any (valid) armor, header, and/or STREAM
|
||||
func testDecryptReaderAt(t *testing.T, v *vector, plaintext []byte) {
|
||||
if v.armored {
|
||||
t.Skip("armor.NewReader does not implement ReaderAt")
|
||||
}
|
||||
rAt, s, err := age.DecryptReaderAt(bytes.NewReader(v.file), int64(len(v.file)), v.identities...)
|
||||
switch v.expect {
|
||||
case "success":
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got: %v", err)
|
||||
}
|
||||
if int64(len(plaintext)) != s {
|
||||
t.Errorf("unexpected size: got %d, want %d", s, len(plaintext))
|
||||
}
|
||||
case "payload failure":
|
||||
// DecryptReaderAt detects some (but not all) payload failures upfront,
|
||||
// either from the size of the payload, or by decrypting the last chunk
|
||||
// to authenticate its size.
|
||||
if err != nil {
|
||||
t.Log(err)
|
||||
return
|
||||
}
|
||||
default:
|
||||
if err != nil {
|
||||
t.Log(err)
|
||||
return
|
||||
}
|
||||
t.Fatalf("expected %s, got success", v.expect)
|
||||
}
|
||||
out, err := io.ReadAll(io.NewSectionReader(rAt, 0, s))
|
||||
if v.expect == "success" {
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err == nil {
|
||||
t.Fatalf("expected %s, got success", v.expect)
|
||||
}
|
||||
t.Log(err)
|
||||
// We can't check the partial payload hash, because the ReaderAt will
|
||||
// notice errors that a linearly scanning Reader could not. For example,
|
||||
// if there are two final chunks, the linear Reader will decrypt the
|
||||
// first one and then error out on the second, while the ReaderAt will
|
||||
// decrypt the second one to check the size, and then know that the
|
||||
// first chunk could not be the last one. Instead, check that the
|
||||
// prefix, if any, matches.
|
||||
if !bytes.HasPrefix(plaintext, out) {
|
||||
t.Errorf("partial payload prefix mismatch, read %d bytes", len(out))
|
||||
}
|
||||
return
|
||||
}
|
||||
if sha256.Sum256(out) != *v.payloadHash {
|
||||
t.Error("payload hash mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func testInspect(t *testing.T, v *vector, plaintext []byte) {
|
||||
if v.expect != "success" {
|
||||
t.Skip("invalid file, can't inspect")
|
||||
}
|
||||
for _, fileSize := range []int64{int64(len(v.file)), -1} {
|
||||
metadata, err := inspect.Inspect(bytes.NewReader(v.file), fileSize)
|
||||
if err != nil {
|
||||
t.Fatalf("inspect failed: %v", err)
|
||||
}
|
||||
if metadata.Armor != v.armored {
|
||||
t.Errorf("unexpected armor: %v", metadata.Armor)
|
||||
}
|
||||
if metadata.Armor && metadata.Sizes.Armor == 0 {
|
||||
t.Errorf("expected non-zero armor size")
|
||||
}
|
||||
if metadata.Sizes.Armor+metadata.Sizes.Header+metadata.Sizes.Overhead+metadata.Sizes.MinPayload != int64(len(v.file)) {
|
||||
t.Errorf("size breakdown does not add up to file size")
|
||||
}
|
||||
if metadata.Sizes.MinPayload != int64(len(plaintext)) {
|
||||
t.Errorf("unexpected payload size: got %d, want %d", metadata.Sizes.MinPayload, len(plaintext))
|
||||
}
|
||||
if metadata.Sizes.MaxPayload != metadata.Sizes.MinPayload {
|
||||
t.Errorf("unexpected max payload size: got %d, want %d", metadata.Sizes.MaxPayload, metadata.Sizes.MinPayload)
|
||||
}
|
||||
if metadata.Sizes.MinPadding != 0 || metadata.Sizes.MaxPadding != 0 {
|
||||
t.Errorf("unexpected padding sizes: got min %d max %d, want 0", metadata.Sizes.MinPadding, metadata.Sizes.MaxPadding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// testVectorsRoundTrip checks that any (valid) armor, header, and/or STREAM
|
||||
// payload in the test vectors re-encodes identically.
|
||||
func TestVectorsRoundTrip(t *testing.T) {
|
||||
forEachVector(t, testVectorRoundTrip)
|
||||
}
|
||||
|
||||
func testVectorRoundTrip(t *testing.T, v *vector) {
|
||||
if v.armored {
|
||||
if v.expect == "armor failure" {
|
||||
t.SkipNow()
|
||||
t.Skip("invalid armor, nothing to round-trip")
|
||||
}
|
||||
t.Run("armor", func(t *testing.T) {
|
||||
payload, err := io.ReadAll(armor.NewReader(bytes.NewReader(v.file)))
|
||||
@@ -212,7 +325,7 @@ func testVectorRoundTrip(t *testing.T, v *vector) {
|
||||
}
|
||||
|
||||
if v.expect == "header failure" {
|
||||
t.SkipNow()
|
||||
t.Skip("invalid header, nothing to round-trip")
|
||||
}
|
||||
hdr, p, err := format.Parse(bytes.NewReader(v.file))
|
||||
if err != nil {
|
||||
@@ -234,34 +347,62 @@ func testVectorRoundTrip(t *testing.T, v *vector) {
|
||||
}
|
||||
})
|
||||
|
||||
if v.expect == "success" {
|
||||
t.Run("STREAM", func(t *testing.T) {
|
||||
nonce, payload := payload[:16], payload[16:]
|
||||
key := streamKey(v.fileKey[:], nonce)
|
||||
r, err := stream.NewReader(key, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plaintext, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
w, err := stream.NewWriter(key, buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(buf.Bytes(), payload) {
|
||||
t.Error("got a different STREAM ciphertext")
|
||||
}
|
||||
})
|
||||
if v.expect != "success" {
|
||||
return
|
||||
}
|
||||
|
||||
t.Run("STREAM", func(t *testing.T) {
|
||||
nonce, payload := payload[:16], payload[16:]
|
||||
key := streamKey(v.fileKey[:], nonce)
|
||||
|
||||
r, err := stream.NewDecryptReader(key, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plaintext, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rAt, err := stream.NewDecryptReaderAt(key, bytes.NewReader(payload), int64(len(payload)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plaintextAt, err := io.ReadAll(io.NewSectionReader(rAt, 0, int64(len(plaintext))))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(plaintextAt, plaintext) {
|
||||
t.Errorf("got a different plaintext from DecryptReaderAt")
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
w, err := stream.NewEncryptWriter(key, buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(plaintext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(buf.Bytes(), payload) {
|
||||
t.Error("got a different STREAM ciphertext")
|
||||
}
|
||||
|
||||
er, err := stream.NewEncryptReader(key, bytes.NewReader(plaintext))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ciphertext, err := io.ReadAll(er)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(ciphertext, payload) {
|
||||
t.Error("got a different STREAM ciphertext from EncryptReader")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func streamKey(fileKey, nonce []byte) []byte {
|
||||
|
||||
10
x25519.go
10
x25519.go
@@ -21,8 +21,9 @@ import (
|
||||
|
||||
const x25519Label = "age-encryption.org/v1/X25519"
|
||||
|
||||
// X25519Recipient is the standard age public key. Messages encrypted to this
|
||||
// recipient can be decrypted with the corresponding X25519Identity.
|
||||
// X25519Recipient is the standard age pre-quantum public key. Messages
|
||||
// encrypted to this recipient can be decrypted with the corresponding
|
||||
// [X25519Identity]. For post-quantum resistance, use [HybridRecipient].
|
||||
//
|
||||
// This recipient is anonymous, in the sense that an attacker can't tell from
|
||||
// the message alone if it is encrypted to a certain recipient.
|
||||
@@ -105,8 +106,9 @@ func (r *X25519Recipient) String() string {
|
||||
return s
|
||||
}
|
||||
|
||||
// X25519Identity is the standard age private key, which can decrypt messages
|
||||
// encrypted to the corresponding X25519Recipient.
|
||||
// X25519Identity is the standard pre-quantum age private key, which can decrypt
|
||||
// messages encrypted to the corresponding [X25519Recipient]. For post-quantum
|
||||
// resistance, use [HybridIdentity].
|
||||
type X25519Identity struct {
|
||||
secretKey, ourPublicKey []byte
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user