58 Commits
Author SHA1 Message Date
GitHub Actions b74dce4cdb doc: regenerate groff and html man pages 2026-08-29 17:40:19 +00:00
Filippo Valsorda 5c1b90c1a6 all: release v1.3.2 2026-08-29 19:38:23 +02:00
Filippo Valsorda 7fcc72e15e plugin: correct working directory comment
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 58ef9fcb8d cmd/age-keygen: correct public key output documentation
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda de96c8e8e1 cmd/age: reject invalid UTF-8 terminal output
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 3604609d98 cmd/age-plugin-batchpass: correct documented scrypt limit
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 6ff2f308e6 cmd/age: enable PTY tests on Darwin 2026-08-29 19:30:10 +02:00
Filippo Valsorda cdb30a188b cmd/age: recognize armored identity files after whitespace
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 9cbe8d8c2a plugin: allow empty prompt responses
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda eb706087c6 internal/format: validate stanza fields when marshaling
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda bebcffc2f3 plugin: always canonicalize plugin names
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 572b47c3e7 agessh: enforce the RSA key size when wrapping
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda b030f04671 armor: enforce writer lifecycle
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 352cb3e133 plugin: reject negative file indexes
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda e8d1b2f216 armor,internal/format: restore CCTV fuzz seed corpora
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 6424047654 armor: reject embedded carriage returns
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 88299a50e4 cmd/age-inspect: classify unknown recipient stanzas
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda ec60cb56b4 internal/format: reject headers without recipient stanzas
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda aaadae2d85 cmd/age-keygen,extra/age-plugin-pq: buffer identity conversion output
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 019df184df .github/workflows: include compatibility plugins in release archives 2026-08-29 19:30:10 +02:00
Filippo Valsorda e0ffaca260 agessh: don't cache mismatched encrypted identities
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 86c6cc24c0 internal/term: sanitize terminal prompts
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda fb6bef07c3 age: don't reorder caller identities
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 45445f91cf cmd/age-keygen: report output write failures
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 343dda9aa4 cmd/age: reject malformed supported SSH recipients
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 418c7ac33b age,cmd/age: reject oversized key files
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda c45ccfd215 age,internal/format: avoid echoing private keys in errors
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 366cc58ca8 internal/bech32: avoid panic on Unicode input
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 7a8f500567 cmd/age: delay output creation until payload authentication
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda dbe5773864 cmd/age: reject output aliases of input files
Reported by Joe Doyle of Trail of Bits.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 20f9e8574f agessh,tag/tagtest: handle recipient hint collisions
The four-byte SSH fingerprints (and tagged recipient tags) are hints to
avoid unnecessary password prompts or hardware interactions. Treat an
authenticated unwrap failure after a hint match as a false positive and
continue looking for a matching stanza.

Without this, a colliding recipient stanza that appears before the
valid stanza makes decryption fail even though the file is correctly
encrypted to the identity. Any password prompt or hardware interaction
has already happened by then.

Reported by Claude, Anthropic's AI assistant, and triaged by the
Anthropic security team in collaboration with Anthropic Research,
as ANT-2026-S9C25K8W.
2026-08-29 19:30:10 +02:00
Filippo Valsorda 27188e780b internal/format: limit header size and memory use
The application has no way to limit the header separately from the body,
so we have to enforce some sanity limits to prevent excessive memory use.

To relax the limits somewhat, make the parsing more efficient.

New limits are:

  * 2 MiB total header size (enough for > 1024 PQ recipients)
  * 1024 recipient stanzas
  * 128 arguments per stanza

Fixes #701
2026-08-29 19:30:10 +02:00
Filippo Valsorda c083f6045f internal/stream: detect trailing data returned alongside io.EOF
The trailing data check after a full-length final chunk ignored the
byte count, so a reader returning (1, io.EOF), as io.Reader permits,
would have its trailing data silently accepted, and a transient
(0, nil) return would be misreported as trailing data. io.ReadFull
handles both.
2026-08-29 14:31:26 +02:00
Filippo Valsorda 91bc452b9a internal/stream: reject negative and overflowing encrypted sizes 2026-08-29 14:26:05 +02:00
Filippo Valsorda 04f65ea9fc internal/stream: extract readFullAt and clean up tests 2026-08-29 14:24:43 +02:00
Arpit GuptaandArpit Gupta b155aff565 stream: fix NewDecryptReaderAt EOF handling (#708)
Co-authored-by: Arpit Gupta <arpitb.lgupta1@gmail.com>
2026-08-29 13:45:51 +02:00
Ziad Yahya 2313f65737 README: add --version flag to usage section (#739) 2026-08-29 12:52:26 +02:00
Richard Ulmer d16b021305 README: add syntax highlighting to code blocks (#713) 2026-08-29 00:37:00 +02:00
GitHub Actions e65b40fc99 doc: regenerate groff and html man pages 2026-08-28 22:24:16 +00:00
Filippo Valsorda ac0ed948b4 .github/workflows: remove expired Windows code signing certificate
Closes #715
2026-08-29 00:22:02 +02:00
itsVentieandFilippo Valsorda 933c44d4d5 internal/inspect: handle reads after EOF in trackReader
trackReader.Read panicked if called again after a read returned io.EOF,
which can happen with readers that do not return EOF consistently, such
as a terminal after Ctrl-D. Latch the EOF instead, per the io.Reader
contract, so count stays consistent with the file size.

Fixes #719

Co-authored-by: Filippo Valsorda <hi@filippo.io>
2026-08-29 00:22:02 +02:00
Filippo Valsorda 6d29a923f3 .github/workflows: add windows/arm64 build
Fixes #733
2026-08-29 00:22:02 +02:00
Filippo Valsorda 3cfb3adc2a all: update dependencies and GitHub Actions
Closes #736
Fixes #731
2026-08-29 00:22:02 +02:00
Mohamed Moumni 72586cd449 cmd/age: add --version to usage text (#694) 2026-08-29 00:20:37 +02:00
udf2457 7c7978c12e ‎internal/stream: simplify a min() computation in tests (#727) 2026-08-28 23:28:31 +02:00
Filippo Valsorda a68b8e921b README: add dependencies badge 2026-08-28 23:06:33 +02:00
Steve WeisandClaude 706dfc1e79 all: minor cleanups (#700)
Replace two hand-rolled slicesEqual helpers with slices.Equal from
the standard library, which is already imported in age.go.
(age.go:211-221, plugin/plugin.go:666-676)

Fix godoc link typo [Indentity] -> [Identity].
(plugin/plugin.go:3)

Fix comment typo "not MinPayload" -> "so MinPayload".
(internal/inspect/inspect.go:25)

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-20 22:12:32 +01:00
Steve WeisandClaude 4c86929a84 internal/format: return correct byte count from writeWrapped (#698)
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-20 22:02:09 +01:00
Steve WeisandClaude db09cd66d1 internal/inspect: fix tag stanza type names in post-quantum classifier (#695)
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-20 21:49:27 +01:00
Filippo Valsorda 4a3a4ef00a SIGSUM.md: extend release playbook 2026-02-02 17:31:01 +01:00
Filippo Valsorda 10561a774f .github/workflows: remove one-off darwin/amd64 backfill workflow
Updates #676
2026-01-21 18:56:39 +01:00
Filippo Valsorda c918a2c113 .github/workflows: add darwin/amd64 backfill workflow
Updates #676
2026-01-21 18:49:52 +01:00
Filippo Valsorda beb5dfa28c .github/workflows: add darwin/amd64 build target
Closes #676
2026-01-21 18:49:46 +01:00
Filippo Valsorda 0293aca1d7 SIGSUM.md: add release playbook
Updates #617
2025-12-31 16:19:37 +01:00
GitHub Actions e7601d8a67 doc: regenerate groff and html man pages 2025-12-31 11:05:09 +00:00
brettwhiteinc acfa73142b doc: fix post-quantum example in age-keygen manpage (#675) 2025-12-31 12:03:49 +01:00
Filippo Valsorda b8564adb6d .github/workflows: inject version into source release artifact
Updates #671
Updates golang/go#77020
2025-12-28 13:00:23 +01:00
Filippo Valsorda e4c611f778 cmd,extra: restore the Version link-time variable
We don't need it in our builds, but it's useful for downstream packagers.

Fixes #671
Updates NixOS/nixpkgs#474666
Updates golang/go#77020
2025-12-28 12:49:37 +01:00
60 changed files with 1942 additions and 538 deletions

No files matched your search

+16 -20
View File
@@ -17,16 +17,18 @@ jobs:
- {GOOS: linux, GOARCH: arm, GOARM: 6}
- {GOOS: linux, GOARCH: arm64}
- {GOOS: darwin, GOARCH: arm64}
- {GOOS: darwin, GOARCH: amd64}
- {GOOS: windows, GOARCH: amd64}
- {GOOS: windows, GOARCH: arm64}
- {GOOS: freebsd, GOARCH: amd64}
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- name: Install Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: false
@@ -35,21 +37,10 @@ jobs:
VERSION="$(git describe --tags)"
DIR="$(mktemp -d)"
mkdir "$DIR/age"
go build -o "$DIR/age" -trimpath ./cmd/...
go build -o "$DIR/age" -trimpath ./cmd/... ./extra/...
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
for exe in "$DIR"/age/*.exe; do
/usr/bin/osslsigncode sign -t "http://timestamp.comodoca.com" \
-certs .github/workflows/certs/uitacllc.crt \
-key .github/workflows/certs/uitacllc.key \
-pass "${{ secrets.SIGN_PASS }}" \
-n age -in "$exe" -out "$exe.signed"
mv "$exe.signed" "$exe"
done
fi
( cd "$DIR"; zip age.zip -r age )
mv "$DIR/age.zip" "age-$VERSION-$GOOS-$GOARCH.zip"
else
@@ -61,7 +52,7 @@ jobs:
GOARCH: ${{ matrix.GOARCH }}
GOARM: ${{ matrix.GOARM }}
- name: Upload workflow artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: age-artifacts-${{ matrix.GOOS }}-${{ matrix.GOARCH }}
path: age-*
@@ -70,12 +61,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- name: Install Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: false
@@ -86,9 +77,14 @@ jobs:
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
uses: actions/upload-artifact@v7
with:
name: age-artifacts-source
path: age-*-source.tar.gz
@@ -103,12 +99,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Download workflow artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
pattern: age-artifacts-*
merge-multiple: true
- name: Generate artifacts attestation
uses: actions/attest-build-provenance@v3
uses: actions/attest-build-provenance@v4
with:
subject-path: age-*
- name: Upload release artifacts
-14
View File
@@ -1,14 +0,0 @@
In this folder there are
uitacllc.crt
PKCS#7 encoded certificate chain for a code signing certificate issued
to Up in the Air Consulting LLC valid until Sep 26 23:59:59 2024 GMT.
https://crt.sh/?id=5339775059
uitacllc.key
PEM encrypted private key for the leaf certificate above.
Its passphrase is long and randomly generated, so the awful legacy key
derivation doesn't really matter, and it makes osslsigncode happy.
Binary file not shown.
-42
View File
@@ -1,42 +0,0 @@
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-256-CBC,B93C1A166F3677D68FB9CB3E8A184729
UriYsaq3tLyvycDDB2YeQ+9L1P5VCPcfVkYR1ocleF8WxNDUPdz3RqbryAZZdXVO
0bcvAHTXkdI4Oiw5mN0S8fGsNq9zn+pyResx3lXtgN3oCDCe2SQn28uEEKxPzud5
0NRXYoBP+pLDjiuQ/6Lp7DovnAO/uxaPFvYRMiknNVOhwyHGWZyuUe01S9J9im7y
vgc1wkyQzmABIhARynEXHp3KnM9aF8X1/ck839lQRBFrvRFNm5rqiON26spr1Hu5
znrbVGROYk0XNdH5VHDk7V9k+v2WLL/b4nxlMymZpDzr9pXzX8olpLnQrarsMbHe
ysfXNTtQi5Dq6KXURW8VA4DmxAzTRNUxe2aA4JnAEyFU5LDLetTN9F9M7BUkHbXH
RpSbZqDjPwg7U98vuSwxjIkncHSiYYi3FmSoupLvV+eIP6qRSgONdzGlP5NTn4Lh
N1lYMPHPldH6UjLHrldkYN16TQlrqNHZExN91XvsZVjpyAgErY18xwi3CTEco45D
fRqsiWXtoas4LkafhSY0vfl5aFhY9YPUpS6uFdgWBvgcQeYb8meX5Nr4dNXVk5Wa
yRlYlW/X0TWC0T9qaBOPN/z7OWO5aL4jYRcKQQ+aR8gFcHGGCpRAKD369OneXfOQ
MD9UHoPG4WTBg/NU9OSskcywfuSOkwAGfBVNXrnEj6tYFjsjYK2nC2gm+opUCfm0
a1FeDb5nQSOgOJKUCO6Aj+0NvDvVLUOsTk1lfzSugIkmUOdV+rXHnrZC+90q8KfN
S2JlzwSZNg0e+VxZpnD7k7axHkbHrbebtrLvzKVnrh3s0OFAXN0isMw7yhhWtzUe
mPoQTZusLDOAJe/QPuNlDUgr4uoVZtoXrPzoZZkw2VFLwYy2g/EYvlK9BdVVTnRm
9Hq9IBDrZw+SV/7roaeVOXbzrQoxEoXcL7eo6iWvV5Q7Ll5C4ovelHKy3IAzcpYP
6LKfxAO2sIKTALrHbtBNG+O4RTtxOva1hyg27V4v2k53CF/GhoBRPSpbbupwppXc
lJJ9RtMTRfhCv/ObhdsJED+YUqFifTJfcnQ1iGN8dnBuGrjXxVCN0wgmv46Pdhn0
tUfGlkFquOOWamaVaIvp6JCVUDa1ezMzleILoYvrxvOuP+dGVrwTwVCXpx4JuUgp
d72/w+EnqlZnwsAzdrErJFXnHux981ZoojmG94km1B6gPPwMB8JRcD67lfhG/vne
IpTuuzGaSInf24cGNig01hbBuKSg79yNY0llkECPBXbEhfkemEMhg1WHoNP2eG8j
MHS5OCT5KiOfi77pSO3M2mGB1HWYE5R0lcMibukK9ZdyIYcTeMZ0RcGm6YSNv570
ok/Ex4LUCW66AIWFefmbIOtJSIMHlNKWRPJwnJxVoE5qgH0f/2xL3k15vpI55lAS
sabzegnYlElPbUlZGhgwjKknxgqMhFIW/ZS0h2FukFLwipr4qI47nHWz5dguNkYn
48sSKg3YMhVx/sT+X2A/6zqsC+p4PT7Ti5ruWb7S9L9vRuBdIDNE9qAwuz0g8Bs3
WhOx6OW2ZqDQEuRhN0lyGA0mwRC4HPFE9b8dnN8lNm+RsnMfNoFxzPnqtsxhEAwa
2a4ijT97ka94lDy7WQ2bwLRz7trKV/T6MeETKE4s7+z2dMTr1f8IwA2uCovFmO9T
aMQAePFEtDT3qwIPu0zH1ocSCkZ50f7RgVmp4FNn03uT/TnsASrr5CS9m8A9gjEn
QiztQyqt27fTT61YkNdA6lwbpFiByugVbS+mWsNa9kvBkgQkcMQwgrELmU9sYdBT
nRMa60i0nEINT/x3zFvT6R7Dl/O8/QhXLeYv20X2roghPw48IovLb8x7dT3YEQSn
ARIXXVPxwOVvS8xcCa69/+1HjC6vNG9dNNnAsVHxB8mDTBqmmLzAMOVzDoNWEgDd
zoRhQ3ORb1brPlKWg8um/svLiSV63ZYi2J8LPamoGmZ/7J8i5rjOpOeG493UICBR
JymmYGUo6/C1Ze8swdMHApVU/spo0s8BCGkMjYUAaxXD7RufN2DuY30Vny/DMn4y
XasuHS9RstD2Okv25PD06Y2H52HJ6MNdArmPZRe0k2ZbhATs5dXOfmaF5Z0f4IkE
G+hsxE1wlCo900ewntx16sBCbI0v9aE+Napf2+ueqPQ06CdfiTG5yOmeXzgR/8zS
KVmTHpmmFpYtj/N350BLAVb/Hwzmh+ieWnO7TUjvNAHUn2i5LZU65rN3GOlPyIlz
DzB2T6KjOUPFKqSRrIin14HLyf5w0vDuJhe5Zpe0hhYKvoKhwCEVefbmkasWeso3
xsXxOOoL39GA0QpYjR6ztqR8fS9jTeu5IY+zY5LO8yS7+StP3H8CcqRMuxb3ntym
-----END RSA PRIVATE KEY-----
+5 -5
View File
@@ -13,10 +13,10 @@ jobs:
name: Ronn
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: geomys/sandboxed-step@v1.2.1
- uses: geomys/sandboxed-step@v1.2.2
with:
persist-workspace-changes: true
run: |
@@ -29,7 +29,7 @@ jobs:
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
- uses: actions/upload-artifact@v7
with:
name: man-pages
path: |
@@ -42,10 +42,10 @@ jobs:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
persist-credentials: true
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
name: man-pages
path: doc/
+11 -11
View File
@@ -21,10 +21,10 @@ jobs:
- windows-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version: ${{ matrix.go.go-version }}
- run: |
@@ -38,13 +38,13 @@ jobs:
- { go-version: stable }
- { go-version: oldstable }
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version: ${{ matrix.go.go-version }}
- uses: geomys/sandboxed-step@v1.2.1
- uses: geomys/sandboxed-step@v1.2.2
with:
run: |
go get -u -t ./...
@@ -52,24 +52,24 @@ jobs:
staticcheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version: stable
- uses: geomys/sandboxed-step@v1.2.1
- uses: geomys/sandboxed-step@v1.2.2
with:
run: go run honnef.co/go/tools/cmd/staticcheck@latest ./...
govulncheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version: stable
- uses: geomys/sandboxed-step@v1.2.1
- uses: geomys/sandboxed-step@v1.2.2
with:
run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...
+13 -14
View File
@@ -7,6 +7,7 @@
</p>
[![Go Reference](https://pkg.go.dev/badge/filippo.io/age.svg)](https://pkg.go.dev/filippo.io/age)
[![Dependencies](https://badges.api.sourcespotter.com/deps?firstparty=filippo.io&package=filippo.io%2Fage)](https://sourcespotter.com/deps/?firstparty=filippo.io&package=filippo.io%2Fage)
[![man page](<https://img.shields.io/badge/age(1)-man%20page-lightgrey>)](https://filippo.io/age/age.1)
[![C2SP specification](https://img.shields.io/badge/%C2%A7%23-specification-blueviolet)](https://age-encryption.org/v1)
@@ -14,7 +15,7 @@ age is a simple, modern and secure file encryption tool, format, and Go library.
It features small explicit keys, post-quantum support, no config options, and UNIX-style composability.
```
```console
$ age-keygen -o key.txt
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
$ tar cvz ~/data | age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p > data.tar.gz.age
@@ -149,11 +150,9 @@ $ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
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.3.0?for=darwin/arm64
...
```
- [https://dl.filippo.io/age/latest?for=linux/amd64](https://dl.filippo.io/age/latest?for=linux/amd64)
- [https://dl.filippo.io/age/v1.3.2?for=darwin/arm64](https://dl.filippo.io/age/v1.3.2?for=darwin/arm64)
- ...
If you download the pre-built binaries, you can check their [Sigsum proofs](./SIGSUM.md).
@@ -184,6 +183,7 @@ Options:
-r, --recipient RECIPIENT Encrypt to the specified RECIPIENT. Can be repeated.
-R, --recipients-file PATH Encrypt to recipients listed at PATH. Can be repeated.
-i, --identity PATH Use the identity file at PATH. Can be repeated.
--version Print the version.
INPUT defaults to standard input, and OUTPUT defaults to standard output.
If OUTPUT exists, it will be overwritten.
@@ -238,7 +238,7 @@ 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.
```
```console
$ age-keygen -pq -o key.txt
$ age-keygen -y key.txt > recipient.txt
$ age -R recipient.txt example.jpg > example.jpg.age
@@ -255,7 +255,7 @@ identities with `age-plugin-pq -identity`.
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.
```
```console
$ age -p secrets.txt > secrets.txt.age
Enter passphrase (leave empty to autogenerate a secure one):
Using the autogenerated passphrase "release-response-step-brand-wrap-ankle-pair-unusual-sword-train".
@@ -267,7 +267,7 @@ Enter passphrase:
If an identity file passed to `-i` is a passphrase encrypted age file, it will be automatically decrypted.
```
```console
$ age-keygen | age -p > key.age
Public key: age1yhm4gctwfmrpz87tdslm550wrx6m79y9f2hdzt0lndjnehwj0ukqrjpyx5
Enter passphrase (leave empty to autogenerate a secure one):
@@ -283,7 +283,7 @@ Passphrase-protected identity files are not necessary for most use cases, where
As a convenience feature, age also supports encrypting to `ssh-rsa` and `ssh-ed25519` SSH public keys, and decrypting with the respective private key file. (`ssh-agent` is not supported.)
```
```console
$ age -R ~/.ssh/id_ed25519.pub example.jpg > example.jpg.age
$ age -d -i ~/.ssh/id_ed25519 example.jpg.age > example.jpg
```
@@ -294,7 +294,7 @@ Note that SSH key support employs more complex cryptography, and embeds a public
Combining SSH key support and `-R`, you can easily encrypt a file to the SSH keys listed on a GitHub profile.
```
```console
$ curl https://github.com/benjojo.keys | age -R - example.jpg > example.jpg.age
```
@@ -304,7 +304,7 @@ Keep in mind that people might not protect SSH keys long-term, since they are re
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.
```
```console
$ age-inspect secrets.age
secrets.age is an age file, version "age-encryption.org/v1".
@@ -321,6 +321,5 @@ Size breakdown (assuming it decrypts successfully):
-------------------
Total 1701 bytes
Tip: for machine-readable output, use --json.
```
For scripting, use `--json` to get machine-readable output.
+24 -4
View File
@@ -5,19 +5,39 @@ 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.
```
```shell
cat << EOF > age-sigsum-key.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM1WpnEswJLPzvXJDiswowy48U+G+G1kmgwUE2eaRHZG
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAz2WM5CyPLqiNjk7CLl4roDXwKhQ0QExXLebukZEZFS
EOF
curl -JLO "https://dl.filippo.io/age/v1.3.0?for=darwin/arm64"
curl -JLO "https://dl.filippo.io/age/v1.3.0?for=darwin/arm64&proof"
curl -JLO "https://dl.filippo.io/age/v1.3.2?for=darwin/arm64"
curl -JLO "https://dl.filippo.io/age/v1.3.2?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.0-darwin-arm64.tar.gz.proof < age-v1.3.0-darwin-arm64.tar.gz
age-v1.3.2-darwin-arm64.tar.gz.proof < age-v1.3.2-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
```shell
VERSION=v1.3.2
go install sigsum.org/sigsum-go/cmd/sigsum-verify@latest
go install github.com/tillitis/tkey-ssh-agent/cmd/tkey-ssh-agent@main
tkey-ssh-agent --agent-socket tkey-ssh-agent.sock --uss
passage -c other/tkey-ssh-sigsum-age
SSH_AUTH_SOCK=tkey-ssh-agent.sock ssh-add -L > tkey-ssh-agent.pub
passage other/sigsum-ratelimit > sigsum-ratelimit
gh release download $VERSION --repo FiloSottile/age --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 --repo FiloSottile/age artifacts/*.proof
```
In the future, we will move to reproducing the artifacts locally, and signing
those instead of the ones built by GitHub Actions.
+3 -13
View File
@@ -130,7 +130,7 @@ func encryptHdr(fileKey []byte, recipients ...Recipient) (*format.Header, error)
sort.Strings(l)
if i == 0 {
labels = l
} else if !slicesEqual(labels, l) {
} else if !slices.Equal(labels, l) {
return nil, incompatibleLabelsError(labels, l)
}
for _, s := range stanzas {
@@ -208,18 +208,6 @@ func wrapWithLabels(r Recipient, fileKey []byte) (s []*Stanza, labels []string,
return
}
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
}
func incompatibleLabelsError(l1, l2 []string) error {
hasPQ1 := slices.Contains(l1, "postquantum")
hasPQ2 := slices.Contains(l2, "postquantum")
@@ -333,6 +321,8 @@ func decryptHdr(hdr *format.Header, identities ...Identity) ([]byte, error) {
if len(identities) == 0 {
return nil, errors.New("no identities specified")
}
// Don't sort the caller's slice in place.
identities = slices.Clone(identities)
slices.SortStableFunc(identities, func(a, b Identity) int {
var aIsNative, bIsNative bool
switch a.(type) {
+229
View File
@@ -15,9 +15,12 @@ import (
"os"
"slices"
"strings"
"sync"
"testing"
"filippo.io/age"
"filippo.io/age/internal/bech32"
"filippo.io/age/plugin"
)
func ExampleEncrypt() {
@@ -259,6 +262,112 @@ AGE-SECRET-KEY--1D6K0SGAX3NU66R4GYFZY0UQWCLM3UUSF3CXLW4KXZM342WQSJ82QKU59Q`},
}
}
func TestParseErrorsDoNotIncludeLine(t *testing.T) {
x25519, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
hybrid, err := age.GenerateHybridIdentity()
if err != nil {
t.Fatal(err)
}
// Re-encode the hybrid identity as a plugin identity.
_, seed, err := bech32.Decode(hybrid.String())
if err != nil {
t.Fatal(err)
}
pluginIdentity, err := bech32.Encode("AGE-PLUGIN-PQ-", seed)
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
line string
parse func(io.Reader) error
}{
{"identities/plugin", pluginIdentity, func(r io.Reader) error {
_, err := age.ParseIdentities(r)
return err
}},
{"recipients/x25519", x25519.String(), func(r io.Reader) error {
_, err := age.ParseRecipients(r)
return err
}},
{"recipients/hybrid", hybrid.String(), func(r io.Reader) error {
_, err := age.ParseRecipients(r)
return err
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.parse(strings.NewReader("# a comment\n" + tt.line + "\n"))
if err == nil {
t.Fatal("expected an error for an unrecognized line, got nil")
}
if strings.Contains(err.Error(), tt.line) {
t.Errorf("error includes the private key from the file: %v", err)
}
if !strings.Contains(err.Error(), "line 2") {
t.Errorf("error doesn't say which line failed: %v", err)
}
})
}
}
func paddedFile(pad int, lines string) io.Reader {
const chunkSize = 1 << 16
chunk := strings.Repeat("#\n", chunkSize/2)
var readers []io.Reader
if pad%2 != 0 {
readers = append(readers, strings.NewReader("\n"))
pad--
}
for ; pad > chunkSize; pad -= chunkSize {
readers = append(readers, strings.NewReader(chunk))
}
return io.MultiReader(append(readers,
strings.NewReader(chunk[:pad]), strings.NewReader(lines))...)
}
func TestParseFileSizeLimit(t *testing.T) {
const sizeLimit = 1 << 24
const identity = "AGE-SECRET-KEY-1D6K0SGAX3NU66R4GYFZY0UQWCLM3UUSF3CXLW4KXZM342WQSJ82QKU59QJ\n"
const recipient = "age1cy0su9fwf3gf9mw868g5yut09p6nytfmmnktexz2ya5uqg9vl9sss4euqm\n"
tests := []struct {
name string
entry string
parse func(io.Reader) (int, error)
}{
{"identities", identity, func(r io.Reader) (int, error) {
ids, err := age.ParseIdentities(r)
return len(ids), err
}},
{"recipients", recipient, func(r io.Reader) (int, error) {
recipients, err := age.ParseRecipients(r)
return len(recipients), err
}},
}
for _, tt := range tests {
t.Run(tt.name+"/at limit", func(t *testing.T) {
r := paddedFile(sizeLimit-4*len(tt.entry), strings.Repeat(tt.entry, 4))
n, err := tt.parse(r)
if err != nil {
t.Fatal(err)
}
if n != 4 {
t.Errorf("got %d entries, want 4", n)
}
})
t.Run(tt.name+"/over limit", func(t *testing.T) {
r := paddedFile(sizeLimit-4*len(tt.entry), strings.Repeat(tt.entry, 8))
if _, err := tt.parse(r); err == nil {
t.Error("expected an error")
}
})
}
}
type testRecipient struct {
labels []string
}
@@ -271,6 +380,22 @@ func (t testRecipient) WrapWithLabels(fileKey []byte) (s []*age.Stanza, labels [
return []*age.Stanza{{Type: "test"}}, t.labels, nil
}
type emptyRecipient struct{}
func (emptyRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
return nil, nil
}
func TestEmptyRecipient(t *testing.T) {
buf := &bytes.Buffer{}
if _, err := age.Encrypt(buf, emptyRecipient{}); err == nil {
t.Fatal("Encrypt accepted a recipient with no stanzas")
}
if buf.Len() != 0 {
t.Errorf("Encrypt wrote %d bytes", buf.Len())
}
}
func TestLabels(t *testing.T) {
scrypt, err := age.NewScryptRecipient("xxx")
if err != nil {
@@ -333,6 +458,83 @@ func (ti *testIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
return nil, age.ErrIncorrectIdentity
}
type noMatchIdentity int
func (noMatchIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
return nil, age.ErrIncorrectIdentity
}
func TestDecryptDoesNotReorderIdentities(t *testing.T) {
identity, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
buf := &bytes.Buffer{}
w, err := age.Encrypt(buf, identity.Recipient())
if 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)
}
tests := []struct {
name string
decrypt func([]age.Identity) error
}{
{"Decrypt", func(identities []age.Identity) error {
_, err := age.Decrypt(bytes.NewReader(encrypted), identities...)
return err
}},
{"DecryptReaderAt", func(identities []age.Identity) error {
_, _, err := age.DecryptReaderAt(bytes.NewReader(encrypted), int64(len(encrypted)), identities...)
return err
}},
{"DecryptHeader", func(identities []age.Identity) error {
_, err := age.DecryptHeader(header, identities...)
return err
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
identities := []age.Identity{noMatchIdentity(1), identity, noMatchIdentity(2)}
want := slices.Clone(identities)
if err := tt.decrypt(identities); err != nil {
t.Fatal(err)
}
if !slices.Equal(identities, want) {
t.Error("identities were reordered")
}
})
}
t.Run("concurrent", func(t *testing.T) {
identities := []age.Identity{noMatchIdentity(1), identity, noMatchIdentity(2)}
want := slices.Clone(identities)
var wg sync.WaitGroup
for range 4 {
wg.Add(1)
go func() {
defer wg.Done()
for range 20 {
if _, err := age.Decrypt(bytes.NewReader(encrypted), identities...); err != nil {
t.Error(err)
}
}
}()
}
wg.Wait()
if !slices.Equal(identities, want) {
t.Error("identities were reordered")
}
})
}
func TestDecryptNativeIdentitiesFirst(t *testing.T) {
correct, err := age.GenerateX25519Identity()
if err != nil {
@@ -549,3 +751,30 @@ func TestEncryptReader(t *testing.T) {
t.Errorf("wrong data: %q, excepted %q", outBytes, helloWorld)
}
}
func TestParseUnicode(t *testing.T) {
// U+212A folds to "k", shrinking the data part below the checksum.
w := "AA3100AC" + string(rune(0x212A))
for _, tc := range []struct {
name string
fn func(string) error
}{
{"age.ParseX25519Recipient", func(s string) error { _, err := age.ParseX25519Recipient(s); return err }},
{"age.ParseX25519Identity", func(s string) error { _, err := age.ParseX25519Identity(s); return err }},
{"age.ParseHybridRecipient", func(s string) error { _, err := age.ParseHybridRecipient(s); return err }},
{"age.ParseHybridIdentity", func(s string) error { _, err := age.ParseHybridIdentity(s); return err }},
{"plugin.ParseIdentity", func(s string) error { _, _, err := plugin.ParseIdentity(s); return err }},
{"plugin.ParseRecipient", func(s string) error { _, _, err := plugin.ParseRecipient(s); return err }},
} {
t.Run(tc.name, func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("%s panicked on malformed input: %v", tc.name, r)
}
}()
if err := tc.fn(w); err == nil {
t.Errorf("%s returned nil error, want error", tc.name)
}
})
}
}
+10 -3
View File
@@ -63,13 +63,16 @@ func NewRSARecipient(pk ssh.PublicKey) (*RSARecipient, error) {
} else {
return nil, errors.New("pk does not implement ssh.CryptoPublicKey")
}
if r.pubKey.Size() < 2048/8 {
if r.pubKey.N.BitLen() < 2048 {
return nil, errors.New("RSA key size is too small")
}
return r, nil
}
func (r *RSARecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
if r.pubKey.N.BitLen() < 2048 {
return nil, errors.New("RSA key size is too small")
}
l := &age.Stanza{
Type: "ssh-rsa",
Args: []string{sshFingerprint(r.sshKey)},
@@ -129,7 +132,9 @@ func (i *RSAIdentity) unwrap(block *age.Stanza) ([]byte, error) {
fileKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, i.k,
block.Body, []byte(oaepLabel))
if err != nil {
return nil, fmt.Errorf("failed to decrypt file key: %v", err)
// The fingerprint is only a short hint, and might collide with the
// fingerprint of a different recipient.
return nil, age.ErrIncorrectIdentity
}
return fileKey, nil
}
@@ -344,7 +349,9 @@ func (i *Ed25519Identity) unwrap(block *age.Stanza) ([]byte, error) {
fileKey, err := aeadDecrypt(wrappingKey, block.Body)
if err != nil {
return nil, fmt.Errorf("failed to decrypt file key: %v", err)
// The fingerprint is only a short hint, and might collide with the
// fingerprint of a different recipient.
return nil, age.ErrIncorrectIdentity
}
return fileKey, nil
}
+178
View File
@@ -9,6 +9,8 @@ import (
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"encoding/pem"
"math/big"
"reflect"
"testing"
@@ -16,6 +18,33 @@ import (
"golang.org/x/crypto/ssh"
)
func TestSSHRSAKeySize(t *testing.T) {
for _, bits := range []int{2041, 2047, 2048} {
n := new(big.Int).Lsh(big.NewInt(1), uint(bits-1))
n.SetBit(n, 0, 1)
pub, err := ssh.NewPublicKey(&rsa.PublicKey{N: n, E: 65537})
if err != nil {
t.Fatal(err)
}
_, err = agessh.NewRSARecipient(pub)
if rejected, want := err != nil, bits < 2048; rejected != want {
t.Errorf("%d-bit key: rejected = %v, want %v", bits, rejected, want)
}
}
key, err := rsa.GenerateKey(rand.Reader, 1024)
if err != nil {
t.Fatal(err)
}
id, err := agessh.NewRSAIdentity(key)
if err != nil {
t.Fatal(err)
}
if _, err := id.Recipient().Wrap(make([]byte, 16)); err == nil {
t.Error("RSAIdentity.Recipient accepted a 1024-bit key")
}
}
func TestSSHRSARoundTrip(t *testing.T) {
pk, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
@@ -59,6 +88,48 @@ func TestSSHRSARoundTrip(t *testing.T) {
}
}
func TestSSHRSAFingerprintCollision(t *testing.T) {
targetKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
targetIdentity, err := agessh.NewRSAIdentity(targetKey)
if err != nil {
t.Fatal(err)
}
otherKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
otherIdentity, err := agessh.NewRSAIdentity(otherKey)
if err != nil {
t.Fatal(err)
}
fileKey := make([]byte, 16)
if _, err := rand.Read(fileKey); err != nil {
t.Fatal(err)
}
stanzas, err := targetIdentity.Recipient().Wrap(fileKey)
if err != nil {
t.Fatal(err)
}
collision, err := otherIdentity.Recipient().Wrap(make([]byte, 16))
if err != nil {
t.Fatal(err)
}
collision[0].Args[0] = stanzas[0].Args[0]
out, err := targetIdentity.Unwrap(append(collision, stanzas...))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(fileKey, out) {
t.Errorf("invalid output: %x, expected %x", out, fileKey)
}
}
func TestSSHEd25519RoundTrip(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
@@ -101,3 +172,110 @@ func TestSSHEd25519RoundTrip(t *testing.T) {
t.Errorf("invalid output: %x, expected %x", out, fileKey)
}
}
func TestSSHEd25519FingerprintCollision(t *testing.T) {
_, targetKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
targetIdentity, err := agessh.NewEd25519Identity(targetKey)
if err != nil {
t.Fatal(err)
}
_, otherKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
otherIdentity, err := agessh.NewEd25519Identity(otherKey)
if err != nil {
t.Fatal(err)
}
fileKey := make([]byte, 16)
if _, err := rand.Read(fileKey); err != nil {
t.Fatal(err)
}
stanzas, err := targetIdentity.Recipient().Wrap(fileKey)
if err != nil {
t.Fatal(err)
}
collision, err := otherIdentity.Recipient().Wrap(make([]byte, 16))
if err != nil {
t.Fatal(err)
}
collision[0].Args[0] = stanzas[0].Args[0]
out, err := targetIdentity.Unwrap(append(collision, stanzas...))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(fileKey, out) {
t.Errorf("invalid output: %x, expected %x", out, fileKey)
}
}
func TestEncryptedSSHIdentityMismatch(t *testing.T) {
announcedPub, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
embeddedPub, embeddedPriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
announcedKey, err := ssh.NewPublicKey(announcedPub)
if err != nil {
t.Fatal(err)
}
embeddedKey, err := ssh.NewPublicKey(embeddedPub)
if err != nil {
t.Fatal(err)
}
passphrase := []byte("passphrase")
block, err := ssh.MarshalPrivateKeyWithPassphrase(embeddedPriv, "", passphrase)
if err != nil {
t.Fatal(err)
}
prompts := 0
i, err := agessh.NewEncryptedSSHIdentity(announcedKey, pem.EncodeToMemory(block), func() ([]byte, error) {
prompts++
return passphrase, nil
})
if err != nil {
t.Fatal(err)
}
announcedRecipient, err := agessh.NewEd25519Recipient(announcedKey)
if err != nil {
t.Fatal(err)
}
embeddedRecipient, err := agessh.NewEd25519Recipient(embeddedKey)
if err != nil {
t.Fatal(err)
}
fileKey := make([]byte, 16)
if _, err := rand.Read(fileKey); err != nil {
t.Fatal(err)
}
stanzas, err := announcedRecipient.Wrap(fileKey)
if err != nil {
t.Fatal(err)
}
embeddedStanzas, err := embeddedRecipient.Wrap(fileKey)
if err != nil {
t.Fatal(err)
}
stanzas = append(stanzas, embeddedStanzas...)
for attempt := 1; attempt <= 2; attempt++ {
_, err := i.Unwrap(stanzas)
if err == nil || err.Error() != "mismatched private and public SSH key" {
t.Fatalf("attempt %d: unexpected error: %v", attempt, err)
}
if prompts != attempt {
t.Fatalf("attempt %d: passphrase callback invoked %d times", attempt, prompts)
}
}
}
+5 -3
View File
@@ -109,16 +109,17 @@ func (i *EncryptedSSHIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, er
var pubKey interface {
Equal(x crypto.PublicKey) bool
}
var decrypted age.Identity
switch k := k.(type) {
case *ed25519.PrivateKey:
i.decrypted, err = NewEd25519Identity(*k)
decrypted, err = NewEd25519Identity(*k)
pubKey = k.Public().(ed25519.PublicKey)
// ParseRawPrivateKey returns inconsistent types. See Issue 429.
case ed25519.PrivateKey:
i.decrypted, err = NewEd25519Identity(k)
decrypted, err = NewEd25519Identity(k)
pubKey = k.Public().(ed25519.PublicKey)
case *rsa.PrivateKey:
i.decrypted, err = NewRSAIdentity(k)
decrypted, err = NewRSAIdentity(k)
pubKey = &k.PublicKey
default:
return nil, fmt.Errorf("unexpected SSH key type: %T", k)
@@ -131,5 +132,6 @@ func (i *EncryptedSSHIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, er
return nil, fmt.Errorf("mismatched private and public SSH key")
}
i.decrypted = decrypted
return i.decrypted.Unwrap(stanzas)
}
+23 -5
View File
@@ -31,13 +31,24 @@ type armoredWriter struct {
dst io.Writer
}
func (a *armoredWriter) Write(p []byte) (int, error) {
if !a.started {
if _, err := io.WriteString(a.dst, Header+"\n"); err != nil {
return 0, err
}
func (a *armoredWriter) writeHeader() error {
if a.started {
return nil
}
if _, err := io.WriteString(a.dst, Header+"\n"); err != nil {
return err
}
a.started = true
return nil
}
func (a *armoredWriter) Write(p []byte) (int, error) {
if a.closed {
return 0, errors.New("ArmoredWriter already closed")
}
if err := a.writeHeader(); err != nil {
return 0, err
}
return a.encoder.Write(p)
}
@@ -46,6 +57,9 @@ func (a *armoredWriter) Close() error {
return errors.New("ArmoredWriter already closed")
}
a.closed = true
if err := a.writeHeader(); err != nil {
return err
}
if err := a.encoder.Close(); err != nil {
return err
}
@@ -146,6 +160,10 @@ func (r *armoredReader) Read(p []byte) (int, error) {
if len(line) > format.ColumnsPerLine {
return 0, r.setErr(errors.New("column limit exceeded"))
}
// Reject newline characters ignored by base64.Decode.
if bytes.ContainsAny(line, "\n\r") {
return 0, r.setErr(errors.New("unexpected newline character"))
}
r.unread = r.buf[:]
n, err := base64.StdEncoding.Strict().Decode(r.unread, line)
if err != nil {
+31 -4
View File
@@ -12,12 +12,12 @@ import (
"encoding/pem"
"fmt"
"io"
"io/fs"
"log"
"os"
"path/filepath"
"strings"
"testing"
agetest "c2sp.org/CCTV/age"
"filippo.io/age"
"filippo.io/age/armor"
"filippo.io/age/internal/format"
@@ -94,6 +94,28 @@ func TestArmor(t *testing.T) {
t.Run("FullLine", func(t *testing.T) { testArmor(t, 10*format.BytesPerLine) })
}
func TestWriterLifecycle(t *testing.T) {
t.Run("CloseWithoutWrite", func(t *testing.T) {
buf := &bytes.Buffer{}
w := armor.NewWriter(buf)
if err := w.Close(); err != nil {
t.Fatal(err)
}
if want := armor.Header + "\n" + armor.Footer + "\n"; buf.String() != want {
t.Errorf("output = %q, want %q", buf.String(), want)
}
})
t.Run("WriteAfterClose", func(t *testing.T) {
w := armor.NewWriter(io.Discard)
if err := w.Close(); err != nil {
t.Fatal(err)
}
if n, err := w.Write([]byte("x")); n != 0 || err == nil {
t.Errorf("Write after Close = %d, %v", n, err)
}
})
}
func testArmor(t *testing.T, size int) {
buf := &bytes.Buffer{}
w := armor.NewWriter(buf)
@@ -134,12 +156,13 @@ func testArmor(t *testing.T, size int) {
}
func FuzzMalleability(f *testing.F) {
tests, err := filepath.Glob("../testdata/testkit/*")
tests, err := fs.ReadDir(agetest.Vectors, ".")
if err != nil {
f.Fatal(err)
}
seeds := 0
for _, test := range tests {
contents, err := os.ReadFile(test)
contents, err := fs.ReadFile(agetest.Vectors, test.Name())
if err != nil {
f.Fatal(err)
}
@@ -149,8 +172,12 @@ func FuzzMalleability(f *testing.F) {
}
if bytes.Contains(header, []byte("armored: yes")) {
f.Add(contents)
seeds++
}
}
if seeds == 0 {
f.Fatal("no armored test vectors")
}
f.Fuzz(func(t *testing.T, data []byte) {
r := armor.NewReader(bytes.NewReader(data))
content, err := io.ReadAll(r)
+10 -4
View File
@@ -24,6 +24,10 @@ Options:
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) }
@@ -37,11 +41,10 @@ func main() {
flag.Parse()
if versionFlag {
if buildInfo, ok := debug.ReadBuildInfo(); ok {
fmt.Println(buildInfo.Main.Version)
return
if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
Version = buildInfo.Main.Version
}
fmt.Println("(unknown)")
fmt.Println(Version)
return
}
@@ -98,6 +101,9 @@ func main() {
case "no":
fmt.Printf("This file does NOT use post-quantum encryption.\n")
fmt.Printf("\n")
case "unknown":
fmt.Printf("It is unknown whether this file uses post-quantum encryption.\n")
fmt.Printf("\n")
}
fmt.Printf("Size breakdown (assuming it decrypts successfully):\n")
fmt.Printf("\n")
+43 -26
View File
@@ -31,8 +31,8 @@ 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.
When generating a key, if the output is not going to a terminal, the public key
is printed to standard error. If OUTPUT already exists, it is not overwritten.
In -y mode, age-keygen reads an identity file from INPUT or from standard
input and writes the corresponding recipient(s) to OUTPUT or to standard
@@ -56,6 +56,10 @@ Examples:
$ age-keygen -y key.txt
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p`
// 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) }
@@ -69,6 +73,15 @@ func main() {
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")
}
@@ -78,13 +91,19 @@ func main() {
if pqFlag && convertFlag {
errorf("-pq cannot be used with -y")
}
if versionFlag {
if buildInfo, ok := debug.ReadBuildInfo(); ok {
fmt.Println(buildInfo.Main.Version)
return
var converted []byte
if convertFlag {
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
}
fmt.Println("(unknown)")
return
converted = convert(in)
}
out := os.Stdout
@@ -101,18 +120,8 @@ func main() {
out = f
}
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
}
if convertFlag {
convert(in, out)
writef(out, "%s", converted)
} else {
if fi, err := out.Stat(); err == nil && fi.Mode().IsRegular() && fi.Mode().Perm()&0004 != 0 {
warning("writing secret key to a world-readable file")
@@ -144,12 +153,18 @@ func generate(out *os.File, pq bool) {
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", r)
fmt.Fprintf(out, "%s\n", i)
writef(out, "# created: %s\n", time.Now().Format(time.RFC3339))
writef(out, "# public key: %s\n", r)
writef(out, "%s\n", i)
}
func convert(in io.Reader, out io.Writer) {
func writef(out io.Writer, format string, v ...any) {
if _, err := fmt.Fprintf(out, format, v...); err != nil {
errorf("failed to write output: %v", err)
}
}
func convert(in io.Reader) []byte {
ids, err := age.ParseIdentities(in)
if err != nil {
errorf("failed to parse input: %v", err)
@@ -157,17 +172,19 @@ func convert(in io.Reader, out io.Writer) {
if len(ids) == 0 {
errorf("no identities found in the input")
}
var out []byte
for _, id := range ids {
switch id := id.(type) {
case *age.X25519Identity:
fmt.Fprintf(out, "%s\n", id.Recipient())
out = append(out, id.Recipient().String()...)
case *age.HybridIdentity:
fmt.Fprintf(out, "%s\n", id.Recipient())
out = append(out, id.Recipient().String()...)
default:
errorf("internal error: unexpected identity type: %T", id)
}
out = append(out, '\n')
}
return out
}
func errorf(format string, v ...any) {
+70
View File
@@ -0,0 +1,70 @@
// Copyright 2019 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 (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"filippo.io/age"
"github.com/rogpeppe/go-internal/testscript"
)
func TestMain(m *testing.M) {
testscript.Main(m, map[string]func(){
"age-keygen": main,
})
}
func unwritable(t *testing.T) *os.File {
t.Helper()
name := filepath.Join(t.TempDir(), "out")
if err := os.WriteFile(name, nil, 0600); err != nil {
t.Fatal(err)
}
f, err := os.Open(name)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { f.Close() })
if _, err := f.Write([]byte("x")); err == nil {
t.Skip("writes to a read-only file descriptor succeed")
}
return f
}
func TestOutputWriteErrors(t *testing.T) {
identity, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
args []string
stdin string
}{
{"generate", nil, ""},
{"generate PQ", []string{"-pq"}, ""},
{"convert", []string{"-y"}, identity.String() + "\n"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stderr := &strings.Builder{}
cmd := exec.Command("age-keygen", tt.args...)
cmd.Stdin = strings.NewReader(tt.stdin)
cmd.Stdout = unwritable(t)
cmd.Stderr = stderr
if err := cmd.Run(); err == nil {
t.Error("age-keygen succeeded")
}
if !strings.Contains(stderr.String(), "failed to write output") {
t.Errorf("stderr = %q", stderr)
}
})
}
}
+23 -4
View File
@@ -7,6 +7,7 @@ import (
"io"
"log"
"os"
"runtime/debug"
"strconv"
"strings"
@@ -67,11 +68,16 @@ 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.
but slower. The age CLI refuses to decrypt files above 22, and so does this
plugin unless AGE_PASSPHRASE_MAX_WORK_FACTOR is set to at least the same value.
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.`
When decrypting, you can set AGE_PASSPHRASE_MAX_WORK_FACTOR to change the
maximum scrypt work factor accepted (between 1 and 30, default 22). This can
be used to avoid very slow decryptions, or to allow slower ones.`
// 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) }
@@ -80,6 +86,19 @@ func main() {
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")
+24 -10
View File
@@ -19,6 +19,7 @@ import (
"slices"
"strings"
"unicode"
"unicode/utf8"
"filippo.io/age"
"filippo.io/age/agessh"
@@ -41,6 +42,7 @@ Options:
-r, --recipient RECIPIENT Encrypt to the specified RECIPIENT. Can be repeated.
-R, --recipients-file PATH Encrypt to recipients listed at PATH. Can be repeated.
-i, --identity PATH Use the identity file at PATH. Can be repeated.
--version Print the version.
INPUT defaults to standard input, and OUTPUT defaults to standard output.
If OUTPUT exists, it will be overwritten.
@@ -98,6 +100,10 @@ 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) }
@@ -136,13 +142,10 @@ func main() {
flag.Parse()
if versionFlag {
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
if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
Version = buildInfo.Main.Version
}
fmt.Println("(unknown)")
fmt.Println(Version)
return
}
@@ -261,8 +264,16 @@ func main() {
}
}
if name := outFlag; name != "" && name != "-" {
outFI, outErr := os.Stat(name)
for _, f := range inUseFiles {
if f == absPath(name) {
same := f == absPath(name)
if !same && outErr == nil {
if fi, err := os.Stat(f); err == nil {
// Catch symlinks and hard links.
same = os.SameFile(fi, outFI)
}
}
if same {
errorf("input and output file are the same: %q", name)
}
}
@@ -285,7 +296,7 @@ func main() {
// Buffer the output to check it's printable.
out = buf
defer func() {
if bytes.ContainsFunc(buf.Bytes(), func(r rune) bool {
if !utf8.Valid(buf.Bytes()) || 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",
@@ -511,10 +522,13 @@ func decrypt(identities []age.Identity, in io.Reader, out io.Writer) {
} 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)
}
// Trigger the lazyOpener even if r is empty, if Copy succeeded.
if _, err := out.Write(nil); err != nil {
errorf("%v", err)
}
}
var lazyScryptIdentity = &LazyScryptIdentity{passphrasePromptForDecryption}
@@ -580,7 +594,7 @@ func (l *lazyOpener) Close() error {
if l.f != nil {
return l.f.Close()
}
return nil
return l.err
}
func absPath(name string) string {
+112
View File
@@ -5,9 +5,11 @@
package main
import (
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
@@ -51,6 +53,116 @@ func (testPlugin) Unwrap(ss []*age.Stanza) ([]byte, error) {
return nil, age.ErrIncorrectIdentity
}
func paddedFile(pad int, lines string) io.Reader {
const chunkSize = 1 << 16
chunk := strings.Repeat("#\n", chunkSize/2)
var readers []io.Reader
if pad%2 != 0 {
readers = append(readers, strings.NewReader("\n"))
pad--
}
for ; pad > chunkSize; pad -= chunkSize {
readers = append(readers, strings.NewReader(chunk))
}
return io.MultiReader(append(readers,
strings.NewReader(chunk[:pad]), strings.NewReader(lines))...)
}
func TestParseFileSizeLimit(t *testing.T) {
const sizeLimit = 16 << 20
const identity = "AGE-SECRET-KEY-1D6K0SGAX3NU66R4GYFZY0UQWCLM3UUSF3CXLW4KXZM342WQSJ82QKU59QJ\n"
const recipient = "age1cy0su9fwf3gf9mw868g5yut09p6nytfmmnktexz2ya5uqg9vl9sss4euqm\n"
tests := []struct {
name string
entry string
parse func(*testing.T, io.Reader) (int, error)
}{
{"identities", identity, func(_ *testing.T, r io.Reader) (int, error) {
ids, err := parseIdentities(r)
return len(ids), err
}},
{"recipients", recipient, func(t *testing.T, r io.Reader) (int, error) {
name := filepath.Join(t.TempDir(), "recipients.txt")
f, err := os.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := io.Copy(f, r); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
recipients, err := parseRecipientsFile(name)
return len(recipients), err
}},
}
for _, tt := range tests {
t.Run(tt.name+"/at limit", func(t *testing.T) {
r := paddedFile(sizeLimit-4*len(tt.entry), strings.Repeat(tt.entry, 4))
n, err := tt.parse(t, r)
if err != nil {
t.Fatal(err)
}
if n != 4 {
t.Errorf("got %d entries, want 4", n)
}
})
t.Run(tt.name+"/over limit", func(t *testing.T) {
r := paddedFile(sizeLimit-4*len(tt.entry), strings.Repeat(tt.entry, 8))
if _, err := tt.parse(t, r); err == nil {
t.Error("expected an error")
}
})
}
}
func TestUnsupportedSSHKey(t *testing.T) {
tests := []struct {
name, key, want string
ok bool
}{
{
"truncated Ed25519",
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH9pO5pz22JZEasoS2LEWWvJiUYI9M6l1uZc31FG",
"", false,
},
{
"type-only RSA",
"ssh-rsa AAAAB3NzaC1yc2E=",
"", false,
},
{
"small RSA",
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCxlfoYpG04TTnmjocggQZI5l0fjvaUMky1ZD5zwktDKAgBj441OAtFj/3m7Gujpxx/8w3jjJoUXVtU+NFbcC972ROpPI4aPq3OR5SmuuR5bCr6efeZlyAEMY/DmbkeUe1TrvdU3VMDGJAvEEzT1wzokKt9PiwbV2jNkdPjaG5LDQ==",
"ssh-rsa", true,
},
{
"invalid Ed25519 point",
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC",
"", false,
},
{
"unknown type",
"totally-not-a-key AAAAEXRvdGFsbHktbm90LWEta2V5",
"totally-not-a-key", true,
},
{
"ECDSA",
"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOSh0Qo5zaBE33EiJnxwIBuZml9Nt9rCfvGi++FniWSk8X/10EO4E7KMgqGLlkCTDNIcbuKcsgfMw1/tifB8aTc=",
"ecdsa-sha2-nistp256", true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := unsupportedSSHKey(tt.key)
if got != tt.want || ok != tt.ok {
t.Errorf("unsupportedSSHKey() = %q, %v; want %q, %v", got, ok, tt.want, tt.ok)
}
})
}
}
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.
+41 -10
View File
@@ -6,6 +6,8 @@ package main
import (
"bufio"
"bytes"
"crypto/rsa"
"encoding/base64"
"fmt"
"io"
@@ -71,7 +73,8 @@ func parseRecipientsFile(name string) ([]age.Recipient, error) {
const recipientFileSizeLimit = 16 << 20 // 16 MiB
const lineLengthLimit = 8 << 10 // 8 KiB, same as sshd(8)
var recs []age.Recipient
scanner := bufio.NewScanner(io.LimitReader(f, recipientFileSizeLimit))
lr := &io.LimitedReader{R: f, N: recipientFileSizeLimit + 1}
scanner := bufio.NewScanner(lr)
var n int
for scanner.Scan() {
n++
@@ -87,7 +90,7 @@ func parseRecipientsFile(name string) ([]age.Recipient, error) {
}
r, err := parseRecipient(line)
if err != nil {
if t, ok := sshKeyType(line); ok {
if t, unsupported := unsupportedSSHKey(line); unsupported {
// Skip unsupported but valid SSH public keys with a warning.
warningf("recipients file %q: ignoring unsupported SSH key of type %q at line %d", name, t, n)
continue
@@ -104,13 +107,16 @@ func parseRecipientsFile(name string) ([]age.Recipient, error) {
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("%q: failed to read recipients file: %v", name, err)
}
if lr.N == 0 {
return nil, fmt.Errorf("%q: recipients file is too long", name)
}
if len(recs) == 0 {
return nil, fmt.Errorf("%q: no recipients found", name)
}
return recs, nil
}
func sshKeyType(s string) (string, bool) {
func unsupportedSSHKey(s string) (string, bool) {
// TODO: also ignore options? And maybe support multiple spaces and tabs as
// field separators like OpenSSH?
fields := strings.Split(s, " ")
@@ -128,7 +134,26 @@ func sshKeyType(s string) (string, bool) {
return "", false
}
if t := fields[0]; t == string(typeBytes) {
return t, true
switch t {
case "ssh-ed25519":
return "", false
case "ssh-rsa":
out, _, _, _, err := ssh.ParseAuthorizedKey([]byte(s))
if err != nil {
return "", false
}
cryptoKey, ok := out.(ssh.CryptoPublicKey)
if !ok {
return "", false
}
rsaKey, ok := cryptoKey.CryptoPublicKey().(*rsa.PublicKey)
if !ok {
return "", false
}
return t, rsaKey.N.BitLen() < 2048
default:
return t, true
}
}
return "", false
}
@@ -155,14 +180,16 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) {
}
b := bufio.NewReader(f)
p, _ := b.Peek(14) // length of "age-encryption" and "-----BEGIN AGE"
peeked := string(p)
const maxWhitespace = 1024
p, _ := b.Peek(maxWhitespace + len(armor.Header))
trimmed := bytes.TrimSpace(p)
switch {
// An age encrypted file, plain or armored.
case peeked == "age-encryption" || peeked == "-----BEGIN AGE":
case bytes.HasPrefix(p, []byte("age-encryption")) ||
bytes.HasPrefix(trimmed, []byte(armor.Header)):
var r io.Reader = b
if peeked == "-----BEGIN AGE" {
if bytes.HasPrefix(trimmed, []byte(armor.Header)) {
r = armor.NewReader(r)
}
const privateKeySizeLimit = 1 << 24 // 16 MiB
@@ -188,7 +215,7 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) {
}}, nil
// Another PEM file, possibly an SSH private key.
case strings.HasPrefix(peeked, "-----BEGIN"):
case bytes.HasPrefix(trimmed, []byte("-----BEGIN")):
const privateKeySizeLimit = 1 << 14 // 16 KiB
contents, err := io.ReadAll(io.LimitReader(b, privateKeySizeLimit))
if err != nil {
@@ -226,7 +253,8 @@ func parseIdentity(s string) (age.Identity, error) {
func parseIdentities(f io.Reader) ([]age.Identity, error) {
const privateKeySizeLimit = 1 << 24 // 16 MiB
var ids []age.Identity
scanner := bufio.NewScanner(io.LimitReader(f, privateKeySizeLimit))
lr := &io.LimitedReader{R: f, N: privateKeySizeLimit + 1}
scanner := bufio.NewScanner(lr)
var n int
for scanner.Scan() {
n++
@@ -249,6 +277,9 @@ func parseIdentities(f io.Reader) ([]age.Identity, error) {
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read identities file: %v", err)
}
if lr.N == 0 {
return nil, fmt.Errorf("identities file is too long")
}
if len(ids) == 0 {
return nil, fmt.Errorf("no identities found")
}
-1
View File
@@ -42,7 +42,6 @@ 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
+16 -1
View File
@@ -2,7 +2,6 @@
# age file password prompt during encryption
[!linux] [!darwin] skip # no pty support
[darwin] [go1.20] skip # https://go.dev/issue/61779
# use an encrypted OpenSSH private key without .pub file
age -R key_ed25519.pub -o ed25519.age input
@@ -60,10 +59,26 @@ ttyout 'Enter passphrase'
! stderr .
cmp stdout input
# armored identity files accept leading whitespace
exec age-keygen -o key.txt
age -e -i key.txt -o test.age input
ttyin newpass
age -p -a -o key.age key.txt
exec cat blank key.age
cp stdout key-with-blank.age
ttyin terminal
age -d -i key-with-blank.age test.age
cmp stdout input
-- input --
test
-- terminal --
password
-- newpass --
password
password
-- blank --
-- key_ed25519 --
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABCuvb97i7
+7
View File
@@ -39,9 +39,16 @@ age -d -i key.txt test.age
cmp stdout input
! stderr .
! exec age-plugin-pq -identity -o mixed-plugin.txt mixed.txt
stderr 'identity #2 is not a post-quantum hybrid identity'
! exists mixed-plugin.txt
-- input --
test
-- key.txt --
# created: 2025-11-17T13:27:37+01:00
# public key: age1pq1044e3hfmq2tltgck54m0gnpv8rfrwkdxjgl38psnwg8zfyv4uqdu63kk0u94ztt0q35k9dzuwgv405kxq6due2sre2kfsjftf7k2vz2mdpetku9pufy9d6ny6k5ss0cghpxdxrux0yd5xy40f8fu8ch9xn2rczyek2pqetqk0l9fqj0e3hfhkyfmcf9j6cpe5mf5stt9cymfq3xm276x4sramhn8srr42g78q9rkmqesevy33p4kdhkuggl99x9zx7j05uflx3s8q558xeaq9q9ctxqx52rzg6yffwykxngvxf7g3n83ddawagq2lnk59kcnexm54jrsmkjk9nemlge9v6p7fxu5z6yvq3ghpet8ww2c3h9fylvjx6j9njwz744376ap5r3xcpzh7qlk0kq3gknzfqhmxmjqypf6xz6geffdmy25apzsfr4ce0x825njkf62mgzx0x9n3zt0490u4dnt4468zse4zvd9sv0ycx2u2wsseuy4yymrg0nmfuxcwq9q0jykq7g5f59vunf95vnmcw336ksrh43fgapsd40u4227ymx56xtudtrte39twnq6fdfdj4p9pgx7pcqdk953za76fqcnvatx2cmywurag7ky4y8ecq5lvf25rydhxg6gmcn6vf78vtsqw79hp3mzv2u4v4jzn0nk50kl4gpv7k60cgz79c6k30jzy8dyetqtwg04tguxkv2p4hqj02yvsz2szaqdn9flu24rhth3v0t33023nwy27gt0x32eqpa5tpz6ygz3yzcsa3w889drpynzd5hlywuk9dwgafuax7upfnnxch4znrx4vactasg32u7vl886pxfswskk7wjgqkggrewtnaecfusl3nmg0xxh8e5nl5eft0xqwj5rk9ajn2j6yxehhgg5k6kpw5pk382hxmpu8wcd3rqx6m8905c7f8v0x2z9gyduxtqp2fdy9w2z4g2rrgu4d5cqes5tnwag007h2f326s3j9p6970xyd73rv5awx27zzezrax467c6vuye62ha88pzhw9xytjpezw3vg3l6x4k9rxwm9f6lwj6j7r9makg59rhgt0355s3jx9vw3raz5qdk5cnpny56q5h3dyd75suskuz63wmmfp9333tx4ry38vnh7jxy0j0cka99y7v2dmyjldjy0mqjetcefsmmg3pw0h9ll0rtskpmxz2cs8vmx6lu4v5rjtzpzgaqt46cwvnmhy5e3ye0tv6sre342lwg0tfd0yjkfz2haqc3hprpqf5ce8c9lx076dnk5cukn3tu38jgprrxt9sk9nqzt8grvkc4qzlg8jjf638v07ej9cf4eu48kphrgyjy83qm822xq8v9k22zwa7txr439x74j2f9ay7frkwtfdwn02enyx8stjpr4wgg25n5tj7uxyx0dqt3hnrqf7dcgt9fqnkjgemjfzyck6pawewptcayxm49sq3477f4cvm2p8fmg27rcmwm5l2vmzvdmymvkrr3aywu04y3zlhs7awu3r9u9lseheqpffvm2xf8y6w2qaj9g22pcu0sdsnqseusrxq9awru5lyqxnzftlslgpe3njegxkze98rveswp3u0xgx69wkg38vgp4k55lwwgv5l7ccv503mh9q23nud4xtm499xq276u40rxyrzaqfyuz2f3v5qr2vq00xs4g3ruynmq0c6nprvjcwqc5zrdn4cnzeeuwumjvqfjdpxr739n4cqcwytc8pjmfcj2qs3l2a8p5fzq7nvtng2jvvklx0x5ypnlkeuegsm587f6d4dkwel2es0lwwt88y2jdg8enjyph02tnjyndlx34h52fngjk96ggtu6vcxqkreqcxs4gwa8ww40t0kzlxfzyfhtjmfdm5fcrr5pt2xm4yslfynr0h9wkranhyadxf33kjn9xpg2fq5hnlq0
AGE-SECRET-KEY-PQ-16XDSLZ3XCSZ3236YJ5J0T9NAPLZTP96LJKQCVHJYUDTQVJR5J5PQTDPQCX
-- mixed.txt --
AGE-SECRET-KEY-PQ-16XDSLZ3XCSZ3236YJ5J0T9NAPLZTP96LJKQCVHJYUDTQVJR5J5PQTDPQCX
AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0
+4
View File
@@ -23,3 +23,7 @@ stdout age1pq1
exec age-keygen -y key.txt
stdout age1pq1
! exec age-keygen -y -o missing.txt missing
stderr 'failed to open input file'
! exists missing.txt
+24 -1
View File
@@ -25,6 +25,22 @@ age -d -i key.txt -o new empty.age
! stderr .
cmp new empty
# Don't create or truncate the output if the payload fails authentication.
! age -o test.out -d -i key.txt corrupt.age
stderr 'failed to decrypt and authenticate payload chunk'
! exists test.out
cp input existing
! age -o existing -d -i key.txt corrupt.age
stderr 'failed to decrypt and authenticate payload chunk'
cmp existing input
# Report output creation errors for an empty plaintext.
! age -o missingdir/test.out -d -i key.txt empty.age
stderr 'missingdir/test.out'
! exists missingdir/test.out
! age -o missingdir/test.out -d -i key.txt test.age
stderr 'missingdir/test.out'
# https://github.com/FiloSottile/age/issues/491
cp input inputcopy
! age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o inputcopy inputcopy
@@ -44,7 +60,6 @@ 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
@@ -68,6 +83,14 @@ wrong
-- input --
age
-- empty --
-- corrupt.age --
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBDMXYwVmsyU3dyc1NPdUF1
S1ZKRXhVMXFKWDdyd2ZHajZyUkRDRHBad20wCk93WjZOS0tOZjBNSW5Ka2ErUFRz
K0gzU1pzWEdqS3EvZWlmbUE1ZE95V1EKLS0tIEZBTW81V3RhWk1xVVhoMnA3UDRl
YSs2QW5zeHp6ZFk4M1dSc0pmeld6VWcK2XikoRl4QMDKbFQRZXWCl/A6Z5nvCK3a
RaZyUlKtXBnSTaVCMKRb
-----END AGE ENCRYPTED FILE-----
-- key.txt --
# created: 2021-02-02T13:09:43+01:00
# public key: age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef
+61
View File
@@ -0,0 +1,61 @@
[!symlink] skip # no symlink support
age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o test.age input
# The output is a symlink to the input.
cp input inputcopy
symlink inputlink -> inputcopy
! age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o inputlink inputcopy
stderr 'input and output file are the same'
cmp inputcopy input
# The input is a symlink to the output.
! age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o inputcopy inputlink
stderr 'input and output file are the same'
cmp inputcopy input
# The output goes through a symlinked directory.
mkdir dir
cp input dir/inputcopy
symlink dirlink -> dir
! age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o dirlink/inputcopy dir/inputcopy
stderr 'input and output file are the same'
cmp dir/inputcopy input
# The output is a symlink to the identity file.
cp key.txt keycopy
symlink keylink -> keycopy
! age -d -i keycopy -o keylink test.age
stderr 'input and output file are the same'
cmp keycopy key.txt
# A distinct existing file is still a valid output.
cp input other
age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o other inputcopy
! cmp other input
cmp inputcopy input
# A dangling symlink is still a valid output.
symlink new -> notexist
age -d -i key.txt -o new test.age
cmp notexist input
# testscript has no hard link command, so the rest needs an external ln.
[!exec:ln] stop
exec ln inputcopy inputhard
! age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o inputhard inputcopy
stderr 'input and output file are the same'
cmp inputcopy input
exec ln keycopy keyhard
! age -d -i keycopy -o keyhard test.age
stderr 'input and output file are the same'
cmp keycopy key.txt
-- input --
age
-- key.txt --
# created: 2021-02-02T13:09:43+01:00
# public key: age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef
AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0
-1
View File
@@ -1,5 +1,4 @@
[!linux] [!darwin] skip # no pty support
[darwin] [go1.20] skip # https://go.dev/issue/61779
# encrypt with a provided passphrase
stdin input
-1
View File
@@ -1,5 +1,4 @@
[!linux] [!darwin] skip # no pty support
[darwin] [go1.20] skip # https://go.dev/issue/61779
# controlling terminal is used instead of stdin/stderr
ttyin terminal
+1 -1
View File
@@ -1,6 +1,6 @@
.\" generated with Ronn-NG/v0.9.1
.\" http://github.com/apjanke/ronn-ng/tree/0.9.1
.TH "AGE\-INSPECT" "1" "December 2025" ""
.TH "AGE\-INSPECT" "1" "August 2026" ""
.SH "NAME"
\fBage\-inspect\fR \- inspect age(1) encrypted files
.SH "SYNOPSIS"
+1 -1
View File
@@ -195,7 +195,7 @@ Tip: for machine-readable output, use --json.
<ol class='man-decor man-foot man foot'>
<li class='tl'></li>
<li class='tc'>December 2025</li>
<li class='tc'>August 2026</li>
<li class='tr'>age-inspect(1)</li>
</ol>
+2 -2
View File
@@ -1,6 +1,6 @@
.\" generated with Ronn-NG/v0.9.1
.\" http://github.com/apjanke/ronn-ng/tree/0.9.1
.TH "AGE\-KEYGEN" "1" "December 2025" ""
.TH "AGE\-KEYGEN" "1" "August 2026" ""
.SH "NAME"
\fBage\-keygen\fR \- generate age(1) key pairs
.SH "SYNOPSIS"
@@ -53,7 +53,7 @@ AGE\-SECRET\-KEY\-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9
Write a new post\-quantum identity to \fBkey\.txt\fR:
.IP "" 4
.nf
$ age\-keygen \-o key\.txt
$ age\-keygen \-pq \-o key\.txt
Public key: age1pq1cd[\|\.\|\.\|\. 1950 more characters \|\.\|\.\|\.]
.fi
.IP "" 0
+2 -2
View File
@@ -130,7 +130,7 @@ AGE-SECRET-KEY-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9
<p>Write a new post-quantum identity to <code>key.txt</code>:</p>
<pre><code>$ age-keygen -o key.txt
<pre><code>$ age-keygen -pq -o key.txt
Public key: age1pq1cd[... 1950 more characters ...]
</code></pre>
@@ -150,7 +150,7 @@ age1pq1cd[... 1950 more characters ...]
<ol class='man-decor man-foot man foot'>
<li class='tl'></li>
<li class='tc'>December 2025</li>
<li class='tc'>August 2026</li>
<li class='tr'>age-keygen(1)</li>
</ol>
+1 -1
View File
@@ -52,7 +52,7 @@ Generate a new traditional identity:
Write a new post-quantum identity to `key.txt`:
$ age-keygen -o key.txt
$ age-keygen -pq -o key.txt
Public key: age1pq1cd[... 1950 more characters ...]
Convert an identity to a recipient:
+3 -3
View File
@@ -1,6 +1,6 @@
.\" generated with Ronn-NG/v0.9.1
.\" http://github.com/apjanke/ronn-ng/tree/0.9.1
.TH "AGE\-PLUGIN\-BATCHPASS" "1" "December 2025" ""
.TH "AGE\-PLUGIN\-BATCHPASS" "1" "August 2026" ""
.SH "NAME"
\fBage\-plugin\-batchpass\fR \- non\-interactive passphrase encryption plugin for age(1)
.SH "SYNOPSIS"
@@ -56,10 +56,10 @@ The passphrase to use for encryption or decryption\. Mutually exclusive with \fB
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\.
The scrypt work factor to use when encrypting\. Must be between 1 and 30\. Default is 18\. Higher values are more secure but slower\. The age CLI refuses to decrypt files above 22, and so does this plugin unless \fBAGE_PASSPHRASE_MAX_WORK_FACTOR\fR is set to at least the same value\.
.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\.
The maximum scrypt work factor to accept when decrypting\. Must be between 1 and 30\. Default is 22\. Can be used to avoid very slow decryptions, or to allow slower ones\.
.SH "EXAMPLES"
Encrypt a file with a passphrase:
.IP "" 4
+6 -4
View File
@@ -140,11 +140,13 @@ persisted in the shell history or leaked to other users on multi-user systems.</
<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>
Higher values are more secure but slower.
The age CLI refuses to decrypt files above 22, and so does this plugin
unless <code>AGE_PASSPHRASE_MAX_WORK_FACTOR</code> is set to at least the same value.</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>
Must be between 1 and 30. Default is 22.
Can be used to avoid very slow decryptions, or to allow slower ones.</dd>
</dl>
<h2 id="EXAMPLES">EXAMPLES</h2>
@@ -174,7 +176,7 @@ persisted in the shell history or leaked to other users on multi-user systems.</
<ol class='man-decor man-foot man foot'>
<li class='tl'></li>
<li class='tc'>December 2025</li>
<li class='tc'>August 2026</li>
<li class='tr'>age-plugin-batchpass(1)</li>
</ol>
+4 -2
View File
@@ -65,11 +65,13 @@ persisted in the shell history or leaked to other users on multi-user systems.
The scrypt work factor to use when encrypting.
Must be between 1 and 30. Default is 18.
Higher values are more secure but slower.
The age CLI refuses to decrypt files above 22, and so does this plugin
unless `AGE_PASSPHRASE_MAX_WORK_FACTOR` is set to at least the same value.
* `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.
Must be between 1 and 30. Default is 22.
Can be used to avoid very slow decryptions, or to allow slower ones.
## EXAMPLES
+1 -1
View File
@@ -1,6 +1,6 @@
.\" generated with Ronn-NG/v0.9.1
.\" http://github.com/apjanke/ronn-ng/tree/0.9.1
.TH "AGE" "1" "December 2025" ""
.TH "AGE" "1" "August 2026" ""
.SH "NAME"
\fBage\fR \- simple, modern, and secure file encryption
.SH "SYNOPSIS"
+1 -1
View File
@@ -461,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'>December 2025</li>
<li class='tc'>August 2026</li>
<li class='tr'>age(1)</li>
</ol>
+26 -18
View File
@@ -30,12 +30,16 @@ 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 {
log.Fatal(err)
errorf("failed to create plugin: %v", err)
}
p.RegisterFlags(nil)
@@ -50,11 +54,10 @@ func main() {
flag.Parse()
if versionFlag {
if buildInfo, ok := debug.ReadBuildInfo(); ok {
fmt.Println(buildInfo.Main.Version)
return
if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
Version = buildInfo.Main.Version
}
fmt.Println("(unknown)")
fmt.Println(Version)
return
}
@@ -63,6 +66,17 @@ func main() {
errorf("too many arguments")
}
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
}
converted := convert(in)
out := os.Stdout
if outFlag != "" {
f, err := os.OpenFile(outFlag, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
@@ -79,18 +93,9 @@ 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")
}
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
if _, err := out.Write(converted); err != nil {
errorf("failed to write output: %v", err)
}
convert(in, out)
return
}
@@ -120,11 +125,12 @@ func main() {
os.Exit(p.Main())
}
func convert(in io.Reader, out io.Writer) {
func convert(in io.Reader) []byte {
ids, err := age.ParseIdentities(in)
if err != nil {
errorf("failed to parse identities: %v", err)
}
var out []byte
for i, id := range ids {
hybridID, ok := id.(*age.HybridIdentity)
if !ok {
@@ -134,8 +140,10 @@ func convert(in io.Reader, out io.Writer) {
if err != nil {
errorf("failed to decode identity #%d: %v", i+1, err)
}
fmt.Fprintln(out, plugin.EncodeIdentity("pq", data))
out = append(out, plugin.EncodeIdentity("pq", data)...)
out = append(out, '\n')
}
return out
}
func errorf(format string, v ...any) {
+19
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"log"
"os"
"runtime/debug"
"filippo.io/age"
"filippo.io/age/plugin"
@@ -18,6 +19,10 @@ 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) }
@@ -25,8 +30,22 @@ func main() {
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())
}
+19
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"log"
"os"
"runtime/debug"
"filippo.io/age"
"filippo.io/age/plugin"
@@ -19,6 +20,10 @@ 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) }
@@ -26,8 +31,22 @@ func main() {
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())
}
+9 -9
View File
@@ -1,22 +1,22 @@
module filippo.io/age
go 1.24.0
go 1.25.0
// Release build version.
toolchain go1.25.5
toolchain go1.27.0
require (
filippo.io/edwards25519 v1.1.0
filippo.io/edwards25519 v1.2.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
golang.org/x/crypto v0.55.0
golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0
)
// Test dependencies.
require (
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
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d
github.com/rogpeppe/go-internal v1.16.0
golang.org/x/tools v0.49.0 // indirect
)
+14 -14
View File
@@ -1,18 +1,18 @@
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=
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d h1:Blprhc2SbChNZtWcU+BLTM4YdoqYAS9V7cJgOwJKyAs=
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
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=
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
+8 -1
View File
@@ -154,14 +154,21 @@ func Decode(s string) (hrp string, data []byte, err error) {
return "", nil, fmt.Errorf("invalid character human-readable part: s[%d]=%d", p, c)
}
}
s = strings.ToLower(s)
for p, c := range s[pos+1:] {
// Fold ASCII explicitly. Unicode case folding can turn a non-ASCII
// rune into a shorter valid charset member.
if c >= 'A' && c <= 'Z' {
c += 'a' - 'A'
}
d := strings.IndexRune(charset, c)
if d == -1 {
return "", nil, fmt.Errorf("invalid character data part: s[%d]=%v", p, c)
}
data = append(data, byte(d))
}
if len(data) < 6 {
return "", nil, fmt.Errorf("data part too short")
}
if !verifyChecksum(hrp, data) {
return "", nil, fmt.Errorf("invalid checksum")
}
+20
View File
@@ -93,3 +93,23 @@ func TestBech32(t *testing.T) {
}
}
}
func TestDecodeShortDataPart(t *testing.T) {
kelvin := string(rune(0x212A))
for _, s := range []string{
"AA3100AC" + kelvin,
"BK1" + kelvin + "0JFM",
"AQM1KZCML",
} {
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("Decode(%+q) panicked: %v", s, r)
}
}()
if _, _, err := bech32.Decode(s); err == nil {
t.Errorf("Decode(%+q) = nil error, want error", s)
}
}()
}
}
+114 -16
View File
@@ -76,6 +76,7 @@ func (w *WrappedBase64Encoder) writeWrapped(p []byte) (int, error) {
if w.buf.Len() != 0 {
panic("age: internal error: non-empty WrappedBase64Encoder.buf")
}
total := len(p)
for len(p) > 0 {
toWrite := min(ColumnsPerLine-(w.written%ColumnsPerLine), len(p))
n, _ := w.buf.Write(p[:toWrite])
@@ -91,7 +92,7 @@ func (w *WrappedBase64Encoder) writeWrapped(p []byte) (int, error) {
// are not recoverable anyway.
return 0, err
}
return len(p), nil
return total, nil
}
// LastLineIsEmpty returns whether the last output line was empty, either
@@ -108,10 +109,21 @@ var stanzaPrefix = []byte("->")
var footerPrefix = []byte("---")
func (r *Stanza) Marshal(w io.Writer) error {
if !isValidString(r.Type) {
return fmt.Errorf("invalid stanza type: %q", r.Type)
}
for _, a := range r.Args {
if !isValidString(a) {
return fmt.Errorf("invalid stanza argument: %q", a)
}
}
if _, err := w.Write(stanzaPrefix); err != nil {
return err
}
for _, a := range append([]string{r.Type}, r.Args...) {
if _, err := io.WriteString(w, " "+r.Type); err != nil {
return err
}
for _, a := range r.Args {
if _, err := io.WriteString(w, " "+a); err != nil {
return err
}
@@ -131,6 +143,9 @@ func (r *Stanza) Marshal(w io.Writer) error {
}
func (h *Header) MarshalWithoutMAC(w io.Writer) error {
if len(h.Recipients) == 0 {
return errors.New("no recipient stanzas")
}
if _, err := io.WriteString(w, intro); err != nil {
return err
}
@@ -153,7 +168,9 @@ func (h *Header) Marshal(w io.Writer) error {
}
type StanzaReader struct {
r *bufio.Reader
r interface {
ReadBytes(delim byte) ([]byte, error)
}
err error
}
@@ -181,11 +198,6 @@ func (r *StanzaReader) ReadStanza() (s *Stanza, err error) {
if prefix != string(stanzaPrefix) || len(args) < 1 {
return nil, fmt.Errorf("malformed stanza: %q", line)
}
for _, a := range args {
if !isValidString(a) {
return nil, fmt.Errorf("malformed stanza: %q", line)
}
}
s.Type = args[0]
s.Args = args[1:]
@@ -217,6 +229,44 @@ type ParseError struct {
err error
}
const (
maxHeaderBytes = 2 << 20
maxRecipientStanzas = 1024
maxStanzaArgs = 128
)
type headerReader struct {
r *bufio.Reader
n int
}
func (r *headerReader) ReadBytes(delim byte) ([]byte, error) {
var line []byte
for {
frag, err := r.r.ReadSlice(delim)
if len(frag) > maxHeaderBytes-r.n {
return nil, errorf("header exceeds 2 MiB")
}
r.n += len(frag)
line = append(line, frag...)
if err != bufio.ErrBufferFull {
return line, err
}
}
}
func (r *headerReader) ReadString(delim byte) (string, error) {
line, err := r.ReadBytes(delim)
return string(line), err
}
func (r *headerReader) Peek(n int) ([]byte, error) {
if r.n+n > maxHeaderBytes {
return nil, errorf("header exceeds 2 MiB")
}
return r.r.Peek(n)
}
func (e *ParseError) Error() string {
return "parsing age header: " + e.err.Error()
}
@@ -229,31 +279,62 @@ func errorf(format string, a ...any) error {
return &ParseError{fmt.Errorf(format, a...)}
}
// describeIntro returns a quoted description of a bad intro line, or an empty
// string if the line contains recognizable private key material.
func describeIntro(line string) string {
for _, prefix := range []string{"AGE-SECRET-KEY-", "AGE-PLUGIN-"} {
if strings.HasPrefix(line, prefix) {
return ""
}
}
// Preserve enough context to diagnose a mangled intro without echoing an
// arbitrarily long first line.
return fmt.Sprintf("%q", line[:min(len(line), len(intro))])
}
// Parse returns the header and a Reader that begins at the start of the
// payload.
func Parse(input io.Reader) (*Header, io.Reader, error) {
h := &Header{}
rr := bufio.NewReader(input)
hr := &headerReader{r: rr}
line, err := rr.ReadString('\n')
line, err := hr.ReadString('\n')
if err == io.EOF {
return nil, nil, errorf("file is empty")
if len(line) == 0 {
return nil, nil, errorf("file is empty")
}
if description := describeIntro(line); description != "" {
return nil, nil, errorf("unexpected EOF reading intro: %s", description)
}
return nil, nil, errorf("unexpected EOF reading intro, expected %q", intro)
} else if err != nil {
// headerReader errors are already ParseErrors; don't nest the prefix.
if _, ok := err.(*ParseError); ok {
return nil, nil, err
}
return nil, nil, errorf("failed to read intro: %w", err)
}
if line != intro {
return nil, nil, errorf("unexpected intro: %q", line)
if description := describeIntro(line); description != "" {
return nil, nil, errorf("unexpected intro: %s", description)
}
return nil, nil, errorf("unexpected intro, expected %q", intro)
}
sr := NewStanzaReader(rr)
sr := &StanzaReader{r: hr}
for {
peek, err := rr.Peek(len(footerPrefix))
peek, err := hr.Peek(len(footerPrefix))
if err != nil {
// headerReader errors are already ParseErrors; don't nest the prefix.
if _, ok := err.(*ParseError); ok {
return nil, nil, err
}
return nil, nil, errorf("failed to read header: %w", err)
}
if bytes.Equal(peek, footerPrefix) {
line, err := rr.ReadBytes('\n')
line, err := hr.ReadBytes('\n')
if err != nil {
return nil, nil, fmt.Errorf("failed to read header: %w", err)
}
@@ -268,6 +349,9 @@ func Parse(input io.Reader) (*Header, io.Reader, error) {
}
break
}
if len(h.Recipients) == maxRecipientStanzas {
return nil, nil, errorf("header contains more than %d recipient stanzas", maxRecipientStanzas)
}
s, err := sr.ReadStanza()
if err != nil {
@@ -275,6 +359,9 @@ func Parse(input io.Reader) (*Header, io.Reader, error) {
}
h.Recipients = append(h.Recipients, s)
}
if len(h.Recipients) == 0 {
return nil, nil, errorf("no recipient stanzas")
}
// If input is a bufio.Reader, rr might be equal to input because
// bufio.NewReader short-circuits. In this case we can just return it (and
@@ -293,8 +380,19 @@ func Parse(input io.Reader) (*Header, io.Reader, error) {
func splitArgs(line []byte) (string, []string) {
l := strings.TrimSuffix(string(line), "\n")
parts := strings.Split(l, " ")
return parts[0], parts[1:]
prefix, rest, ok := strings.Cut(l, " ")
if !ok {
return l, nil
}
var args []string
for arg := range strings.SplitSeq(rest, " ") {
if !isValidString(arg) || len(args) > maxStanzaArgs {
return l, nil
}
args = append(args, arg)
}
return prefix, args
}
func isValidString(s string) bool {
+216 -4
View File
@@ -7,12 +7,15 @@
package format_test
import (
"bufio"
"bytes"
"errors"
"io"
"os"
"path/filepath"
"io/fs"
"strings"
"testing"
agetest "c2sp.org/CCTV/age"
"filippo.io/age/internal/format"
)
@@ -41,15 +44,143 @@ func TestStanzaMarshal(t *testing.T) {
if exp := "-> test 1 2 3\nQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB\n\n"; buf.String() != exp {
t.Errorf("wrong 64 columns stanza encoding: expected %q, got %q", exp, buf.String())
}
for _, s := range []*format.Stanza{
{Type: ""},
{Type: "test", Args: []string{""}},
{Type: "test", Args: []string{"a b"}},
{Type: "test", Args: []string{"a\nb"}},
{Type: "test", Args: []string{"café"}},
} {
buf.Reset()
if err := s.Marshal(buf); err == nil {
t.Errorf("Marshal accepted type %q, args %q", s.Type, s.Args)
}
if buf.Len() != 0 {
t.Errorf("Marshal wrote %d bytes", buf.Len())
}
}
}
func TestHeaderMarshalNoStanzas(t *testing.T) {
buf := &bytes.Buffer{}
if err := (&format.Header{}).Marshal(buf); err == nil {
t.Fatal("Marshal accepted a header with no stanzas")
}
if buf.Len() != 0 {
t.Errorf("Marshal wrote %d bytes", buf.Len())
}
}
func TestParseLimits(t *testing.T) {
const (
intro = "age-encryption.org/v1\n"
maxHeaderBytes = 2 << 20
)
footer := "--- " + format.EncodeToString(make([]byte, 32)) + "\n"
t.Run("header size", func(t *testing.T) {
makeHeader := func(size int) []byte {
const opening = "-> test "
fixed := len(intro) + len(opening) + len("\n\n") + len(footer)
return []byte(intro + opening + strings.Repeat("a", size-fixed) + "\n\n" + footer)
}
if _, _, err := format.Parse(bytes.NewReader(makeHeader(maxHeaderBytes))); err != nil {
t.Fatalf("maximum-size header was rejected: %v", err)
}
_, _, err := format.Parse(bytes.NewReader(makeHeader(maxHeaderBytes + 1)))
if err == nil || !strings.Contains(err.Error(), "header exceeds 2 MiB") {
t.Fatalf("unexpected oversized-header error: %v", err)
}
var parseError *format.ParseError
if !errors.As(err, &parseError) {
t.Errorf("oversized-header error is not a ParseError: %T", err)
}
if strings.Count(err.Error(), "parsing age header") != 1 {
t.Errorf("oversized-header error nests ParseError prefixes: %v", err)
}
// An intro line that exceeds the limit before any newline.
_, _, err = format.Parse(bytes.NewReader(bytes.Repeat([]byte{'a'}, maxHeaderBytes+1)))
if err == nil || !strings.Contains(err.Error(), "header exceeds 2 MiB") {
t.Fatalf("unexpected oversized-intro error: %v", err)
}
if !errors.As(err, &parseError) {
t.Errorf("oversized-intro error is not a ParseError: %T", err)
}
if strings.Count(err.Error(), "parsing age header") != 1 {
t.Errorf("oversized-intro error nests ParseError prefixes: %v", err)
}
})
t.Run("recipient stanzas", func(t *testing.T) {
makeHeader := func(stanzas int) []byte {
return []byte(intro + strings.Repeat("-> test\n\n", stanzas) + footer)
}
if _, _, err := format.Parse(bytes.NewReader(makeHeader(0))); err == nil || !strings.Contains(err.Error(), "no recipient stanzas") {
t.Fatalf("unexpected zero-stanza error: %v", err)
}
if _, _, err := format.Parse(bytes.NewReader(makeHeader(1024))); err != nil {
t.Fatalf("header with 1024 stanzas was rejected: %v", err)
}
_, _, err := format.Parse(bytes.NewReader(makeHeader(1025)))
if err == nil || !strings.Contains(err.Error(), "more than 1024 recipient stanzas") {
t.Fatalf("unexpected stanza-limit error: %v", err)
}
})
t.Run("recipient stanza arguments", func(t *testing.T) {
makeHeader := func(args int) []byte {
return []byte(intro + "-> test" + strings.Repeat(" a", args) + "\n\n" + footer)
}
if _, _, err := format.Parse(bytes.NewReader(makeHeader(128))); err != nil {
t.Fatalf("stanza with 128 arguments was rejected: %v", err)
}
_, _, err := format.Parse(bytes.NewReader(makeHeader(129)))
if err == nil {
t.Fatal("stanza with 129 arguments was accepted")
}
})
t.Run("payload is not limited", func(t *testing.T) {
header := []byte(intro + "-> test\n\n" + footer)
want := bytes.Repeat([]byte("p"), maxHeaderBytes+1)
_, payload, err := format.Parse(io.MultiReader(bytes.NewReader(header), bytes.NewReader(want)))
if err != nil {
t.Fatal(err)
}
got, err := io.ReadAll(payload)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, want) {
t.Error("payload was truncated or modified")
}
})
t.Run("bufio reader is preserved", func(t *testing.T) {
header := []byte(intro + "-> test\n\n" + footer)
input := bufio.NewReader(bytes.NewReader(append(header, "payload"...)))
_, payload, err := format.Parse(input)
if err != nil {
t.Fatal(err)
}
if payload != input {
t.Error("Parse did not return the input bufio.Reader")
}
})
}
func FuzzMalleability(f *testing.F) {
tests, err := filepath.Glob("../../testdata/testkit/*")
tests, err := fs.ReadDir(agetest.Vectors, ".")
if err != nil {
f.Fatal(err)
}
for _, test := range tests {
contents, err := os.ReadFile(test)
contents, err := fs.ReadFile(agetest.Vectors, test.Name())
if err != nil {
f.Fatal(err)
}
@@ -59,6 +190,9 @@ func FuzzMalleability(f *testing.F) {
}
f.Add(contents)
}
if len(tests) == 0 {
f.Fatal("no test vectors")
}
f.Fuzz(func(t *testing.T, data []byte) {
h, payload, err := format.Parse(bytes.NewReader(data))
if err != nil {
@@ -82,3 +216,81 @@ func FuzzMalleability(f *testing.F) {
}
})
}
const secret = "AGE-SECRET-KEY-1NOTAREALKEYNOTAREALKEYNOTAREALKEYNOTAREALKEYNOTAREALKEYNOTA"
const pluginSecret = "AGE-PLUGIN-PQ-1NOTAREALKEYNOTAREALKEYNOTAREALKEYNOTAREALKEYNOTAREALKEYNOTA"
func TestParseIntroErrorIsNotSecret(t *testing.T) {
for _, test := range []struct {
name string
input string
secret bool
}{
{"identity", secret + "\n", true},
{"identity, no newline", secret, true},
{"plugin identity", pluginSecret + "\n", true},
{"plugin identity, no newline", pluginSecret, true},
{"truncated intro", "age-encryption.org/v1", false},
{"binary", "\x00\x01\x02", false},
} {
t.Run(test.name, func(t *testing.T) {
_, _, err := format.Parse(strings.NewReader(test.input))
if err == nil {
t.Fatal("expected an error")
}
if strings.Contains(err.Error(), "file is empty") {
t.Errorf("error says the file is empty, but it is %d bytes: %v",
len(test.input), err)
}
// Check substrings, not just the key prefix.
if test.secret {
key := strings.TrimSuffix(test.input, "\n")
for i := 0; i+17 <= len(key); i++ {
if strings.Contains(err.Error(), key[i:i+17]) {
t.Errorf("error includes %q, a run of the first line, "+
"which is a private key: %v", key[i:i+17], err)
break
}
}
}
})
}
// Preserve the error for empty files (#416).
t.Run("empty", func(t *testing.T) {
_, _, err := format.Parse(strings.NewReader(""))
if err == nil {
t.Fatal("expected an error")
}
if !strings.Contains(err.Error(), "file is empty") {
t.Errorf("expected an empty file error, got: %v", err)
}
})
}
func TestParseIntroErrorShowsMangling(t *testing.T) {
for _, test := range []struct {
name string
input string
want string
}{
{"crlf", "age-encryption.org/v1\r\n", `\r`},
{"utf8 bom", "\xef\xbb\xbfage-encryption.org/v1\n", `\ufeff`},
{"utf16be", "\x00a\x00g\x00e\x00-\x00e\x00n\x00c\x00r\x00y\x00p\x00", `\x00a\x00g\x00e`},
{"trailing space", "age-encryption.org/v1 \n", `v1 `},
{"wrong version", "age-encryption.org/v2\n", "v2"},
{"leading blank line", "\nage-encryption.org/v1\n", `"\n"`},
} {
t.Run(test.name, func(t *testing.T) {
_, _, err := format.Parse(strings.NewReader(test.input))
if err == nil {
t.Fatal("expected an error")
}
if !strings.Contains(err.Error(), test.want) {
t.Errorf("error does not show the mangling: got %v, want it to contain %q",
err, test.want)
}
})
}
}
+10 -7
View File
@@ -21,7 +21,7 @@ type Metadata struct {
Header int64 `json:"header"`
Armor int64 `json:"armor"`
Overhead int64 `json:"overhead"`
// Currently, we don't do any padding, not MinPayload == MaxPayload and
// Currently, we don't do any padding, so MinPayload == MaxPayload and
// MinPadding == MaxPadding == 0, but that might change in the future.
MinPayload int64 `json:"min_payload"`
MaxPayload int64 `json:"max_payload"`
@@ -33,7 +33,7 @@ type Metadata struct {
func Inspect(r io.Reader, fileSize int64) (*Metadata, error) {
data := &Metadata{
Version: "age-encryption.org/v1",
Postquantum: "unknown",
Postquantum: "yes",
}
tr := &trackReader{r: r}
@@ -61,11 +61,13 @@ func Inspect(r io.Reader, fileSize int64) (*Metadata, error) {
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":
case "X25519", "ssh-rsa", "ssh-ed25519", "p256tag", "piv-p256":
data.Postquantum = "no"
case "mlkem768x25519", "scrypt", "age-encryption.org/mlkem768p256tag":
case "mlkem768x25519", "scrypt", "mlkem768p256tag":
// Keep "yes".
default:
if data.Postquantum != "no" {
data.Postquantum = "yes"
data.Postquantum = "unknown"
}
}
}
@@ -103,12 +105,13 @@ type trackReader struct {
}
func (tr *trackReader) Read(p []byte) (int, error) {
if tr.done {
return 0, io.EOF
}
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
}
+113
View File
@@ -1,12 +1,125 @@
package inspect
import (
"bytes"
"fmt"
"io"
"testing"
"filippo.io/age/internal/format"
"filippo.io/age/internal/stream"
)
// buildFile serializes a header with the given stanza types,
// followed by the minimal valid encrypted payload (a 16-byte stream nonce
// and a single empty ChaCha20-Poly1305 chunk).
func buildFile(t *testing.T, stanzaTypes ...string) []byte {
t.Helper()
hdr := &format.Header{MAC: make([]byte, 32)}
for _, stanzaType := range stanzaTypes {
hdr.Recipients = append(hdr.Recipients, &format.Stanza{Type: stanzaType})
}
buf := &bytes.Buffer{}
if err := hdr.Marshal(buf); err != nil {
t.Fatalf("Header.Marshal: %v", err)
}
// Append nonce (16 bytes) + poly1305 tag for empty chunk (16 bytes).
buf.Write(make([]byte, 16+16))
return buf.Bytes()
}
func TestInspectTagStanzas(t *testing.T) {
tests := []struct {
stanzaType string
want string
}{
{stanzaType: "p256tag", want: "no"},
{stanzaType: "mlkem768p256tag", want: "yes"},
}
for _, tt := range tests {
t.Run(tt.stanzaType, func(t *testing.T) {
f := buildFile(t, tt.stanzaType)
md, err := Inspect(bytes.NewReader(f), int64(len(f)))
if err != nil {
t.Fatalf("Inspect: %v", err)
}
if got := md.Postquantum; got != tt.want {
t.Errorf("Postquantum = %q, want %q", got, tt.want)
}
if len(md.StanzaTypes) != 1 || md.StanzaTypes[0] != tt.stanzaType {
t.Errorf("StanzaTypes = %v, want [%q]", md.StanzaTypes, tt.stanzaType)
}
})
}
}
func TestInspectPostquantum(t *testing.T) {
tests := []struct {
name string
stanzaTypes []string
want string
}{
{"postquantum", []string{"mlkem768x25519"}, "yes"},
{"classical", []string{"X25519"}, "no"},
{"both", []string{"mlkem768x25519", "X25519"}, "no"},
{"unrecognized", []string{"tpm-ecc"}, "unknown"},
{"postquantum and unrecognized", []string{"mlkem768x25519", "tpm-ecc"}, "unknown"},
{"unrecognized and postquantum", []string{"tpm-ecc", "mlkem768x25519"}, "unknown"},
{"classical and unrecognized", []string{"X25519", "tpm-ecc"}, "no"},
{"unrecognized and classical", []string{"tpm-ecc", "X25519"}, "no"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := buildFile(t, tt.stanzaTypes...)
md, err := Inspect(bytes.NewReader(f), int64(len(f)))
if err != nil {
t.Fatalf("Inspect: %v", err)
}
if md.Postquantum != tt.want {
t.Errorf("Postquantum = %q, want %q", md.Postquantum, tt.want)
}
})
}
}
// readAfterEOFReader returns io.EOF along with the last of its data, and then
// more data from subsequent Reads, like a terminal that received Ctrl-D
// followed by more input.
type readAfterEOFReader struct {
data []byte
eof bool
}
func (r *readAfterEOFReader) Read(p []byte) (int, error) {
if r.eof {
return copy(p, "\n"), nil
}
n := copy(p, r.data)
r.data = r.data[n:]
if len(r.data) == 0 {
r.eof = true
return n, io.EOF
}
return n, nil
}
func TestInspectReadAfterEOF(t *testing.T) {
f := buildFile(t, "X25519")
r := &readAfterEOFReader{data: f}
md, err := Inspect(r, -1)
if err != nil {
t.Fatalf("Inspect: %v", err)
}
if md.Version != "age-encryption.org/v1" {
t.Errorf("Version = %q, want age-encryption.org/v1", md.Version)
}
// If the reads after EOF were counted towards the file size, the extra
// bytes would show up in the payload size.
if md.Sizes.MinPayload != 0 {
t.Errorf("MinPayload = %d, want 0", md.Sizes.MinPayload)
}
}
func TestStreamOverhead(t *testing.T) {
tests := []struct {
payloadSize int64
+22 -11
View File
@@ -12,6 +12,7 @@ import (
"errors"
"fmt"
"io"
"math"
"sync/atomic"
"golang.org/x/crypto/chacha20poly1305"
@@ -20,6 +21,9 @@ import (
const ChunkSize = 64 * 1024
func EncryptedChunkCount(encryptedSize int64) (int64, error) {
if encryptedSize < 0 || encryptedSize > math.MaxInt64-encChunkSize+1 {
return 0, fmt.Errorf("invalid encrypted payload size: %d", encryptedSize)
}
chunks := (encryptedSize + encChunkSize - 1) / encChunkSize
plaintextSize := encryptedSize - chunks*chacha20poly1305.Overhead
@@ -95,7 +99,7 @@ func (r *DecryptReader) Read(p []byte) (int, error) {
// words, check for trailing data after a full-length final chunk.
// Hopefully, the underlying reader supports returning EOF even if it
// had previously returned an EOF to ReadFull.
if _, err := r.src.Read(make([]byte, 1)); err == nil {
if _, err := io.ReadFull(r.src, make([]byte, 1)); err == nil {
r.err = errors.New("trailing data after end of encrypted file")
} else if err != io.EOF {
r.err = fmt.Errorf("non-EOF error reading after end of encrypted file: %w", err)
@@ -359,6 +363,20 @@ type cachedChunk struct {
data []byte
}
func readFullAt(r io.ReaderAt, p []byte, off int64) error {
n, err := r.ReadAt(p, off)
switch {
case n == len(p) && errors.Is(err, io.EOF):
// io.ReaderAt implementations are allowed to return io.EOF when the
// read reaches the end of the source.
err = nil
case n < len(p) && (err == nil || errors.Is(err, io.EOF)):
// Incorrect io.ReaderAt implementation, or the source is short.
err = io.ErrUnexpectedEOF
}
return err
}
func NewDecryptReaderAt(key []byte, src io.ReaderAt, size int64) (*DecryptReaderAt, error) {
aead, err := chacha20poly1305.New(key)
if err != nil {
@@ -374,7 +392,7 @@ func NewDecryptReaderAt(key []byte, src io.ReaderAt, size int64) (*DecryptReader
finalChunkOff := finalChunkIndex * encChunkSize
finalChunkSize := size - finalChunkOff
finalChunk := make([]byte, finalChunkSize)
if _, err := src.ReadAt(finalChunk, finalChunkOff); err != nil {
if err := readFullAt(src, finalChunk, finalChunkOff); err != nil {
return nil, fmt.Errorf("failed to read final chunk: %w", err)
}
nonce := nonceForChunk(finalChunkIndex)
@@ -412,21 +430,14 @@ func (r *DecryptReaderAt) ReadAt(p []byte, off int64) (n int, err error) {
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 {
if err := readFullAt(r.src, chunk[:chunkSize], chunkOff); 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)
}
var err error
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)
+159 -209
View File
@@ -7,8 +7,10 @@ package stream_test
import (
"bytes"
"crypto/rand"
"errors"
"fmt"
"io"
"math"
"testing"
"testing/iotest"
@@ -18,6 +20,23 @@ import (
const cs = stream.ChunkSize
// encrypt encrypts plaintext with key and returns the ciphertext.
func encrypt(t *testing.T, key, plaintext []byte) []byte {
t.Helper()
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)
}
return buf.Bytes()
}
func TestRoundTrip(t *testing.T) {
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} {
@@ -35,13 +54,9 @@ func TestRoundTrip(t *testing.T) {
func testRoundTrip(t *testing.T, stepSize, length int) {
src := make([]byte, length)
if _, err := rand.Read(src); err != nil {
t.Fatal(err)
}
rand.Read(src)
key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
rand.Read(key)
var ciphertext []byte
t.Run("EncryptWriter", func(t *testing.T) {
@@ -201,9 +216,7 @@ func (t *trackingReaderAt) reset() {
func TestDecryptReaderAt(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
rand.Read(key)
// Create plaintext spanning exactly 3 chunks: 2 full chunks + partial third
// Chunk 0: [0, cs)
@@ -211,23 +224,8 @@ func TestDecryptReaderAt(t *testing.T) {
// 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)
}
// 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()
rand.Read(plaintext)
ciphertext := encrypt(t, key, plaintext)
// Create tracking ReaderAt
tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)}
@@ -266,10 +264,7 @@ func TestDecryptReaderAt(t *testing.T) {
// Verify data correctness
if n > 0 && off >= 0 && off < int64(plaintextSize) {
end := int(off) + n
if end > plaintextSize {
end = plaintextSize
}
end := min(int(off)+n, plaintextSize)
if !bytes.Equal(p[:n], plaintext[off:end]) {
t.Errorf("%s: data mismatch", name)
}
@@ -411,22 +406,100 @@ func TestDecryptReaderAt(t *testing.T) {
checkRead("cross boundary 1-2", int64(2*cs-50), 100, 100, false, true)
}
func TestDecryptReaderAtEmpty(t *testing.T) {
// eofReaderAt wraps a bytes.Reader and forces it to return io.EOF alongside a
// read that successfully reaches the exact end of the data.
type eofReaderAt struct {
r *bytes.Reader
}
func (e *eofReaderAt) ReadAt(p []byte, off int64) (int, error) {
n, err := e.r.ReadAt(p, off)
if err == nil && off+int64(n) == e.r.Size() {
err = io.EOF
}
return n, err
}
func TestDecryptReaderAtEOF(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
rand.Read(key)
// Multiple chunks, so that reads reach the final chunk both in the
// constructor and through ReadAt.
plaintext := make([]byte, 2*cs+100)
rand.Read(plaintext)
ciphertext := encrypt(t, key, plaintext)
// An io.ReaderAt is allowed to return io.EOF alongside a successful read
// that reaches the end of the source; both the constructor (which reads
// the final chunk eagerly) and ReadAt must tolerate it.
er := &eofReaderAt{r: bytes.NewReader(ciphertext)}
ra, err := stream.NewDecryptReaderAt(key, er, int64(len(ciphertext)))
if err != nil {
t.Fatalf("NewDecryptReaderAt failed on EOF: %v", err)
}
// Create empty encrypted file
buf := &bytes.Buffer{}
w, err := stream.NewEncryptWriter(key, buf)
// Sequential small reads evict the final chunk from the cache, so it is
// read again through ReadAt, exercising its own EOF handling.
got, err := io.ReadAll(io.NewSectionReader(ra, 0, int64(len(plaintext))))
if err != nil {
t.Fatal(err)
t.Fatalf("ReadAll failed on EOF: %v", err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
if !bytes.Equal(got, plaintext) {
t.Error("plaintext mismatch")
}
ciphertext := buf.Bytes()
}
// TestDecryptReaderTrailingData checks that data appended after a full-length
// final chunk is rejected, including when the source returns the trailing byte
// alongside io.EOF as permitted by io.Reader.
func TestDecryptReaderTrailingData(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
rand.Read(key)
// A plaintext of exactly one full chunk, so the trailing data can only be
// detected by reading past the final chunk.
plaintext := make([]byte, cs)
rand.Read(plaintext)
ciphertext := encrypt(t, key, plaintext)
trailing := append(bytes.Clone(ciphertext), 0x42)
wrappers := map[string]func(io.Reader) io.Reader{
"plain": func(r io.Reader) io.Reader { return r },
"dataErr": iotest.DataErrReader,
}
for name, wrap := range wrappers {
t.Run(name, func(t *testing.T) {
r, err := stream.NewDecryptReader(key, wrap(bytes.NewReader(trailing)))
if err != nil {
t.Fatal(err)
}
if _, err := io.ReadAll(r); err == nil {
t.Error("trailing data: expected error, got nil")
}
r, err = stream.NewDecryptReader(key, wrap(bytes.NewReader(ciphertext)))
if err != nil {
t.Fatal(err)
}
got, err := io.ReadAll(r)
if err != nil {
t.Errorf("valid file: got err=%v, want nil", err)
}
if !bytes.Equal(got, plaintext) {
t.Error("valid file: plaintext mismatch")
}
})
}
}
func TestDecryptReaderAtEmpty(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
rand.Read(key)
// Create empty encrypted file
ciphertext := encrypt(t, key, nil)
tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)}
ra, err := stream.NewDecryptReaderAt(key, tracker, int64(len(ciphertext)))
@@ -468,28 +541,12 @@ func TestDecryptReaderAtEmpty(t *testing.T) {
func TestDecryptReaderAtSingleChunk(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
rand.Read(key)
// 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()
rand.Read(plaintext)
ciphertext := encrypt(t, key, plaintext)
tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)}
ra, err := stream.NewDecryptReaderAt(key, tracker, int64(len(ciphertext)))
@@ -532,28 +589,12 @@ func TestDecryptReaderAtSingleChunk(t *testing.T) {
func TestDecryptReaderAtFullChunks(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
rand.Read(key)
// 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()
rand.Read(plaintext)
ciphertext := encrypt(t, key, plaintext)
tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)}
ra, err := stream.NewDecryptReaderAt(key, tracker, int64(len(ciphertext)))
@@ -594,35 +635,15 @@ func TestDecryptReaderAtFullChunks(t *testing.T) {
func TestDecryptReaderAtWrongKey(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
rand.Read(key)
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()
rand.Read(plaintext)
ciphertext := encrypt(t, key, plaintext)
// 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)))
rand.Read(wrongKey)
_, err := stream.NewDecryptReaderAt(wrongKey, bytes.NewReader(ciphertext), int64(len(ciphertext)))
if err == nil {
t.Error("wrong key: expected error, got nil")
}
@@ -630,30 +651,13 @@ func TestDecryptReaderAtWrongKey(t *testing.T) {
func TestDecryptReaderAtInvalidSize(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
rand.Read(key)
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()
rand.Read(plaintext)
ciphertext := encrypt(t, key, plaintext)
// Wrong size (too small)
_, err = stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), int64(len(ciphertext)-1))
_, err := stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), int64(len(ciphertext)-1))
if err == nil {
t.Error("wrong size (small): expected error, got nil")
}
@@ -671,64 +675,44 @@ func TestDecryptReaderAtInvalidSize(t *testing.T) {
if err == nil {
t.Error("invalid size (empty final chunk): expected error, got nil")
}
// Negative sizes and sizes that would overflow the chunk count computation
// must be rejected with an error rather than a panic.
for _, size := range []int64{-1, -70000, math.MaxInt64, math.MaxInt64 - 200, math.MaxInt64 - 65550} {
if _, err := stream.EncryptedChunkCount(size); err == nil {
t.Errorf("EncryptedChunkCount(%d): expected error, got nil", size)
}
if _, err := stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), size); err == nil {
t.Errorf("NewDecryptReaderAt(%d): expected error, got nil", size)
}
}
}
func TestDecryptReaderAtTruncated(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
rand.Read(key)
plaintext := make([]byte, 2*cs+500)
if _, err := rand.Read(plaintext); err != nil {
t.Fatal(err)
}
rand.Read(plaintext)
ciphertext := encrypt(t, key, plaintext)
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
// Truncate ciphertext but lie about size. The source ending before the
// expected end of the final chunk must be reported as an unexpected EOF,
// not as a decryption failure.
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")
_, err := stream.NewDecryptReaderAt(key, bytes.NewReader(truncated), int64(len(ciphertext)))
if !errors.Is(err, io.ErrUnexpectedEOF) {
t.Errorf("truncated: got err=%v, want io.ErrUnexpectedEOF", err)
}
}
func TestDecryptReaderAtTruncatedChunk(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
rand.Read(key)
// 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()
rand.Read(plaintext)
ciphertext := encrypt(t, key, plaintext)
// Truncate to 3 chunks (remove the actual final chunk)
// The third chunk was NOT encrypted with the last chunk flag,
@@ -737,7 +721,7 @@ func TestDecryptReaderAtTruncatedChunk(t *testing.T) {
truncatedSize := int64(3 * encChunkSize)
truncated := ciphertext[:truncatedSize]
_, err = stream.NewDecryptReaderAt(key, bytes.NewReader(truncated), truncatedSize)
_, err := stream.NewDecryptReaderAt(key, bytes.NewReader(truncated), truncatedSize)
if err == nil {
t.Error("truncated at chunk boundary: expected error, got nil")
}
@@ -745,30 +729,13 @@ func TestDecryptReaderAtTruncatedChunk(t *testing.T) {
func TestDecryptReaderAtConcurrent(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
rand.Read(key)
// 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()
rand.Read(plaintext)
ciphertext := encrypt(t, key, plaintext)
ra, err := stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), int64(len(ciphertext)))
if err != nil {
@@ -907,32 +874,15 @@ func TestDecryptReaderAtConcurrent(t *testing.T) {
func TestDecryptReaderAtCorrupted(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
rand.Read(key)
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())
rand.Read(plaintext)
ciphertext := encrypt(t, key, plaintext)
// 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)))
_, err := stream.NewDecryptReaderAt(key, bytes.NewReader(corruptedFinal), int64(len(corruptedFinal)))
if err == nil {
t.Error("corrupted final: expected error, got nil")
}
+14 -3
View File
@@ -4,6 +4,8 @@ import (
"fmt"
"os"
"runtime"
"strings"
"unicode"
"golang.org/x/term"
)
@@ -13,6 +15,15 @@ import (
// characters being printed to the console.
var enableVirtualTerminalProcessing func(out *os.File) error
func printPrompt(out *os.File, prompt string) {
fmt.Fprintf(out, "%s ", strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return '\uFFFD'
}
return r
}, prompt))
}
// 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) {
@@ -64,7 +75,7 @@ func WithTerminal(f func(in, out *os.File) error) error {
// 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)
printPrompt(out, prompt)
defer clearLine(out)
s, err = term.ReadPassword(int(in.Fd()))
return err
@@ -75,7 +86,7 @@ func ReadSecret(prompt string) (s []byte, err error) {
// 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)
printPrompt(out, prompt)
defer clearLine(out)
oldState, err := term.MakeRaw(int(in.Fd()))
@@ -96,7 +107,7 @@ func ReadPublic(prompt string) (s []byte, err error) {
// prompt is ephemeral.
func ReadCharacter(prompt string) (c byte, err error) {
err = WithTerminal(func(in, out *os.File) error {
fmt.Fprintf(out, "%s ", prompt)
printPrompt(out, prompt)
defer clearLine(out)
oldState, err := term.MakeRaw(int(in.Fd()))
+16 -4
View File
@@ -24,7 +24,8 @@ import (
func ParseIdentities(f io.Reader) ([]Identity, error) {
const privateKeySizeLimit = 1 << 24 // 16 MiB
var ids []Identity
scanner := bufio.NewScanner(io.LimitReader(f, privateKeySizeLimit))
lr := &io.LimitedReader{R: f, N: privateKeySizeLimit + 1}
scanner := bufio.NewScanner(lr)
var n int
for scanner.Scan() {
n++
@@ -44,6 +45,9 @@ func ParseIdentities(f io.Reader) ([]Identity, error) {
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read identities file: %v", err)
}
if lr.N == 0 {
return nil, fmt.Errorf("identities file is too long")
}
if len(ids) == 0 {
return nil, fmt.Errorf("no identities found")
}
@@ -57,7 +61,9 @@ func parseIdentity(arg string) (Identity, error) {
case strings.HasPrefix(arg, "AGE-SECRET-KEY-PQ-1"):
return ParseHybridIdentity(arg)
default:
return nil, fmt.Errorf("unknown identity type: %q", arg)
// Don't include arg in the error: it may contain private key material,
// and callers print these errors.
return nil, fmt.Errorf("unknown identity type")
}
}
@@ -74,7 +80,8 @@ func parseIdentity(arg string) (Identity, error) {
func ParseRecipients(f io.Reader) ([]Recipient, error) {
const recipientFileSizeLimit = 1 << 24 // 16 MiB
var recs []Recipient
scanner := bufio.NewScanner(io.LimitReader(f, recipientFileSizeLimit))
lr := &io.LimitedReader{R: f, N: recipientFileSizeLimit + 1}
scanner := bufio.NewScanner(lr)
var n int
for scanner.Scan() {
n++
@@ -94,6 +101,9 @@ func ParseRecipients(f io.Reader) ([]Recipient, error) {
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read recipients file: %v", err)
}
if lr.N == 0 {
return nil, fmt.Errorf("recipients file is too long")
}
if len(recs) == 0 {
return nil, fmt.Errorf("no recipients found")
}
@@ -107,6 +117,8 @@ func parseRecipient(arg string) (Recipient, error) {
case strings.HasPrefix(arg, "age1"):
return ParseX25519Recipient(arg)
default:
return nil, fmt.Errorf("unknown recipient type: %q", arg)
// It might be a private key from an identities file accidentally used
// as a recipients file, so don't include arg in the error.
return nil, fmt.Errorf("unknown recipient type")
}
}
+3 -4
View File
@@ -193,7 +193,7 @@ func NewIdentityWithoutData(name string, ui *ClientUI) (*Identity, error) {
return nil, fmt.Errorf("invalid plugin name: %q", name)
}
return &Identity{
name: name, encoding: s, ui: ui,
name: strings.ToLower(name), encoding: s, ui: ui,
}, nil
}
@@ -457,9 +457,8 @@ func openClientConnection(name, protocol string) (*clientConnection, error) {
cmd.Stderr = os.Stderr
}
// We don't want the plugins to rely on the working directory for anything
// as different clients might treat it differently, so we set it to an empty
// temporary directory.
// Avoid running plugins in the client's working directory, as it might
// differ between clients.
cmd.Dir = os.TempDir()
if err := cmd.Start(); err != nil {
+55
View File
@@ -7,6 +7,7 @@
package plugin
import (
"bufio"
"bytes"
"errors"
"io"
@@ -19,6 +20,7 @@ import (
"filippo.io/age"
"filippo.io/age/internal/bech32"
"filippo.io/age/internal/format"
)
func TestMain(m *testing.M) {
@@ -67,6 +69,59 @@ func (testPQCRecipient) WrapWithLabels(fileKey []byte) ([]*age.Stanza, []string,
return []*age.Stanza{{Type: "test", Body: fileKey}}, []string{"postquantum"}, nil
}
func TestIdentityV1NegativeIndex(t *testing.T) {
p, err := New("test")
if err != nil {
t.Fatal(err)
}
p.HandleIdentity(func([]byte) (age.Identity, error) { return nil, nil })
stderr := &bytes.Buffer{}
p.SetIO(strings.NewReader("-> recipient-stanza -1 X25519\n\n"), io.Discard, stderr)
if code := p.IdentityV1(); code != 1 {
t.Errorf("exit code = %d, want 1", code)
}
want := "unexpected file index -1, previous was -1"
if got := stderr.String(); got != want {
t.Errorf("stderr = %q, want %q", got, want)
}
}
func TestPluginNameCase(t *testing.T) {
p, err := New("MixedCase")
if err != nil {
t.Fatal(err)
}
if p.Name() != "mixedcase" {
t.Errorf("Plugin.Name = %q", p.Name())
}
id, err := NewIdentityWithoutData("MixedCase", &ClientUI{})
if err != nil {
t.Fatal(err)
}
if id.Name() != "mixedcase" {
t.Errorf("Identity.Name = %q", id.Name())
}
if _, err := New("\u212a"); err == nil {
t.Error("New accepted a non-ASCII name")
}
}
func TestRequestValueEmpty(t *testing.T) {
p, err := New("test")
if err != nil {
t.Fatal(err)
}
p.SetIO(nil, io.Discard, io.Discard)
p.sr = format.NewStanzaReader(bufio.NewReader(strings.NewReader("-> ok\n\n")))
value, err := p.RequestValue("prompt", true)
if err != nil {
t.Fatal(err)
}
if value != "" {
t.Errorf("value = %q", value)
}
}
func TestLabels(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows support is TODO")
+22 -19
View File
@@ -1,6 +1,6 @@
// Package plugin implements the age plugin protocol.
//
// [Recipient] and [Indentity] are plugin clients, that execute plugin binaries to
// [Recipient] and [Identity] 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]
@@ -14,7 +14,9 @@ import (
"fmt"
"io"
"os"
"slices"
"strconv"
"strings"
"filippo.io/age"
"filippo.io/age/internal/format"
@@ -43,11 +45,14 @@ type Plugin struct {
broken bool
}
// New creates a new Plugin with the given name.
// New creates a new Plugin with the given case-insensitive 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,
if !validPluginName(name) {
return nil, fmt.Errorf("invalid plugin name: %q", name)
}
return &Plugin{name: strings.ToLower(name), stdin: os.Stdin,
stdout: os.Stdout, stderr: os.Stderr}, nil
}
@@ -358,7 +363,7 @@ func wrapWithLabels(r age.Recipient, fileKey []byte) ([]*age.Stanza, []string, e
}
func checkLabels(ll, labels []string) error {
if !slicesEqual(ll, labels) {
if !slices.Equal(ll, labels) {
return fmt.Errorf("labels %q do not match previous recipients %q", ll, labels)
}
return nil
@@ -398,6 +403,9 @@ ReadLoop:
if err != nil {
return p.fatalf("failed to parse recipient-stanza stanza argument: %v", err)
}
if i < 0 {
return p.fatalf("unexpected file index %d, previous was %d", i, len(files)-1)
}
ss := &age.Stanza{Type: s.Args[1], Args: s.Args[2:], Body: s.Body}
switch i {
case len(files):
@@ -529,7 +537,7 @@ func (p *Plugin) RequestValue(prompt string, secret bool) (string, error) {
if s.Type == "fail" {
return "", fmt.Errorf("client failed to request value")
}
if err := expectStanzaWithBody(s, 0); err != nil {
if err := expectStanzaWithAnyBody(s, 0); err != nil {
return "", p.fatalInteractf("%v", err)
}
return string(s.Body), nil
@@ -588,8 +596,8 @@ func expectStanzaWithNoBody(s *format.Stanza, wantArgs int) error {
}
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 err := expectStanzaWithAnyBody(s, wantArgs); err != nil {
return err
}
if len(s.Body) == 0 {
return fmt.Errorf("%s stanza has 0 bytes of body, want >0", s.Type)
@@ -597,6 +605,13 @@ func expectStanzaWithBody(s *format.Stanza, wantArgs int) error {
return nil
}
func expectStanzaWithAnyBody(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)
}
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)
@@ -662,15 +677,3 @@ func (p *Plugin) writeError(args []string, err error) error {
}
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
}
+16 -4
View File
@@ -74,14 +74,20 @@ func (i *ClassicIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) {
return nil, fmt.Errorf("failed to compute tag: %v", err)
}
if subtle.ConstantTimeCompare(tagArg, expTag[:4]) != 1 {
return nil, age.ErrIncorrectIdentity
continue
}
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)
fileKey, err := r.Open(nil, s.Body)
if err != nil {
// The tag is only a short hint, and might collide with the tag of a
// different recipient.
continue
}
return fileKey, nil
}
return nil, age.ErrIncorrectIdentity
}
@@ -139,14 +145,20 @@ func (i *HybridIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) {
return nil, fmt.Errorf("failed to compute tag: %v", err)
}
if subtle.ConstantTimeCompare(tagArg, expTag[:4]) != 1 {
return nil, age.ErrIncorrectIdentity
continue
}
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)
fileKey, err := r.Open(nil, s.Body)
if err != nil {
// The tag is only a short hint, and might collide with the tag of a
// different recipient.
continue
}
return fileKey, nil
}
return nil, age.ErrIncorrectIdentity
}
+62
View File
@@ -10,6 +10,7 @@ import (
"testing"
"filippo.io/age"
"filippo.io/age/internal/format"
"filippo.io/age/tag"
"filippo.io/age/tag/internal/tagtest"
)
@@ -108,6 +109,67 @@ func TestHybridRoundTrip(t *testing.T) {
}
}
func TestTagCollision(t *testing.T) {
tests := []struct {
name string
identity age.Identity
recipient *tag.Recipient
other *tag.Recipient
}{
{
name: "classic",
identity: tagtest.NewClassicIdentity("target"),
recipient: tagtest.NewClassicIdentity("target").Recipient(),
other: tagtest.NewClassicIdentity("other").Recipient(),
},
{
name: "hybrid",
identity: tagtest.NewHybridIdentity("target"),
recipient: tagtest.NewHybridIdentity("target").Recipient(),
other: tagtest.NewHybridIdentity("other").Recipient(),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fileKey := bytes.Repeat([]byte{1}, 16)
stanzas, err := test.recipient.Wrap(fileKey)
if err != nil {
t.Fatal(err)
}
unrelated, err := test.other.Wrap(make([]byte, 16))
if err != nil {
t.Fatal(err)
}
collision := &age.Stanza{
Type: unrelated[0].Type,
Args: append([]string(nil), unrelated[0].Args...),
Body: append([]byte(nil), unrelated[0].Body...),
}
enc, err := format.DecodeString(collision.Args[1])
if err != nil {
t.Fatal(err)
}
collisionTag, err := test.recipient.Tag(enc)
if err != nil {
t.Fatal(err)
}
collision.Args[0] = format.EncodeToString(collisionTag)
allStanzas := append(unrelated, collision)
allStanzas = append(allStanzas, stanzas...)
out, err := test.identity.Unwrap(allStanzas)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(fileKey, out) {
t.Errorf("invalid output: %x, expected %x", out, fileKey)
}
})
}
}
func TestTagHybridMixingRestrictions(t *testing.T) {
x25519, err := age.GenerateX25519Identity()
if err != nil {