34 Commits

Author SHA1 Message Date
Filippo Valsorda
b6b5f4300f cmd/age: disable golang/go#61779 tests workaround 2024-06-16 13:52:42 +02:00
Filippo Valsorda
627e6bc9d8 cmd/age: create file for empty decryptions
Fixes #555
Updates #159
Updates #57
2024-06-16 13:50:52 +02:00
Filippo Valsorda
7ed486868a .github/workflows: apparently setup-go has no defaults 2024-06-16 06:03:13 -04:00
Filippo Valsorda
2a761fcb8c .github/workflows: update GitHub Actions 2024-06-16 06:03:13 -04:00
Filippo Valsorda
98e7afcbac all: upgrade dependencies 2024-06-16 06:03:13 -04:00
Filippo Valsorda
5ef63b6153 .github/workflows: install bootstrap Go 2024-06-16 06:03:13 -04:00
Filippo Valsorda
bc21ece498 .github/workflows: disable environment
It's very spammy, just move the secret to a repository secret.
2024-06-16 06:03:13 -04:00
Filippo Valsorda
69c21b83fb README: fix scoop command
Closes #564
2024-06-16 05:06:58 -04:00
Filippo Valsorda
35cf02b1d0 .github: link maintenance policy from CONTRIBUTING.md 2024-06-16 05:03:03 -04:00
Jakub Wilk
29b68c20fc README: fix typo (#534) 2024-01-10 06:40:17 -05:00
Filippo Valsorda
101cc86763 README: Debian 12 installation instructions 2023-09-20 08:41:00 -04:00
Filippo Valsorda
6ad4560f4a .github/workflows: drop FreeBSD tests
This is unfortunate, but without a live platform to test on,
I can't investigate issues, and CI is now failing with just

   ?   	filippo.io/age/cmd/age-keygen	[no test files]
  Killed

which really could be anything.
2023-08-07 18:44:57 -04:00
Filippo Valsorda
93055632ad cmd/age: fix FreeBSD tests 2023-08-06 19:39:31 +02:00
Filippo Valsorda
294b0aa1e3 plugin: skip execution tests on Windows for now 2023-08-06 19:03:27 +02:00
Filippo Valsorda
f1f96c25e0 plugin: build tag EncodeX25519Recipient which uses crypto/ecdh 2023-08-06 18:36:38 +02:00
Filippo Valsorda
9fd564d543 .github/workflows: update and fix CI 2023-08-06 18:29:16 +02:00
Filippo Valsorda
c89f0b932e age,plugin: add RecipientWithLabels 2023-08-05 21:34:47 +02:00
Filippo Valsorda
dd733c5c0f cmd/age: grease the client-controlled plugin phases 2023-08-05 21:34:14 +02:00
Filippo Valsorda
004b544d83 plugin: add EncodeX25519Recipient 2023-08-05 21:34:14 +02:00
Filippo Valsorda
02181d83e9 plugin: add identity and recipient encoding 2023-08-05 21:34:14 +02:00
Filippo Valsorda
6976c5fca5 plugin: expose package 2023-08-05 21:34:14 +02:00
Filippo Valsorda
980763a16e age: make TestVectorsRoundTrip a little stricter 2023-07-23 00:54:40 +02:00
Filippo Valsorda
4740a92ef9 age: use testkit vectors to test armor, header, and STREAM round-trips
Before

	filippo.io/age/armor	coverage: 72.3% of statements in filippo.io/age/...
	filippo.io/age/internal/format	coverage: 86.8% of statements in filippo.io/age/...
	filippo.io/age/internal/stream	coverage: 83.9% of statements in filippo.io/age/...

After

	filippo.io/age/armor	coverage: 88.0% of statements in filippo.io/age/...
	filippo.io/age/internal/format	coverage: 87.6% of statements in filippo.io/age/...
	filippo.io/age/internal/stream	coverage: 86.0% of statements in filippo.io/age/...
2023-07-23 00:18:41 +02:00
Stepan
6c36e167c8 README: update release download link (#512) 2023-06-30 09:05:44 -04:00
Filippo Valsorda
9f0a2d25ac README: add link to awesome age list 2023-04-22 16:40:45 -04:00
Helio Machado
b6537b1865 .github/workflows: trigger interop. tests using gh (#481) 2023-04-22 06:01:29 -04:00
GitHub Actions
486b6dac96 doc: regenerate groff and html man pages 2023-04-22 09:53:44 +00:00
zhsj
877ca247e3 .github/workflows: update ronn to 0.9 (#483)
This fixes apostrophes in generated manpage.
Apostrophes should be entered as `'` ; using `\'` produces an acute accent.

ronn in Ubuntu is from https://github.com/apjanke/ronn-ng
2023-04-22 05:53:03 -04:00
andros21
502b180b17 README: dark/light mode logo (#500)
https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#specifying-the-theme-an-image-is-shown-to
2023-04-22 05:46:50 -04:00
Filippo Valsorda
8e3f74c283 cmd/age: deflake TestScript and update testscript 2023-01-02 13:34:35 +01:00
Berk D. Demir
edf7388f77 age: depend on c2sp.org/CCTV/age for TestVectors
Simplifies importing test data from CCTV without needing to invoke
"go mod download" from TestVectors. Makes life easier for package
builders with no networking, like Nixpkgs.
2022-12-30 18:24:08 -05:00
Filippo Valsorda
5471e05672 Revert "all: temporarily disable testscript tests"
This reverts commit 90a446549a.
2022-12-29 21:53:31 +01:00
Filippo Valsorda
c6dcfa1efc all: temporarily disable testscript tests
They require a replace directive that breaks "go install". Will revert
this after tagging a new latest release.
2022-12-26 15:36:58 -05:00
Filippo Valsorda
a1fabee4c8 all: upgrade dependencies 2022-12-26 15:36:58 -05:00
32 changed files with 854 additions and 500 deletions

View File

@@ -12,6 +12,8 @@ age is a little unusual in how it is maintained. I like to keep the code style c
Therefore, **be prepared for your change to get reimplemented rather than merged**, and please don't be offended if that happens. PRs are still appreciated as a way to clarify the intended behavior, but are not at all required: prefer focusing on providing detailed context in an issue report instead.
To learn more, please see my [maintenance policy](https://github.com/FiloSottile/FiloSottile/blob/main/maintenance.md).
<!-- ## Feature requests
age is small, simple, and config-free by design. A lot of effort is put into resisting scope creep and enabling use cases by integrating and interoperating well with other projects rather than by adding features.

View File

@@ -10,7 +10,6 @@ jobs:
build:
name: Build binaries
runs-on: ubuntu-latest
environment: "Build, sign, release binaries"
strategy:
matrix:
include:
@@ -27,7 +26,7 @@ jobs:
with:
go-version: 1.x
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build binary

View File

@@ -8,8 +8,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Trigger interoperability tests in str4d/rage
run: |
curl -X POST https://api.github.com/repos/str4d/rage/dispatches \
-H 'Accept: application/vnd.github.v3+json' \
-H 'Authorization: token ${{ secrets.RAGE_INTEROP_ACCESS_TOKEN }}' \
--data '{"event_type": "age-interop-request", "client_payload": { "sha": "'"$GITHUB_SHA"'" }}'
run: >
gh api repos/str4d/rage/dispatches
--field event_type="age-interop-request"
--field client_payload[sha]="$GITHUB_SHA"
env:
GITHUB_TOKEN: ${{ secrets.RAGE_INTEROP_ACCESS_TOKEN }}

View File

@@ -6,7 +6,6 @@ on:
paths:
- '**.ronn'
- '**/ronn.yml'
- '**/ronn/**'
permissions:
contents: read
jobs:
@@ -15,10 +14,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Install ronn
run: sudo apt-get update && sudo apt-get install -y ronn
- name: Run ronn
uses: ./.github/workflows/ronn
id: ronn
run: bash -O globstar -c 'ronn **/*.ronn'
- name: Undo email mangling
# rdiscount randomizes the output for no good reason, which causes
# changes to always get committed. Sigh.
@@ -43,7 +43,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Download generated files
uses: actions/download-artifact@v2
with:

View File

@@ -1,8 +0,0 @@
FROM ruby:3.0.1-buster
RUN apt-get update && apt-get install -y groff
RUN bundle config --global frozen 1
COPY Gemfile Gemfile.lock ./
RUN bundle install
ENTRYPOINT ["bash", "-O", "globstar", "-c", \
"/usr/local/bundle/bin/ronn **/*.ronn"]

View File

@@ -1,5 +0,0 @@
# frozen_string_literal: true
source "https://rubygems.org"
gem "ronn", "~> 0.7.3"

View File

@@ -1,20 +0,0 @@
GEM
remote: https://rubygems.org/
specs:
hpricot (0.8.6)
mustache (1.1.1)
rdiscount (2.2.0.2)
ronn (0.7.3)
hpricot (>= 0.8.2)
mustache (>= 0.7.0)
rdiscount (>= 1.5.8)
PLATFORMS
aarch64-linux
x86_64-linux
DEPENDENCIES
ronn (~> 0.7.3)
BUNDLED WITH
2.2.15

View File

@@ -1,4 +0,0 @@
name: Ronn
runs:
using: docker
image: Dockerfile

View File

@@ -8,37 +8,20 @@ jobs:
strategy:
fail-fast: false
matrix:
go: [1.18.x, 1.19.x]
go: [1.19.x, 1.x]
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Install Go ${{ matrix.go }}
uses: actions/setup-go@v2
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run tests
run: go test -race ./...
freebsd:
name: Test (FreeBSD)
runs-on: macos-10.15
steps:
- name: Checkout repository
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Run tests
# Unpinned Action allowed with read-only permissions.
uses: vmactions/freebsd-vm@v0
with:
prepare: |
freebsd-version
pkg install -y go
go version
run: go test -buildvcs=false -race ./...
gotip:
name: Test (Go tip)
strategy:
@@ -47,6 +30,10 @@ jobs:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Install bootstrap Go
uses: actions/setup-go@v5
with:
go-version: 1.22
- name: Install Go tip (UNIX)
if: runner.os != 'Windows'
run: |
@@ -60,7 +47,7 @@ jobs:
cd $HOME/gotip/src && ./make.bat
echo "$HOME/gotip/bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
fetch-depth: 0
- run: go version

View File

@@ -1,4 +1,10 @@
<p align="center"><img alt="The age logo, an wireframe of St. Peters dome in Rome, with the text: age, file encryption" width="600" src="https://user-images.githubusercontent.com/1225294/132245842-fda4da6a-1cea-4738-a3da-2dc860861c98.png"></p>
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/FiloSottile/age/blob/main/logo/logo_white.svg">
<source media="(prefers-color-scheme: light)" srcset="https://github.com/FiloSottile/age/blob/main/logo/logo.svg">
<img alt="The age logo, a wireframe of St. Peters dome in Rome, with the text: age, file encryption" width="600" src="https://github.com/FiloSottile/age/blob/main/logo/logo.svg">
</picture>
</p>
[![Go Reference](https://pkg.go.dev/badge/filippo.io/age.svg)](https://pkg.go.dev/filippo.io/age)
[![man page](<https://img.shields.io/badge/age(1)-man%20page-lightgrey>)](https://filippo.io/age/age.1)
@@ -23,7 +29,9 @@ $ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
🔑 Hardware PIV tokens such as YubiKeys are supported through the [age-plugin-yubikey](https://github.com/str4d/age-plugin-yubikey) plugin.
💬 The author pronounces it `[aɡe̞]`, like the Italian [“aghe”](https://translate.google.com/?sl=it&text=aghe).
✨ For more plugins, implementations, tools, and integrations, check out the [awesome age](https://github.com/FiloSottile/awesome-age) list.
💬 The author pronounces it `[aɡe̞]` [with a hard *g*](https://translate.google.com/?sl=it&text=aghe), like GIF, and is always spelled lowercase.
## Installation
@@ -53,7 +61,13 @@ $ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
</td>
</tr>
<tr>
<td>Debian 11+ (Bullseye)</td>
<td>Debian 12+ (Bookworm)</td>
<td>
<code>apt install age</code>
</td>
</tr>
<tr>
<td>Debian 11 (Bullseye)</td>
<td>
<code>apt install age/bullseye-backports</code>
(<a href="https://backports.debian.org/Instructions/#index2h2">enable backports</a> for age v1.0.0+)
@@ -116,7 +130,7 @@ $ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
<tr>
<td>Scoop (Windows)</td>
<td>
<code>scoop bucket add extras; scoop install age</code>
<code>scoop bucket add extras && scoop install age</code>
</td>
</tr>
</table>
@@ -125,7 +139,7 @@ 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.0.0-rc.1?for=darwin/arm64
https://dl.filippo.io/age/v1.1.1?for=darwin/arm64
...
```

59
age.go
View File

@@ -13,7 +13,7 @@
// age encrypted files are binary and not malleable. For encoding them as text,
// use the filippo.io/age/armor package.
//
// Key management
// # Key management
//
// age does not have a global keyring. Instead, since age keys are small,
// textual, and cheap, you are encouraged to generate dedicated keys for each
@@ -34,7 +34,7 @@
// infrastructure, you might want to consider implementing your own Recipient
// and Identity.
//
// Backwards compatibility
// # Backwards compatibility
//
// Files encrypted with a stable version (not alpha, beta, or release candidate)
// of age, or with any v1.0.0 beta or release candidate, will decrypt with any
@@ -51,6 +51,7 @@ import (
"errors"
"fmt"
"io"
"sort"
"filippo.io/age/internal/format"
"filippo.io/age/internal/stream"
@@ -84,6 +85,21 @@ type Recipient interface {
Wrap(fileKey []byte) ([]*Stanza, error)
}
// RecipientWithLabels can be optionally implemented by a Recipient, in which
// case Encrypt will use WrapWithLabels instead of Wrap.
//
// Encrypt will succeed only if the labels returned by all the recipients
// (assuming the empty set for those that don't implement RecipientWithLabels)
// are the same.
//
// This can be used to ensure a recipient is only used with other recipients
// with equivalent properties (for example by setting a "postquantum" label) or
// to ensure a recipient is always used alone (by returning a random label, for
// example to preserve its authentication properties).
type RecipientWithLabels interface {
WrapWithLabels(fileKey []byte) (s []*Stanza, labels []string, err error)
}
// A Stanza is a section of the age header that encapsulates the file key as
// encrypted to a specific recipient.
//
@@ -111,27 +127,24 @@ func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) {
return nil, errors.New("no recipients specified")
}
// As a best effort, prevent an API user from generating a file that the
// ScryptIdentity will refuse to decrypt. This check can't unfortunately be
// implemented as part of the Recipient interface, so it lives as a special
// case in Encrypt.
for _, r := range recipients {
if _, ok := r.(*ScryptRecipient); ok && len(recipients) != 1 {
return nil, errors.New("an ScryptRecipient must be the only one for the file")
}
}
fileKey := make([]byte, fileKeySize)
if _, err := rand.Read(fileKey); err != nil {
return nil, err
}
hdr := &format.Header{}
var labels []string
for i, r := range recipients {
stanzas, err := r.Wrap(fileKey)
stanzas, l, err := wrapWithLabels(r, fileKey)
if err != nil {
return nil, fmt.Errorf("failed to wrap key for recipient #%d: %v", i, err)
}
sort.Strings(l)
if i == 0 {
labels = l
} else if !slicesEqual(labels, l) {
return nil, fmt.Errorf("incompatible recipients")
}
for _, s := range stanzas {
hdr.Recipients = append(hdr.Recipients, (*format.Stanza)(s))
}
@@ -156,6 +169,26 @@ func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) {
return stream.NewWriter(streamKey(fileKey, nonce), dst)
}
func wrapWithLabels(r Recipient, fileKey []byte) (s []*Stanza, labels []string, err error) {
if r, ok := r.(RecipientWithLabels); ok {
return r.WrapWithLabels(fileKey)
}
s, err = r.Wrap(fileKey)
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
}
// NoIdentityMatchError is returned by Decrypt when none of the supplied
// identities match the encrypted file.
type NoIdentityMatchError struct {

View File

@@ -220,3 +220,67 @@ AGE-SECRET-KEY--1D6K0SGAX3NU66R4GYFZY0UQWCLM3UUSF3CXLW4KXZM342WQSJ82QKU59Q`},
})
}
}
type testRecipient struct {
labels []string
}
func (testRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
panic("expected WrapWithLabels instead")
}
func (t testRecipient) WrapWithLabels(fileKey []byte) (s []*age.Stanza, labels []string, err error) {
return []*age.Stanza{{Type: "test"}}, t.labels, nil
}
func TestLabels(t *testing.T) {
scrypt, err := age.NewScryptRecipient("xxx")
if err != nil {
t.Fatal(err)
}
i, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
x25519 := i.Recipient()
pqc := testRecipient{[]string{"postquantum"}}
pqcAndFoo := testRecipient{[]string{"postquantum", "foo"}}
fooAndPQC := testRecipient{[]string{"foo", "postquantum"}}
if _, err := age.Encrypt(io.Discard, scrypt, scrypt); err == nil {
t.Error("expected two scrypt recipients to fail")
}
if _, err := age.Encrypt(io.Discard, scrypt, x25519); err == nil {
t.Error("expected x25519 mixed with scrypt to fail")
}
if _, err := age.Encrypt(io.Discard, x25519, scrypt); err == nil {
t.Error("expected x25519 mixed with scrypt to fail")
}
if _, err := age.Encrypt(io.Discard, pqc, x25519); err == nil {
t.Error("expected x25519 mixed with pqc to fail")
}
if _, err := age.Encrypt(io.Discard, x25519, pqc); err == nil {
t.Error("expected x25519 mixed with pqc to fail")
}
if _, err := age.Encrypt(io.Discard, pqc, pqc); err != nil {
t.Errorf("expected two pqc to work, got %v", err)
}
if _, err := age.Encrypt(io.Discard, pqc); err != nil {
t.Errorf("expected one pqc to work, got %v", err)
}
if _, err := age.Encrypt(io.Discard, pqcAndFoo, pqc); err == nil {
t.Error("expected pqc+foo mixed with pqc to fail")
}
if _, err := age.Encrypt(io.Discard, pqc, pqcAndFoo); err == nil {
t.Error("expected pqc+foo mixed with pqc to fail")
}
if _, err := age.Encrypt(io.Discard, pqc, pqc, pqcAndFoo); err == nil {
t.Error("expected pqc+foo mixed with pqc to fail")
}
if _, err := age.Encrypt(io.Discard, pqcAndFoo, pqcAndFoo); err != nil {
t.Errorf("expected two pqc+foo to work, got %v", err)
}
if _, err := age.Encrypt(io.Discard, pqcAndFoo, fooAndPQC); err != nil {
t.Errorf("expected pqc+foo mixed with foo+pqc to work, got %v", err)
}
}

View File

@@ -18,7 +18,7 @@ import (
"filippo.io/age"
"filippo.io/age/agessh"
"filippo.io/age/armor"
"filippo.io/age/internal/plugin"
"filippo.io/age/plugin"
"golang.org/x/term"
)
@@ -465,6 +465,7 @@ func decrypt(identities []age.Identity, in io.Reader, out io.Writer) {
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)
}

View File

@@ -36,19 +36,27 @@ func TestMain(m *testing.M) {
scanner := bufio.NewScanner(os.Stdin)
scanner.Scan() // add-recipient
scanner.Scan() // body
scanner.Scan() // grease
scanner.Scan() // body
scanner.Scan() // wrap-file-key
scanner.Scan() // body
fileKey := scanner.Text()
scanner.Scan() // extension-labels
scanner.Scan() // body
scanner.Scan() // done
scanner.Scan() // body
os.Stdout.WriteString("-> recipient-stanza 0 test\n")
os.Stdout.WriteString(fileKey + "\n")
scanner.Scan() // ok
scanner.Scan() // body
os.Stdout.WriteString("-> done\n\n")
return 0
case "--age-plugin=identity-v1":
scanner := bufio.NewScanner(os.Stdin)
scanner.Scan() // add-identity
scanner.Scan() // body
scanner.Scan() // grease
scanner.Scan() // body
scanner.Scan() // recipient-stanza
scanner.Scan() // body
fileKey := scanner.Text()
@@ -56,6 +64,8 @@ func TestMain(m *testing.M) {
scanner.Scan() // body
os.Stdout.WriteString("-> file-key 0\n")
os.Stdout.WriteString(fileKey + "\n")
scanner.Scan() // ok
scanner.Scan() // body
os.Stdout.WriteString("-> done\n\n")
return 0
default:

View File

@@ -15,7 +15,7 @@ import (
"filippo.io/age"
"filippo.io/age/agessh"
"filippo.io/age/armor"
"filippo.io/age/internal/plugin"
"filippo.io/age/plugin"
"golang.org/x/crypto/cryptobyte"
"golang.org/x/crypto/ssh"
)

View File

@@ -1,19 +1,19 @@
# TODO: age-encrypted private keys, multiple identities, -i ordering, -e -i,
# age file password prompt during encryption
[windows] skip # no pty support
[!linux] [!darwin] skip # no pty support
# use an encrypted OpenSSH private key without .pub file
age -R key_ed25519.pub -o ed25519.age input
rm key_ed25519.pub
pty terminal
ttyin terminal
age -d -i key_ed25519 ed25519.age
cmp stdout input
! stderr .
# -e -i with an encrypted OpenSSH private key
age -e -i key_ed25519 -o ed25519.age input
pty terminal
ttyin terminal
age -d -i key_ed25519 ed25519.age
cmp stdout input
@@ -24,7 +24,7 @@ stderr 'no identity matched any of the recipients'
# use an encrypted legacy PEM private key with a .pub file
age -R key_rsa_legacy.pub -o rsa_legacy.age input
pty terminal
ttyin terminal
age -d -i key_rsa_legacy rsa_legacy.age
cmp stdout input
! stderr .
@@ -34,7 +34,7 @@ stderr 'no identity matched any of the recipients'
# -e -i with an encrypted legacy PEM private key
age -e -i key_rsa_legacy -o rsa_legacy.age input
pty terminal
ttyin terminal
age -d -i key_rsa_legacy rsa_legacy.age
cmp stdout input
@@ -45,17 +45,17 @@ stderr 'key_rsa_legacy.pub'
# mismatched .pub file causes an error
cp key_rsa_legacy key_rsa_other
pty terminal
ttyin terminal
! age -d -i key_rsa_other rsa_other.age
stderr 'mismatched private and public SSH key'
# buffer armored ciphertext before prompting if stdin is the terminal
pty terminal
ttyin terminal
age -e -i key_ed25519 -a -o test.age input
exec cat test.age terminal # concatenated ciphertext + password
pty -stdin stdout
ttyin -stdin stdout
age -d -i key_ed25519
ptyout 'Enter passphrase'
ttyout 'Enter passphrase'
! stderr .
cmp stdout input

54
cmd/age/testdata/output_file.txt vendored Normal file
View File

@@ -0,0 +1,54 @@
# https://github.com/FiloSottile/age/issues/57
age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o test.age input
! age -o test.out -d -i wrong.txt test.age
! exists test.out
! age -o test.out -d test.age
! exists test.out
! age -o test.out -d -i notexist test.age
! exists test.out
! age -o test.out -d -i wrong.txt notexist
! exists test.out
! age -o test.out -r BAD
! exists test.out
! age -o test.out -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef notexist
! exists test.out
! age -o test.out -p notexist
! exists test.out
# https://github.com/FiloSottile/age/issues/555
age -r age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef -o empty.age empty
exists empty.age
age -d -i key.txt empty.age
! stdout .
! stderr .
age -d -i key.txt -o new empty.age
! stderr .
cmp new empty
[!linux] [!darwin] skip # no pty support
# https://github.com/FiloSottile/age/issues/159
ttyin terminal
age -p -a -o test.age input
ttyin terminalwrong
! age -o test.out -d test.age
ttyout 'Enter passphrase'
stderr 'incorrect passphrase'
! exists test.out
-- terminal --
password
password
-- terminalwrong --
wrong
-- input --
age
-- empty --
-- key.txt --
# created: 2021-02-02T13:09:43+01:00
# public key: age1xmwwc06ly3ee5rytxm9mflaz2u56jjj36s0mypdrwsvlul66mv4q47ryef
AGE-SECRET-KEY-1EGTZVFFV20835NWYV6270LXYVK2VKNX2MMDKWYKLMGR48UAWX40Q2P2LM0
-- wrong.txt --
# created: 2024-06-16T12:14:00+02:00
# public key: age10k7vsqmeg3sp8elfyq5ts55feg4huarpcaf9dmljn9umydg3gymsvx4dp9
AGE-SECRET-KEY-1NPX08S4LELW9K68FKU0U05XXEKG6X7GT004TPNYLF86H3M00D3FQ3VQQNN

View File

@@ -1,42 +1,42 @@
[windows] skip # no pty support
[!linux] [!darwin] skip # no pty support
# encrypt with a provided passphrase
stdin input
pty terminal
ttyin terminal
age -p -o test.age
ptyout 'Enter passphrase'
ttyout 'Enter passphrase'
! stderr .
! stdout .
# decrypt with a provided passphrase
pty terminal
ttyin terminal
age -d test.age
ptyout 'Enter passphrase'
ttyout 'Enter passphrase'
! stderr .
cmp stdout input
# decrypt with the wrong passphrase
pty wrong
ttyin wrong
! age -d test.age
stderr 'incorrect passphrase'
# encrypt with a generated passphrase
stdin input
pty empty
ttyin empty
age -p -o test.age
! stderr .
! stdout .
pty autogenerated
ttyin autogenerated
age -d test.age
cmp stdout input
# fail when -i is present
pty terminal
ttyin terminal
! age -d -i key.txt test.age
stderr 'file is passphrase-encrypted but identities were specified'
# fail when passphrases don't match
pty wrong
ttyin wrong
! age -p -o fail.age
stderr 'passphrases didn''t match'
! exists fail.age

View File

@@ -1,21 +1,21 @@
[windows] skip # no pty support
[!linux] [!darwin] skip # no pty support
# controlling terminal is used instead of stdin/stderr
pty terminal
ttyin terminal
age -p -o test.age input
! stderr .
# autogenerated passphrase is printed to terminal
pty empty
ttyin empty
age -p -o test.age input
ptyout 'autogenerated passphrase'
ttyout 'autogenerated passphrase'
! stderr .
# with no controlling terminal, stdin terminal is used
## TODO: enable once https://golang.org/issue/53601 is fixed
## and Noctty is added to testscript.
# noctty
# pty -stdin terminal
# ttyin -stdin terminal
# age -p -o test.age input
# ! stderr .
@@ -28,22 +28,22 @@ ptyout 'autogenerated passphrase'
# prompt for password before plaintext if stdin is the terminal
exec cat terminal input # concatenated password + input
pty -stdin stdout
ttyin -stdin stdout
age -p -a -o test.age
ptyout 'Enter passphrase'
ttyout 'Enter passphrase'
! stderr .
# check the file was encrypted correctly
pty terminal
ttyin terminal
age -d test.age
cmp stdout input
# buffer armored ciphertext before prompting if stdin is the terminal
pty terminal
ttyin terminal
age -p -a -o test.age input
exec cat test.age terminal # concatenated ciphertext + password
pty -stdin stdout
ttyin -stdin stdout
age -d
ptyout 'Enter passphrase'
ttyout 'Enter passphrase'
! stderr .
cmp stdout input

View File

@@ -23,7 +23,7 @@ import (
"runtime"
"filippo.io/age/armor"
"filippo.io/age/internal/plugin"
"filippo.io/age/plugin"
"golang.org/x/term"
)

View File

@@ -1,88 +1,56 @@
.\" generated with Ronn/v0.7.3
.\" http://github.com/rtomayko/ronn/tree/0.7.3
.
.TH "AGE\-KEYGEN" "1" "September 2022" "" ""
.
.\" generated with Ronn-NG/v0.9.1
.\" http://github.com/apjanke/ronn-ng/tree/0.9.1
.TH "AGE\-KEYGEN" "1" "April 2023" ""
.SH "NAME"
\fBage\-keygen\fR \- generate age(1) key pairs
.
.SH "SYNOPSIS"
\fBage\-keygen\fR [\fB\-o\fR \fIOUTPUT\fR]
.
.br
\fBage\-keygen\fR \fB\-y\fR [\fB\-o\fR \fIOUTPUT\fR] [\fIINPUT\fR]
.
.br
.
.SH "DESCRIPTION"
\fBage\-keygen\fR generates a new native age(1) key pair, and outputs the identity to standard output or to the \fIOUTPUT\fR file\. The output includes the public key and the current time as comments\.
.
.P
If the output is not going to a terminal, \fBage\-keygen\fR prints the public key to standard error\.
.
.SH "OPTIONS"
.
.TP
\fB\-o\fR, \fB\-\-output\fR=\fIOUTPUT\fR
Write the identity to \fIOUTPUT\fR instead of standard output\.
.
.IP
If \fIOUTPUT\fR already exists, it is not overwritten\.
.
.TP
\fB\-y\fR
Read an identity file from \fIINPUT\fR or from standard input and output the corresponding recipient(s), one per line, with no comments\.
.
.TP
\fB\-\-version\fR
Print the version and exit\.
.
.SH "EXAMPLES"
Generate a new identity:
.
.IP "" 4
.
.nf
$ age\-keygen
# created: 2021\-01\-02T15:30:45+01:00
# public key: age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z
AGE\-SECRET\-KEY\-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9
.
.fi
.
.IP "" 0
.
.P
Write a new identity to \fBkey\.txt\fR:
.
.IP "" 4
.
.nf
$ age\-keygen \-o key\.txt
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
.
.fi
.
.IP "" 0
.
.P
Convert an identity to a recipient:
.
.IP "" 4
.
.nf
$ age\-keygen \-y key\.txt
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
.
.fi
.
.IP "" 0
.
.SH "SEE ALSO"
age(1)
.
.SH "AUTHORS"
Filippo Valsorda \fIage@filippo\.io\fR

View File

@@ -1,8 +1,8 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv='content-type' value='text/html;charset=utf8'>
<meta name='generator' value='Ronn/v0.7.3 (http://github.com/rtomayko/ronn/tree/0.7.3)'>
<meta http-equiv='content-type' content='text/html;charset=utf8'>
<meta name='generator' content='Ronn-NG/v0.9.1 (http://github.com/apjanke/ronn-ng/tree/0.9.1)'>
<title>age-keygen(1) - generate age(1) key pairs</title>
<style type='text/css' media='all'>
/* style: man */
@@ -68,15 +68,16 @@
<li class='tr'>age-keygen(1)</li>
</ol>
<h2 id="NAME">NAME</h2>
<h2 id="NAME">NAME</h2>
<p class="man-name">
<code>age-keygen</code> - <span class="man-whatis">generate <a class="man-ref" href="age.1.html">age<span class="s">(1)</span></a> key pairs</span>
</p>
<h2 id="SYNOPSIS">SYNOPSIS</h2>
<p><code>age-keygen</code> [<code>-o</code> <var>OUTPUT</var>]<br />
<code>age-keygen</code> <code>-y</code> [<code>-o</code> <var>OUTPUT</var>] [<var>INPUT</var>]<br /></p>
<p><code>age-keygen</code> [<code>-o</code> <var>OUTPUT</var>]<br>
<code>age-keygen</code> <code>-y</code> [<code>-o</code> <var>OUTPUT</var>] [<var>INPUT</var>]<br></p>
<h2 id="DESCRIPTION">DESCRIPTION</h2>
@@ -90,15 +91,20 @@ standard error.</p>
<h2 id="OPTIONS">OPTIONS</h2>
<dl>
<dt><code>-o</code>, <code>--output</code>=<var>OUTPUT</var></dt><dd><p> Write the identity to <var>OUTPUT</var> instead of standard output.</p>
<dt>
<code>-o</code>, <code>--output</code>=<var>OUTPUT</var>
</dt>
<dd> Write the identity to <var>OUTPUT</var> instead of standard output.
<p> If <var>OUTPUT</var> already exists, it is not overwritten.</p></dd>
<dt class="flush"><code>-y</code></dt><dd><p> Read an identity file from <var>INPUT</var> or from standard input and output the
corresponding recipient(s), one per line, with no comments.</p></dd>
<dt><code>--version</code></dt><dd><p> Print the version and exit.</p></dd>
<p>If <var>OUTPUT</var> already exists, it is not overwritten.</p>
</dd>
<dt><code>-y</code></dt>
<dd> Read an identity file from <var>INPUT</var> or from standard input and output the
corresponding recipient(s), one per line, with no comments.</dd>
<dt><code>--version</code></dt>
<dd> Print the version and exit.</dd>
</dl>
<h2 id="EXAMPLES">EXAMPLES</h2>
<p>Generate a new identity:</p>
@@ -129,10 +135,9 @@ age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
<p>Filippo Valsorda <a href="mailto:age@filippo.io" data-bare-link="true">age@filippo.io</a></p>
<ol class='man-decor man-foot man foot'>
<li class='tl'></li>
<li class='tc'>September 2022</li>
<li class='tc'>April 2023</li>
<li class='tr'>age-keygen(1)</li>
</ol>

155
doc/age.1
View File

@@ -1,281 +1,184 @@
.\" generated with Ronn/v0.7.3
.\" http://github.com/rtomayko/ronn/tree/0.7.3
.
.TH "AGE" "1" "September 2022" "" ""
.
.\" generated with Ronn-NG/v0.9.1
.\" http://github.com/apjanke/ronn-ng/tree/0.9.1
.TH "AGE" "1" "April 2023" ""
.SH "NAME"
\fBage\fR \- simple, modern, and secure file encryption
.
.SH "SYNOPSIS"
\fBage\fR [\fB\-\-encrypt\fR] (\fB\-r\fR \fIRECIPIENT\fR | \fB\-R\fR \fIPATH\fR)\.\.\. [\fB\-\-armor\fR] [\fB\-o\fR \fIOUTPUT\fR] [\fIINPUT\fR]
.
\fBage\fR [\fB\-\-encrypt\fR] (\fB\-r\fR \fIRECIPIENT\fR | \fB\-R\fR \fIPATH\fR)\|\.\|\.\|\. [\fB\-\-armor\fR] [\fB\-o\fR \fIOUTPUT\fR] [\fIINPUT\fR]
.br
\fBage\fR [\fB\-\-encrypt\fR] \fB\-\-passphrase\fR [\fB\-\-armor\fR] [\fB\-o\fR \fIOUTPUT\fR] [\fIINPUT\fR]
.
.br
\fBage\fR \fB\-\-decrypt\fR [\fB\-i\fR \fIPATH\fR | \fB\-j\fR \fIPLUGIN\fR]\.\.\. [\fB\-o\fR \fIOUTPUT\fR] [\fIINPUT\fR]
.
\fBage\fR \fB\-\-decrypt\fR [\fB\-i\fR \fIPATH\fR | \fB\-j\fR \fIPLUGIN\fR]\|\.\|\.\|\. [\fB\-o\fR \fIOUTPUT\fR] [\fIINPUT\fR]
.br
.
.SH "DESCRIPTION"
\fBage\fR encrypts or decrypts \fIINPUT\fR to \fIOUTPUT\fR\. The \fIINPUT\fR argument is optional and defaults to standard input\. Only a single \fIINPUT\fR file may be specified\. If \fB\-o\fR is not specified, \fIOUTPUT\fR defaults to standard output\.
.
.P
If \fB\-p\fR/\fB\-\-passphrase\fR is specified, the file is encrypted with a passphrase requested interactively\. Otherwise, it\'s encrypted to one or more \fIRECIPIENTS\fR specified with \fB\-r\fR/\fB\-\-recipient\fR or \fB\-R\fR/\fB\-\-recipients\-file\fR\. Every recipient can decrypt the file\.
.
If \fB\-p\fR/\fB\-\-passphrase\fR is specified, the file is encrypted with a passphrase requested interactively\. Otherwise, it's encrypted to one or more \fIRECIPIENTS\fR specified with \fB\-r\fR/\fB\-\-recipient\fR or \fB\-R\fR/\fB\-\-recipients\-file\fR\. Every recipient can decrypt the file\.
.P
In \fB\-d\fR/\fB\-\-decrypt\fR mode, passphrase\-encrypted files are detected automatically and the passphrase is requested interactively\. Otherwise, one or more \fIIDENTITIES\fR specified with \fB\-i\fR/\fB\-\-identity\fR are used to decrypt the file\.
.
.P
\fBage\fR encrypted files are binary and not malleable, with around 200 bytes of overhead per recipient, plus 16 bytes every 64KiB of plaintext\.
.
.SH "OPTIONS"
.
.TP
\fB\-o\fR, \fB\-\-output\fR=\fIOUTPUT\fR
Write encrypted or decrypted file to \fIOUTPUT\fR instead of standard output\. If \fIOUTPUT\fR already exists it will be overwritten\.
.
.IP
If encrypting without \fB\-\-armor\fR, \fBage\fR will refuse to output binary to a TTY\. This can be forced by specifying \fB\-\fR as \fIOUTPUT\fR\.
.
.TP
\fB\-\-version\fR
Print the version and exit\.
.
.SS "Encryption options"
.
.TP
\fB\-e\fR, \fB\-\-encrypt\fR
Encrypt \fIINPUT\fR to \fIOUTPUT\fR\. This is the default\.
.
.TP
\fB\-r\fR, \fB\-\-recipient\fR=\fIRECIPIENT\fR
Encrypt to the explicitly specified \fIRECIPIENT\fR\. See the \fIRECIPIENTS AND IDENTITIES\fR section for possible recipient formats\.
.
.IP
This option can be repeated and combined with other recipient flags, and the file can be decrypted by all provided recipients independently\.
.
.TP
\fB\-R\fR, \fB\-\-recipients\-file\fR=\fIPATH\fR
Encrypt to the \fIRECIPIENTS\fR listed in the file at \fIPATH\fR, one per line\. Empty lines and lines starting with \fB#\fR are ignored as comments\.
.
.IP
If \fIPATH\fR is \fB\-\fR, the recipients are read from standard input\. In this case, the \fIINPUT\fR argument must be specified\.
.
.IP
This option can be repeated and combined with other recipient flags, and the file can be decrypted by all provided recipients independently\.
.
.TP
\fB\-p\fR, \fB\-\-passphrase\fR
Encrypt with a passphrase, requested interactively from the terminal\. \fBage\fR will offer to auto\-generate a secure passphrase\.
.
.IP
This option can\'t be used with other recipient flags\.
.
This option can't be used with other recipient flags\.
.TP
\fB\-a\fR, \fB\-\-armor\fR
Encrypt to an ASCII\-only "armored" encoding\.
.
.IP
\fBage\fR armor is a strict version of PEM with type \fBAGE ENCRYPTED FILE\fR, canonical "strict" Base64, no headers, and no support for leading and trailing extra data\.
.
.IP
Decryption transparently detects and decodes ASCII armoring\.
.
.TP
\fB\-i\fR, \fB\-\-identity\fR=\fIPATH\fR
Encrypt to the \fIRECIPIENTS\fR corresponding to the \fIIDENTITIES\fR listed in the file at \fIPATH\fR\. This is equivalent to converting the file at \fIPATH\fR to a recipients file with \fBage\-keygen \-y\fR and then passing that to \fB\-R\fR/\fB\-\-recipients\-file\fR\.
.
.IP
For the format of \fIPATH\fR, see the definition of \fB\-i\fR/\fB\-\-identity\fR in the \fIDecryption options\fR section\.
.
.IP
\fB\-e\fR/\fB\-\-encrypt\fR must be explicitly specified when using \fB\-i\fR/\fB\-\-identity\fR in encryption mode to avoid confusion\.
.
.TP
\fB\-j\fR \fIPLUGIN\fR
Encrypt using the data\-less \fIplugin\fR \fIPLUGIN\fR\.
.
.IP
This is equivalent to using \fB\-i\fR/\fB\-\-identity\fR with a file that contains a single plugin \fBIDENTITY\fR that encodes no plugin\-specific data\.
.
.IP
\fB\-e\fR/\fB\-\-encrypt\fR must be explicitly specified when using \fB\-j\fR in encryption mode to avoid confusion\.
.
.SS "Decryption options"
.
.TP
\fB\-d\fR, \fB\-\-decrypt\fR
Decrypt \fIINPUT\fR to \fIOUTPUT\fR\.
.
.IP
If \fIINPUT\fR is passphrase encrypted, it will be automatically detected and the passphrase will be requested interactively\. Otherwise, the \fIIDENTITIES\fR specified with \fB\-i\fR/\fB\-\-identity\fR are used\.
.
.IP
ASCII armoring is transparently detected and decoded\.
.
.TP
\fB\-i\fR, \fB\-\-identity\fR=\fIPATH\fR
Decrypt using the \fIIDENTITIES\fR at \fIPATH\fR\.
.
.IP
\fIPATH\fR may be one of the following:
.
.IP
a\. A file listing \fIIDENTITIES\fR one per line\. Empty lines and lines starting with "\fB#\fR" are ignored as comments\.
.
.IP
b\. A passphrase encrypted age file, containing \fIIDENTITIES\fR one per line like above\. The passphrase is requested interactively\. Note that passphrase\-protected identity files are not necessary for most use cases, where access to the encrypted identity file implies access to the whole system\.
.
.IP
c\. An SSH private key file, in PKCS#1, PKCS#8, or OpenSSH format\. If the private key is password\-protected, the password is requested interactively only if the SSH identity matches the file\. See the \fISSH keys\fR section for more information, including supported key types\.
.
.IP
d\. "\fB\-\fR", causing one of the options above to be read from standard input\. In this case, the \fIINPUT\fR argument must be specified\.
.
.IP
This option can be repeated\. Identities are tried in the order in which are provided, and the first one matching one of the file\'s recipients is used\. Unused identities are ignored, but it is an error if the \fIINPUT\fR file is passphrase\-encrypted and \fB\-i\fR/\fB\-\-identity\fR is specified\.
.
This option can be repeated\. Identities are tried in the order in which are provided, and the first one matching one of the file's recipients is used\. Unused identities are ignored, but it is an error if the \fIINPUT\fR file is passphrase\-encrypted and \fB\-i\fR/\fB\-\-identity\fR is specified\.
.TP
\fB\-j\fR \fIPLUGIN\fR
Decrypt using the data\-less \fIplugin\fR \fIPLUGIN\fR\.
.
.IP
This is equivalent to using \fB\-i\fR/\fB\-\-identity\fR with a file that contains a single plugin \fBIDENTITY\fR that encodes no plugin\-specific data\.
.
.SH "RECIPIENTS AND IDENTITIES"
\fBRECIPIENTS\fR are public values, like a public key, that a file can be encrypted to\. \fBIDENTITIES\fR are private values, like a private key, that allow decrypting a file encrypted to the corresponding \fBRECIPIENT\fR\.
.
.SS "Native X25519 keys"
Native \fBage\fR key pairs are generated with age\-keygen(1), and provide small encodings and strong encryption based on X25519\. They are the recommended recipient type for most applications\.
.
.P
A \fBRECIPIENT\fR encoding begins with \fBage1\fR and looks like the following:
.
.IP "" 4
.
.nf
age1gde3ncmahlqd9gg50tanl99r960llztrhfapnmx853s4tjum03uqfssgdh
.
.fi
.
.IP "" 0
.
.P
An \fBIDENTITY\fR encoding begins with \fBAGE\-SECRET\-KEY\-1\fR and looks like the following:
.
.IP "" 4
.
.nf
AGE\-SECRET\-KEY\-1KTYK6RVLN5TAPE7VF6FQQSKZ9HWWCDSKUGXXNUQDWZ7XXT5YK5LSF3UTKQ
.
.fi
.
.IP "" 0
.
.P
An encrypted file can\'t be linked to the native recipient it\'s encrypted to without access to the corresponding identity\.
.
An encrypted file can't be linked to the native recipient it's encrypted to without access to the corresponding identity\.
.SS "SSH keys"
As a convenience feature, \fBage\fR also supports encrypting to RSA or Ed25519 ssh(1) keys\. RSA keys must be at least 2048 bits\. This feature employs more complex cryptography, and should only be used when a native key is not available for the recipient\. Note that SSH keys might not be protected long\-term by the recipient, since they are revokable when used only for authentication\.
.
.P
A \fBRECIPIENT\fR encoding is an SSH public key in \fBauthorized_keys\fR format (see the \fBAUTHORIZED_KEYS FILE FORMAT\fR section of sshd(8)), starting with \fBssh\-rsa\fR or \fBssh\-ed25519\fR, like the following:
.
.IP "" 4
.
.nf
ssh\-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDULTit0KUehbi[\.\.\.]GU4BtElAbzh8=
ssh\-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH9pO5pz22JZEas[\.\.\.]l1uZc31FGYMXa
.
ssh\-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDULTit0KUehbi[\|\.\|\.\|\.]GU4BtElAbzh8=
ssh\-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH9pO5pz22JZEas[\|\.\|\.\|\.]l1uZc31FGYMXa
.fi
.
.IP "" 0
.
.P
The comment at the end of the line, if present, is ignored\.
.
.P
In recipient files passed to \fB\-R\fR/\fB\-\-recipients\-file\fR, unsupported but valid SSH public keys are ignored with a warning, to facilitate using \fBauthorized_keys\fR or GitHub \fB\.keys\fR files\. (See \fIEXAMPLES\fR\.)
.
.P
An \fBIDENTITY\fR is an SSH private key \fIfile\fR passed individually to \fB\-i\fR/\fB\-\-identity\fR\. Note that keys held on hardware tokens such as YubiKeys or accessed via ssh\-agent(1) are not supported\.
.
.P
An encrypted file \fIcan\fR be linked to the SSH public key it was encrypted to\. This is so that \fBage\fR can identify the correct SSH private key before requesting its password, if any\.
.
.SS "Plugins"
\fBage\fR can be extended through plugins\. A plugin is only loaded if a corresponding \fBRECIPIENT\fR or \fBIDENTITY\fR is specified\. (Simply decrypting a file encrypted with a plugin will not cause it to load, for security reasons among others\.)
.
.P
A \fBRECIPIENT\fR for a plugin named \fBexample\fR starts with \fBage1example1\fR, while an \fBIDENTITY\fR starts with \fBAGE\-PLUGIN\-EXAMPLE\-1\fR\. They both encode arbitrary plugin\-specific data, and are generated by the plugin\.
.
.P
When either is specified, \fBage\fR searches for \fBage\-plugin\-example\fR in the PATH and executes it to perform the file header encryption or decryption\. The plugin may request input from the user through \fBage\fR to complete the operation\.
.
.P
Plugins can be freely mixed with other plugins or natively supported keys\.
.
.P
A plugin is not bound to only encrypt or decrypt files meant for or generated by the plugin\. For example, a plugin can be used to decrypt files encrypted to a native X25519 \fBRECIPIENT\fR or even with a passphrase\. Similarly, a plugin can encrypt a file such that it can be decrypted without the use of any plugin\.
.
.P
Plugins for which the \fBIDENTITY\fR/\fBRECIPIENT\fR distinction doesn\'t make sense (such as a symmetric encryption plugin) may generate only an \fBIDENTITY\fR and instruct the user to perform encryption with the \fB\-e\fR/\fB\-\-encrypt\fR and \fB\-i\fR/\fB\-\-identity\fR flags\. Plugins for which the concept of separate identities doesn\'t make sense (such as a password\-encryption plugin) may instruct the user to use the \fB\-j\fR flag\.
.
Plugins for which the \fBIDENTITY\fR/\fBRECIPIENT\fR distinction doesn't make sense (such as a symmetric encryption plugin) may generate only an \fBIDENTITY\fR and instruct the user to perform encryption with the \fB\-e\fR/\fB\-\-encrypt\fR and \fB\-i\fR/\fB\-\-identity\fR flags\. Plugins for which the concept of separate identities doesn't make sense (such as a password\-encryption plugin) may instruct the user to use the \fB\-j\fR flag\.
.SH "EXIT STATUS"
\fBage\fR will exit 0 if and only if encryption or decryption are successful for the full length of the input\.
.
.P
If an error occurs during decryption, partial output might still be generated, but only if it was possible to securely authenticate it\. No unauthenticated output is ever released\.
.
.SH "BACKWARDS COMPATIBILITY"
Files encrypted with a stable version (not alpha, beta, or release candidate) of \fBage\fR, or with any v1\.0\.0 beta or release candidate, will decrypt with any later version of the tool\.
.
.P
If decrypting older files poses a security risk, doing so might cause an error by default\. In this case, a flag will be provided to force the operation\.
.
.SH "EXAMPLES"
Generate a new identity, encrypt data, and decrypt:
.
.IP "" 4
.
.nf
$ age\-keygen \-o key\.txt
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
$ tar cvz ~/data | age \-r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p > data\.tar\.gz\.age
$ age \-d \-o data\.tar\.gz \-i key\.txt data\.tar\.gz\.age
.
.fi
.
.IP "" 0
.
.P
Encrypt \fBexample\.jpg\fR to multiple recipients and output to \fBexample\.jpg\.age\fR:
.
.IP "" 4
.
.nf
$ age \-o example\.jpg\.age \-r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p \e
\-r age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg example\.jpg
.
.fi
.
.IP "" 0
.
.P
Encrypt to a list of recipients:
.
.IP "" 4
.
.nf
$ cat > recipients\.txt
# Alice
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
@@ -283,36 +186,24 @@ age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg
$ age \-R recipients\.txt example\.jpg > example\.jpg\.age
.
.fi
.
.IP "" 0
.
.P
Encrypt and decrypt a file using a passphrase:
.
.IP "" 4
.
.nf
$ 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"\.
$ age \-d secrets\.txt\.age > secrets\.txt
Enter passphrase:
.
.fi
.
.IP "" 0
.
.P
Encrypt and decrypt with a passphrase\-protected identity file:
.
.IP "" 4
.
.nf
$ age\-keygen | age \-p > key\.age
Public key: age1yhm4gctwfmrpz87tdslm550wrx6m79y9f2hdzt0lndjnehwj0ukqrjpyx5
Enter passphrase (leave empty to autogenerate a secure one):
@@ -322,58 +213,36 @@ $ age \-r age1yhm4gctwfmrpz87tdslm550wrx6m79y9f2hdzt0lndjnehwj0ukqrjpyx5 secrets
$ age \-d \-i key\.age secrets\.txt\.age > secrets\.txt
Enter passphrase for identity file "key\.age":
.
.fi
.
.IP "" 0
.
.P
Encrypt and decrypt with an SSH public key:
.
.IP "" 4
.
.nf
$ age \-R ~/\.ssh/id_ed25519\.pub example\.jpg > example\.jpg\.age
$ age \-d \-i ~/\.ssh/id_ed25519 example\.jpg\.age > example\.jpg
.
.fi
.
.IP "" 0
.
.P
Encrypt and decrypt with age\-plugin\-yubikey:
.
.IP "" 4
.
.nf
$ age\-plugin\-yubikey # run interactive setup, generate identity file and obtain recipient
$ age \-r age1yubikey1qwt50d05nh5vutpdzmlg5wn80xq5negm4uj9ghv0snvdd3yysf5yw3rhl3t secrets\.txt > secrets\.txt\.age
$ age \-d \-i age\-yubikey\-identity\-388178f3\.txt secrets\.txt\.age
.
.fi
.
.IP "" 0
.
.P
Encrypt to the SSH keys of a GitHub user:
.
.IP "" 4
.
.nf
$ curl https://github\.com/benjojo\.keys | age \-R \- example\.jpg > example\.jpg\.age
.
.fi
.
.IP "" 0
.
.SH "SEE ALSO"
age\-keygen(1)
.
.SH "AUTHORS"
Filippo Valsorda \fIage@filippo\.io\fR

View File

@@ -1,8 +1,8 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv='content-type' value='text/html;charset=utf8'>
<meta name='generator' value='Ronn/v0.7.3 (http://github.com/rtomayko/ronn/tree/0.7.3)'>
<meta http-equiv='content-type' content='text/html;charset=utf8'>
<meta name='generator' content='Ronn-NG/v0.9.1 (http://github.com/apjanke/ronn-ng/tree/0.9.1)'>
<title>age(1) - simple, modern, and secure file encryption</title>
<style type='text/css' media='all'>
/* style: man */
@@ -71,16 +71,17 @@
<li class='tr'>age(1)</li>
</ol>
<h2 id="NAME">NAME</h2>
<h2 id="NAME">NAME</h2>
<p class="man-name">
<code>age</code> - <span class="man-whatis">simple, modern, and secure file encryption</span>
</p>
<h2 id="SYNOPSIS">SYNOPSIS</h2>
<p><code>age</code> [<code>--encrypt</code>] (<code>-r</code> <var>RECIPIENT</var> | <code>-R</code> <var>PATH</var>)... [<code>--armor</code>] [<code>-o</code> <var>OUTPUT</var>] [<var>INPUT</var>]<br />
<code>age</code> [<code>--encrypt</code>] <code>--passphrase</code> [<code>--armor</code>] [<code>-o</code> <var>OUTPUT</var>] [<var>INPUT</var>]<br />
<code>age</code> <code>--decrypt</code> [<code>-i</code> <var>PATH</var> | <code>-j</code> <var>PLUGIN</var>]... [<code>-o</code> <var>OUTPUT</var>] [<var>INPUT</var>]<br /></p>
<p><code>age</code> [<code>--encrypt</code>] (<code>-r</code> <var>RECIPIENT</var> | <code>-R</code> <var>PATH</var>)... [<code>--armor</code>] [<code>-o</code> <var>OUTPUT</var>] [<var>INPUT</var>]<br>
<code>age</code> [<code>--encrypt</code>] <code>--passphrase</code> [<code>--armor</code>] [<code>-o</code> <var>OUTPUT</var>] [<var>INPUT</var>]<br>
<code>age</code> <code>--decrypt</code> [<code>-i</code> <var>PATH</var> | <code>-j</code> <var>PLUGIN</var>]... [<code>-o</code> <var>OUTPUT</var>] [<var>INPUT</var>]<br></p>
<h2 id="DESCRIPTION">DESCRIPTION</h2>
@@ -104,107 +105,148 @@ overhead per recipient, plus 16 bytes every 64KiB of plaintext.</p>
<h2 id="OPTIONS">OPTIONS</h2>
<dl>
<dt><code>-o</code>, <code>--output</code>=<var>OUTPUT</var></dt><dd><p> Write encrypted or decrypted file to <var>OUTPUT</var> instead of standard output.
If <var>OUTPUT</var> already exists it will be overwritten.</p>
<dt>
<code>-o</code>, <code>--output</code>=<var>OUTPUT</var>
</dt>
<dd> Write encrypted or decrypted file to <var>OUTPUT</var> instead of standard output.
If <var>OUTPUT</var> already exists it will be overwritten.
<p> If encrypting without <code>--armor</code>, <code>age</code> will refuse to output binary to a
TTY. This can be forced by specifying <code>-</code> as <var>OUTPUT</var>.</p></dd>
<dt><code>--version</code></dt><dd><p> Print the version and exit.</p></dd>
<p>If encrypting without <code>--armor</code>, <code>age</code> will refuse to output binary to a
TTY. This can be forced by specifying <code>-</code> as <var>OUTPUT</var>.</p>
</dd>
<dt><code>--version</code></dt>
<dd> Print the version and exit.</dd>
</dl>
<h3 id="Encryption-options">Encryption options</h3>
<dl>
<dt><code>-e</code>, <code>--encrypt</code></dt><dd><p> Encrypt <var>INPUT</var> to <var>OUTPUT</var>. This is the default.</p></dd>
<dt><code>-r</code>, <code>--recipient</code>=<var>RECIPIENT</var></dt><dd><p> Encrypt to the explicitly specified <var>RECIPIENT</var>. See the
<a href="#RECIPIENTS-AND-IDENTITIES" title="RECIPIENTS AND IDENTITIES" data-bare-link="true">RECIPIENTS AND IDENTITIES</a> section for possible recipient formats.</p>
<dt>
<code>-e</code>, <code>--encrypt</code>
</dt>
<dd> Encrypt <var>INPUT</var> to <var>OUTPUT</var>. This is the default.</dd>
<dt>
<code>-r</code>, <code>--recipient</code>=<var>RECIPIENT</var>
</dt>
<dd> Encrypt to the explicitly specified <var>RECIPIENT</var>. See the
<a href="#RECIPIENTS-AND-IDENTITIES" title="RECIPIENTS AND IDENTITIES" data-bare-link="true">RECIPIENTS AND IDENTITIES</a> section for possible recipient formats.
<p> This option can be repeated and combined with other recipient flags,
and the file can be decrypted by all provided recipients independently.</p></dd>
<dt><code>-R</code>, <code>--recipients-file</code>=<var>PATH</var></dt><dd><p> Encrypt to the <a href="#RECIPIENTS-AND-IDENTITIES" title="RECIPIENTS AND IDENTITIES" data-bare-link="true">RECIPIENTS</a> listed in the
<p>This option can be repeated and combined with other recipient flags,
and the file can be decrypted by all provided recipients independently.</p>
</dd>
<dt>
<code>-R</code>, <code>--recipients-file</code>=<var>PATH</var>
</dt>
<dd> Encrypt to the <a href="#RECIPIENTS-AND-IDENTITIES" title="RECIPIENTS AND IDENTITIES" data-bare-link="true">RECIPIENTS</a> listed in the
file at <var>PATH</var>, one per line. Empty lines and lines starting with <code>#</code>
are ignored as comments.</p>
are ignored as comments.
<p> If <var>PATH</var> is <code>-</code>, the recipients are read from standard input. In
<p>If <var>PATH</var> is <code>-</code>, the recipients are read from standard input. In
this case, the <var>INPUT</var> argument must be specified.</p>
<p> This option can be repeated and combined with other recipient flags,
and the file can be decrypted by all provided recipients independently.</p></dd>
<dt><code>-p</code>, <code>--passphrase</code></dt><dd><p> Encrypt with a passphrase, requested interactively from the terminal.
<code>age</code> will offer to auto-generate a secure passphrase.</p>
<p>This option can be repeated and combined with other recipient flags,
and the file can be decrypted by all provided recipients independently.</p>
</dd>
<dt>
<code>-p</code>, <code>--passphrase</code>
</dt>
<dd> Encrypt with a passphrase, requested interactively from the terminal.
<code>age</code> will offer to auto-generate a secure passphrase.
<p> This option can't be used with other recipient flags.</p></dd>
<dt><code>-a</code>, <code>--armor</code></dt><dd><p> Encrypt to an ASCII-only "armored" encoding.</p>
<p>This option can't be used with other recipient flags.</p>
</dd>
<dt>
<code>-a</code>, <code>--armor</code>
</dt>
<dd> Encrypt to an ASCII-only "armored" encoding.
<p> <code>age</code> armor is a strict version of PEM with type <code>AGE ENCRYPTED FILE</code>,
<p><code>age</code> armor is a strict version of PEM with type <code>AGE ENCRYPTED FILE</code>,
canonical "strict" Base64, no headers, and no support for leading and
trailing extra data.</p>
<p> Decryption transparently detects and decodes ASCII armoring.</p></dd>
<dt><code>-i</code>, <code>--identity</code>=<var>PATH</var></dt><dd><p> Encrypt to the <a href="#RECIPIENTS-AND-IDENTITIES" title="RECIPIENTS AND IDENTITIES" data-bare-link="true">RECIPIENTS</a> corresponding to the
<p>Decryption transparently detects and decodes ASCII armoring.</p>
</dd>
<dt>
<code>-i</code>, <code>--identity</code>=<var>PATH</var>
</dt>
<dd> Encrypt to the <a href="#RECIPIENTS-AND-IDENTITIES" title="RECIPIENTS AND IDENTITIES" data-bare-link="true">RECIPIENTS</a> corresponding to the
<a href="#RECIPIENTS-AND-IDENTITIES" title="RECIPIENTS AND IDENTITIES" data-bare-link="true">IDENTITIES</a> listed in the file at <var>PATH</var>. This
is equivalent to converting the file at <var>PATH</var> to a recipients file with
<code>age-keygen -y</code> and then passing that to <code>-R</code>/<code>--recipients-file</code>.</p>
<code>age-keygen -y</code> and then passing that to <code>-R</code>/<code>--recipients-file</code>.
<p> For the format of <var>PATH</var>, see the definition of <code>-i</code>/<code>--identity</code> in the
<p>For the format of <var>PATH</var>, see the definition of <code>-i</code>/<code>--identity</code> in the
<a href="#Decryption-options" title="Decryption options" data-bare-link="true">Decryption options</a> section.</p>
<p> <code>-e</code>/<code>--encrypt</code> must be explicitly specified when using <code>-i</code>/<code>--identity</code>
in encryption mode to avoid confusion.</p></dd>
<dt><code>-j</code> <var>PLUGIN</var></dt><dd><p> Encrypt using the data-less <a href="#Plugins" title="Plugins" data-bare-link="true">plugin</a> <var>PLUGIN</var>.</p>
<p><code>-e</code>/<code>--encrypt</code> must be explicitly specified when using <code>-i</code>/<code>--identity</code>
in encryption mode to avoid confusion.</p>
</dd>
<dt>
<code>-j</code> <var>PLUGIN</var>
</dt>
<dd> Encrypt using the data-less <a href="#Plugins" title="Plugins" data-bare-link="true">plugin</a> <var>PLUGIN</var>.
<p> This is equivalent to using <code>-i</code>/<code>--identity</code> with a file that contains a
<p>This is equivalent to using <code>-i</code>/<code>--identity</code> with a file that contains a
single plugin <code>IDENTITY</code> that encodes no plugin-specific data.</p>
<p> <code>-e</code>/<code>--encrypt</code> must be explicitly specified when using <code>-j</code> in encryption
mode to avoid confusion.</p></dd>
<p><code>-e</code>/<code>--encrypt</code> must be explicitly specified when using <code>-j</code> in encryption
mode to avoid confusion.</p>
</dd>
</dl>
<h3 id="Decryption-options">Decryption options</h3>
<dl>
<dt><code>-d</code>, <code>--decrypt</code></dt><dd><p> Decrypt <var>INPUT</var> to <var>OUTPUT</var>.</p>
<dt>
<code>-d</code>, <code>--decrypt</code>
</dt>
<dd> Decrypt <var>INPUT</var> to <var>OUTPUT</var>.
<p> If <var>INPUT</var> is passphrase encrypted, it will be automatically detected
<p>If <var>INPUT</var> is passphrase encrypted, it will be automatically detected
and the passphrase will be requested interactively. Otherwise, the
<a href="#RECIPIENTS-AND-IDENTITIES" title="RECIPIENTS AND IDENTITIES" data-bare-link="true">IDENTITIES</a> specified with <code>-i</code>/<code>--identity</code>
are used.</p>
<p> ASCII armoring is transparently detected and decoded.</p></dd>
<dt><code>-i</code>, <code>--identity</code>=<var>PATH</var></dt><dd><p> Decrypt using the <a href="#RECIPIENTS-AND-IDENTITIES" title="RECIPIENTS AND IDENTITIES" data-bare-link="true">IDENTITIES</a> at <var>PATH</var>.</p>
<p>ASCII armoring is transparently detected and decoded.</p>
</dd>
<dt>
<code>-i</code>, <code>--identity</code>=<var>PATH</var>
</dt>
<dd> Decrypt using the <a href="#RECIPIENTS-AND-IDENTITIES" title="RECIPIENTS AND IDENTITIES" data-bare-link="true">IDENTITIES</a> at <var>PATH</var>.
<p> <var>PATH</var> may be one of the following:</p>
<p><var>PATH</var> may be one of the following:</p>
<p> a. A file listing <a href="#RECIPIENTS-AND-IDENTITIES" title="RECIPIENTS AND IDENTITIES" data-bare-link="true">IDENTITIES</a> one per line.
<p>a. A file listing <a href="#RECIPIENTS-AND-IDENTITIES" title="RECIPIENTS AND IDENTITIES" data-bare-link="true">IDENTITIES</a> one per line.
Empty lines and lines starting with "<code>#</code>" are ignored as comments.</p>
<p> b. A passphrase encrypted age file, containing
<p>b. A passphrase encrypted age file, containing
<a href="#RECIPIENTS-AND-IDENTITIES" title="RECIPIENTS AND IDENTITIES" data-bare-link="true">IDENTITIES</a> one per line like above.
The passphrase is requested interactively. Note that passphrase-protected
identity files are not necessary for most use cases, where access to the
encrypted identity file implies access to the whole system.</p>
<p> c. An SSH private key file, in PKCS#1, PKCS#8, or OpenSSH format.
<p>c. An SSH private key file, in PKCS#1, PKCS#8, or OpenSSH format.
If the private key is password-protected, the password is requested
interactively only if the SSH identity matches the file. See the
<a href="#SSH-keys" title="SSH keys" data-bare-link="true">SSH keys</a> section for more information, including supported key types.</p>
<p> d. "<code>-</code>", causing one of the options above to be read from standard input.
<p>d. "<code>-</code>", causing one of the options above to be read from standard input.
In this case, the <var>INPUT</var> argument must be specified.</p>
<p> This option can be repeated. Identities are tried in the order in which are
<p>This option can be repeated. Identities are tried in the order in which are
provided, and the first one matching one of the file's recipients is used.
Unused identities are ignored, but it is an error if the <var>INPUT</var> file is
passphrase-encrypted and <code>-i</code>/<code>--identity</code> is specified.</p></dd>
<dt><code>-j</code> <var>PLUGIN</var></dt><dd><p> Decrypt using the data-less <a href="#Plugins" title="Plugins" data-bare-link="true">plugin</a> <var>PLUGIN</var>.</p>
passphrase-encrypted and <code>-i</code>/<code>--identity</code> is specified.</p>
</dd>
<dt>
<code>-j</code> <var>PLUGIN</var>
</dt>
<dd> Decrypt using the data-less <a href="#Plugins" title="Plugins" data-bare-link="true">plugin</a> <var>PLUGIN</var>.
<p> This is equivalent to using <code>-i</code>/<code>--identity</code> with a file that contains a
single plugin <code>IDENTITY</code> that encodes no plugin-specific data.</p></dd>
<p>This is equivalent to using <code>-i</code>/<code>--identity</code> with a file that contains a
single plugin <code>IDENTITY</code> that encodes no plugin-specific data.</p>
</dd>
</dl>
<h2 id="RECIPIENTS-AND-IDENTITIES">RECIPIENTS AND IDENTITIES</h2>
<p><code>RECIPIENTS</code> are public values, like a public key, that a file can be encrypted
@@ -388,10 +430,9 @@ $ age -d -i age-yubikey-identity-388178f3.txt secrets.txt.age
<p>Filippo Valsorda <a href="mailto:age@filippo.io" data-bare-link="true">age@filippo.io</a></p>
<ol class='man-decor man-foot man foot'>
<li class='tl'></li>
<li class='tc'>September 2022</li>
<li class='tc'>April 2023</li>
<li class='tr'>age(1)</li>
</ol>

18
go.mod
View File

@@ -1,19 +1,17 @@
module filippo.io/age
go 1.17
go 1.19
require (
filippo.io/edwards25519 v1.0.0-rc.1
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
golang.org/x/sys v0.0.0-20210903071746-97244b99971b
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b
filippo.io/edwards25519 v1.1.0
golang.org/x/crypto v0.24.0
golang.org/x/sys v0.21.0
golang.org/x/term v0.21.0
)
// Test dependencies.
require (
github.com/creack/pty v1.1.18 // indirect
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e // indirect
github.com/rogpeppe/go-internal v1.8.1
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805
github.com/rogpeppe/go-internal v1.12.0
golang.org/x/tools v0.22.0 // indirect
)
replace github.com/rogpeppe/go-internal => github.com/FiloSottile/go-internal v1.8.2-0.20220728122003-0ced171a3e0e

34
go.sum
View File

@@ -1,20 +1,14 @@
filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU=
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
github.com/FiloSottile/go-internal v1.8.2-0.20220728122003-0ced171a3e0e h1:hLDldUUKSNgXte+2H8yZzPVStWa7697IJer9wwfW/dg=
github.com/FiloSottile/go-internal v1.8.2-0.20220728122003-0ced171a3e0e/go.mod h1:dNbK7mWDMlmf5ttOAJJg+a4CyamnqDRrw+Uja1sBETc=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210903071746-97244b99971b h1:3Dq0eVHn0uaQJmPO+/aYPI/fRMqdrVDbu7MQcku54gg=
golang.org/x/sys v0.0.0-20210903071746-97244b99971b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b h1:9zKuko04nR4gjZ4+DNjHqRlAJqbJETHwiNKDqTfOjfE=
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0=
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=

View File

@@ -12,15 +12,15 @@ import (
"bytes"
"fmt"
"io"
"math/rand"
"os"
"path/filepath"
"strconv"
"strings"
"time"
exec "golang.org/x/sys/execabs"
"filippo.io/age"
"filippo.io/age/internal/bech32"
"filippo.io/age/internal/format"
)
@@ -34,16 +34,13 @@ type Recipient struct {
}
var _ age.Recipient = &Recipient{}
var _ age.RecipientWithLabels = &Recipient{}
func NewRecipient(s string, ui *ClientUI) (*Recipient, error) {
hrp, _, err := bech32.Decode(s)
name, _, err := ParseRecipient(s)
if err != nil {
return nil, fmt.Errorf("invalid recipient encoding %q: %v", s, err)
return nil, err
}
if !strings.HasPrefix(hrp, "age1") {
return nil, fmt.Errorf("not a plugin recipient %q: %v", s, err)
}
name := strings.TrimPrefix(hrp, "age1")
return &Recipient{
name: name, encoding: s, ui: ui,
}, nil
@@ -57,6 +54,11 @@ func (r *Recipient) Name() string {
}
func (r *Recipient) Wrap(fileKey []byte) (stanzas []*age.Stanza, err error) {
stanzas, _, err = r.WrapWithLabels(fileKey)
return
}
func (r *Recipient) WrapWithLabels(fileKey []byte) (stanzas []*age.Stanza, labels []string, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("%s plugin: %w", r.name, err)
@@ -65,7 +67,7 @@ func (r *Recipient) Wrap(fileKey []byte) (stanzas []*age.Stanza, err error) {
conn, err := openClientConnection(r.name, "recipient-v1")
if err != nil {
return nil, fmt.Errorf("couldn't start plugin: %v", err)
return nil, nil, fmt.Errorf("couldn't start plugin: %v", err)
}
defer conn.Close()
@@ -75,13 +77,19 @@ func (r *Recipient) Wrap(fileKey []byte) (stanzas []*age.Stanza, err error) {
addType = "add-identity"
}
if err := writeStanza(conn, addType, r.encoding); err != nil {
return nil, err
return nil, nil, err
}
if err := writeStanza(conn, fmt.Sprintf("grease-%x", rand.Int())); err != nil {
return nil, nil, err
}
if err := writeStanzaWithBody(conn, "wrap-file-key", fileKey); err != nil {
return nil, err
return nil, nil, err
}
if err := writeStanza(conn, "extension-labels"); err != nil {
return nil, nil, err
}
if err := writeStanza(conn, "done"); err != nil {
return nil, err
return nil, nil, err
}
// Phase 2: plugin responds with stanzas
@@ -90,21 +98,21 @@ ReadLoop:
for {
s, err := r.ui.readStanza(r.name, sr)
if err != nil {
return nil, err
return nil, nil, err
}
switch s.Type {
case "recipient-stanza":
if len(s.Args) < 2 {
return nil, fmt.Errorf("malformed recipient stanza: unexpected argument count")
return nil, nil, fmt.Errorf("malformed recipient stanza: unexpected argument count")
}
n, err := strconv.Atoi(s.Args[0])
if err != nil {
return nil, fmt.Errorf("malformed recipient stanza: invalid index")
return nil, nil, fmt.Errorf("malformed recipient stanza: invalid index")
}
// We only send a single file key, so the index must be 0.
if n != 0 {
return nil, fmt.Errorf("malformed recipient stanza: unexpected index")
return nil, nil, fmt.Errorf("malformed recipient stanza: unexpected index")
}
stanzas = append(stanzas, &age.Stanza{
@@ -114,32 +122,41 @@ ReadLoop:
})
if err := writeStanza(conn, "ok"); err != nil {
return nil, err
return nil, nil, err
}
case "labels":
if labels != nil {
return nil, nil, fmt.Errorf("repeated labels stanza")
}
labels = s.Args
if err := writeStanza(conn, "ok"); err != nil {
return nil, nil, err
}
case "error":
if err := writeStanza(conn, "ok"); err != nil {
return nil, err
return nil, nil, err
}
return nil, fmt.Errorf("%s", s.Body)
return nil, nil, fmt.Errorf("%s", s.Body)
case "done":
break ReadLoop
default:
if ok, err := r.ui.handle(r.name, conn, s); err != nil {
return nil, err
return nil, nil, err
} else if !ok {
if err := writeStanza(conn, "unsupported"); err != nil {
return nil, err
return nil, nil, err
}
}
}
}
if len(stanzas) == 0 {
return nil, fmt.Errorf("received zero recipient stanzas")
return nil, nil, fmt.Errorf("received zero recipient stanzas")
}
return stanzas, nil
return stanzas, labels, nil
}
type Identity struct {
@@ -151,25 +168,17 @@ type Identity struct {
var _ age.Identity = &Identity{}
func NewIdentity(s string, ui *ClientUI) (*Identity, error) {
hrp, _, err := bech32.Decode(s)
name, _, err := ParseIdentity(s)
if err != nil {
return nil, fmt.Errorf("invalid identity encoding: %v", err)
return nil, err
}
if !strings.HasPrefix(hrp, "AGE-PLUGIN-") || !strings.HasSuffix(hrp, "-") {
return nil, fmt.Errorf("not a plugin identity: %v", err)
}
name := strings.TrimSuffix(strings.TrimPrefix(hrp, "AGE-PLUGIN-"), "-")
name = strings.ToLower(name)
return &Identity{
name: name, encoding: s, ui: ui,
}, nil
}
func NewIdentityWithoutData(name string, ui *ClientUI) (*Identity, error) {
s, err := bech32.Encode("AGE-PLUGIN-"+strings.ToUpper(name)+"-", nil)
if err != nil {
return nil, err
}
s := EncodeIdentity(name, nil)
return &Identity{
name: name, encoding: s, ui: ui,
}, nil
@@ -211,6 +220,9 @@ func (i *Identity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) {
if err := writeStanza(conn, "add-identity", i.encoding); err != nil {
return nil, err
}
if err := writeStanza(conn, fmt.Sprintf("grease-%x", rand.Int())); err != nil {
return nil, err
}
for _, rs := range stanzas {
s := &format.Stanza{
Type: "recipient-stanza",
@@ -374,8 +386,14 @@ type clientConnection struct {
close func()
}
var testOnlyPluginPath string
func openClientConnection(name, protocol string) (*clientConnection, error) {
cmd := exec.Command("age-plugin-"+name, "--age-plugin="+protocol)
path := "age-plugin-" + name
if testOnlyPluginPath != "" {
path = filepath.Join(testOnlyPluginPath, path)
}
cmd := exec.Command(path, "--age-plugin="+protocol)
stdout, err := cmd.StdoutPipe()
if err != nil {

133
plugin/client_test.go Normal file
View File

@@ -0,0 +1,133 @@
// Copyright 2023 The age Authors
//
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file or at
// https://developers.google.com/open-source/licenses/bsd
package plugin
import (
"bufio"
"io"
"os"
"path/filepath"
"runtime"
"testing"
"filippo.io/age"
"filippo.io/age/internal/bech32"
)
func TestMain(m *testing.M) {
switch filepath.Base(os.Args[0]) {
// TODO: deduplicate from cmd/age TestMain.
case "age-plugin-test":
switch os.Args[1] {
case "--age-plugin=recipient-v1":
scanner := bufio.NewScanner(os.Stdin)
scanner.Scan() // add-recipient
scanner.Scan() // body
scanner.Scan() // grease
scanner.Scan() // body
scanner.Scan() // wrap-file-key
scanner.Scan() // body
fileKey := scanner.Text()
scanner.Scan() // extension-labels
scanner.Scan() // body
scanner.Scan() // done
scanner.Scan() // body
os.Stdout.WriteString("-> recipient-stanza 0 test\n")
os.Stdout.WriteString(fileKey + "\n")
scanner.Scan() // ok
scanner.Scan() // body
os.Stdout.WriteString("-> done\n\n")
os.Exit(0)
default:
panic(os.Args[1])
}
case "age-plugin-testpqc":
switch os.Args[1] {
case "--age-plugin=recipient-v1":
scanner := bufio.NewScanner(os.Stdin)
scanner.Scan() // add-recipient
scanner.Scan() // body
scanner.Scan() // grease
scanner.Scan() // body
scanner.Scan() // wrap-file-key
scanner.Scan() // body
fileKey := scanner.Text()
scanner.Scan() // extension-labels
scanner.Scan() // body
scanner.Scan() // done
scanner.Scan() // body
os.Stdout.WriteString("-> recipient-stanza 0 test\n")
os.Stdout.WriteString(fileKey + "\n")
scanner.Scan() // ok
scanner.Scan() // body
os.Stdout.WriteString("-> labels postquantum\n\n")
scanner.Scan() // ok
scanner.Scan() // body
os.Stdout.WriteString("-> done\n\n")
os.Exit(0)
default:
panic(os.Args[1])
}
default:
os.Exit(m.Run())
}
}
func TestLabels(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows support is TODO")
}
temp := t.TempDir()
testOnlyPluginPath = temp
t.Cleanup(func() { testOnlyPluginPath = "" })
ex, err := os.Executable()
if err != nil {
t.Fatal(err)
}
if err := os.Link(ex, filepath.Join(temp, "age-plugin-test")); err != nil {
t.Fatal(err)
}
if err := os.Chmod(filepath.Join(temp, "age-plugin-test"), 0755); err != nil {
t.Fatal(err)
}
if err := os.Link(ex, filepath.Join(temp, "age-plugin-testpqc")); err != nil {
t.Fatal(err)
}
if err := os.Chmod(filepath.Join(temp, "age-plugin-testpqc"), 0755); err != nil {
t.Fatal(err)
}
name, err := bech32.Encode("age1test", nil)
if err != nil {
t.Fatal(err)
}
testPlugin, err := NewRecipient(name, &ClientUI{})
if err != nil {
t.Fatal(err)
}
namePQC, err := bech32.Encode("age1testpqc", nil)
if err != nil {
t.Fatal(err)
}
testPluginPQC, err := NewRecipient(namePQC, &ClientUI{})
if err != nil {
t.Fatal(err)
}
if _, err := age.Encrypt(io.Discard, testPluginPQC); err != nil {
t.Errorf("expected one pqc to work, got %v", err)
}
if _, err := age.Encrypt(io.Discard, testPluginPQC, testPluginPQC); err != nil {
t.Errorf("expected two pqc to work, got %v", err)
}
if _, err := age.Encrypt(io.Discard, testPluginPQC, testPlugin); err == nil {
t.Errorf("expected one pqc and one normal to fail")
}
if _, err := age.Encrypt(io.Discard, testPlugin, testPluginPQC); err == nil {
t.Errorf("expected one pqc and one normal to fail")
}
}

55
plugin/encode.go Normal file
View File

@@ -0,0 +1,55 @@
// Copyright 2023 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package plugin
import (
"fmt"
"strings"
"filippo.io/age/internal/bech32"
)
// EncodeIdentity encodes a plugin identity string for a plugin with the given
// name. If the name is invalid, it returns an empty string.
func EncodeIdentity(name string, data []byte) string {
s, _ := bech32.Encode("AGE-PLUGIN-"+strings.ToUpper(name)+"-", data)
return s
}
// ParseIdentity decodes a plugin identity string. It returns the plugin name
// in lowercase and the encoded data.
func ParseIdentity(s string) (name string, data []byte, err error) {
hrp, data, err := bech32.Decode(s)
if err != nil {
return "", nil, fmt.Errorf("invalid identity encoding: %v", err)
}
if !strings.HasPrefix(hrp, "AGE-PLUGIN-") || !strings.HasSuffix(hrp, "-") {
return "", nil, fmt.Errorf("not a plugin identity: %v", err)
}
name = strings.TrimSuffix(strings.TrimPrefix(hrp, "AGE-PLUGIN-"), "-")
name = strings.ToLower(name)
return name, data, nil
}
// EncodeRecipient encodes a plugin recipient string for a plugin with the given
// name. If the name is invalid, it returns an empty string.
func EncodeRecipient(name string, data []byte) string {
s, _ := bech32.Encode("age1"+strings.ToLower(name), data)
return s
}
// ParseRecipient decodes a plugin recipient string. It returns the plugin name
// in lowercase and the encoded data.
func ParseRecipient(s string) (name string, data []byte, err error) {
hrp, data, err := bech32.Decode(s)
if err != nil {
return "", nil, fmt.Errorf("invalid recipient encoding: %v", err)
}
if !strings.HasPrefix(hrp, "age1") {
return "", nil, fmt.Errorf("not a plugin recipient: %v", err)
}
name = strings.TrimPrefix(hrp, "age1")
return name, data, nil
}

24
plugin/encode_go1.20.go Normal file
View File

@@ -0,0 +1,24 @@
// Copyright 2023 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build go1.20
package plugin
import (
"crypto/ecdh"
"fmt"
"filippo.io/age/internal/bech32"
)
// EncodeX25519Recipient encodes a native X25519 recipient from a
// [crypto/ecdh.X25519] public key. It's meant for plugins that implement
// identities that are compatible with native recipients.
func EncodeX25519Recipient(pk *ecdh.PublicKey) (string, error) {
if pk.Curve() != ecdh.X25519() {
return "", fmt.Errorf("wrong ecdh Curve")
}
return bech32.Encode("age", pk.Bytes())
}

View File

@@ -6,6 +6,7 @@ package age
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"regexp"
@@ -87,6 +88,29 @@ func (r *ScryptRecipient) Wrap(fileKey []byte) ([]*Stanza, error) {
return []*Stanza{l}, nil
}
// WrapWithLabels implements [age.RecipientWithLabels], returning a random
// label. This ensures a ScryptRecipient can't be mixed with other recipients
// (including other ScryptRecipients).
//
// Users reasonably expect files encrypted to a passphrase to be [authenticated]
// by that passphrase, i.e. for it to be impossible to produce a file that
// decrypts successfully with a passphrase without knowing it. If a file is
// encrypted to other recipients, those parties can produce different files that
// would break that expectation.
//
// [authenticated]: https://words.filippo.io/dispatches/age-authentication/
func (r *ScryptRecipient) WrapWithLabels(fileKey []byte) (stanzas []*Stanza, labels []string, err error) {
stanzas, err = r.Wrap(fileKey)
random := make([]byte, 16)
if _, err := rand.Read(random); err != nil {
return nil, nil, err
}
labels = []string{hex.EncodeToString(random)}
return
}
// ScryptIdentity is a password-based identity.
type ScryptIdentity struct {
password []byte

View File

@@ -11,70 +11,57 @@ import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"io/fs"
"strings"
"testing"
"filippo.io/age"
"filippo.io/age/armor"
"filippo.io/age/internal/format"
"filippo.io/age/internal/stream"
"golang.org/x/crypto/chacha20poly1305"
"golang.org/x/crypto/hkdf"
agetest "c2sp.org/CCTV/age"
)
func TestVectors(t *testing.T) {
if _, err := exec.LookPath("go"); err != nil {
t.Skipf("skipping test because 'go' command is unavailable: %v", err)
}
// Download the testkit files from CCTV using `go mod download -json` so the
// cached source of the testdata can be reused.
path := "c2sp.org/CCTV/age@v0.0.0-20221027185432-cfaa74dc42af"
cmd := exec.Command("go", "mod", "download", "-json", path)
output, err := cmd.Output()
if err != nil {
t.Fatalf("failed to run `go mod download -json %s`, output: %s", path, output)
}
var dm struct {
Dir string // absolute path to cached source root directory
}
if err := json.Unmarshal(output, &dm); err != nil {
t.Fatal(err)
}
testkitDir := filepath.Join(dm.Dir, "testdata")
tests, err := filepath.Glob(testkitDir + "/*")
func forEachVector(t *testing.T, f func(t *testing.T, v *vector)) {
tests, err := fs.ReadDir(agetest.Vectors, ".")
if err != nil {
t.Fatal(err)
}
for _, test := range tests {
contents, err := os.ReadFile(test)
name := test.Name()
contents, err := fs.ReadFile(agetest.Vectors, name)
if err != nil {
t.Fatal(err)
}
name := filepath.Base(test)
t.Run(name, func(t *testing.T) {
testVector(t, contents)
t.Parallel()
f(t, parseVector(t, contents))
})
}
}
func testVector(t *testing.T, test []byte) {
var (
expect string
payloadHash *[32]byte
identities []age.Identity
armored bool
)
type vector struct {
expect string
payloadHash *[32]byte
fileKey *[16]byte
identities []age.Identity
armored bool
file []byte
}
func parseVector(t *testing.T, test []byte) *vector {
v := &vector{file: test}
for {
line, rest, ok := bytes.Cut(test, []byte("\n"))
line, rest, ok := bytes.Cut(v.file, []byte("\n"))
if !ok {
t.Fatal("invalid test file: no payload")
}
test = rest
v.file = rest
if len(line) == 0 {
break
}
@@ -91,87 +78,197 @@ func testVector(t *testing.T, test []byte) {
default:
t.Fatal("invalid test file: unknown expect value:", value)
}
expect = value
v.expect = value
case "payload":
h, err := hex.DecodeString(value)
if err != nil {
t.Fatal(err)
}
payloadHash = (*[32]byte)(h)
v.payloadHash = (*[32]byte)(h)
case "file key":
h, err := hex.DecodeString(value)
if err != nil {
t.Fatal(err)
}
v.fileKey = (*[16]byte)(h)
case "identity":
i, err := age.ParseX25519Identity(value)
if err != nil {
t.Fatal(err)
}
identities = append(identities, i)
v.identities = append(v.identities, i)
case "passphrase":
i, err := age.NewScryptIdentity(value)
if err != nil {
t.Fatal(err)
}
identities = append(identities, i)
v.identities = append(v.identities, i)
case "armored":
armored = true
case "file key":
// Ignored.
v.armored = true
case "comment":
t.Log(value)
default:
t.Fatal("invalid test file: unknown header key:", key)
}
}
return v
}
var in io.Reader = bytes.NewReader(test)
if armored {
func TestVectors(t *testing.T) {
forEachVector(t, testVector)
}
func testVector(t *testing.T, v *vector) {
var in io.Reader = bytes.NewReader(v.file)
if v.armored {
in = armor.NewReader(in)
}
r, err := age.Decrypt(in, identities...)
r, err := age.Decrypt(in, v.identities...)
if err != nil && strings.HasSuffix(err.Error(), "bad header MAC") {
if expect == "HMAC failure" {
if v.expect == "HMAC failure" {
t.Log(err)
return
}
t.Fatalf("expected %s, got HMAC error", expect)
t.Fatalf("expected %s, got HMAC error", v.expect)
} else if e := new(armor.Error); errors.As(err, &e) {
if expect == "armor failure" {
if v.expect == "armor failure" {
t.Log(err)
return
}
t.Fatalf("expected %s, got: %v", expect, err)
t.Fatalf("expected %s, got: %v", v.expect, err)
} else if _, ok := err.(*age.NoIdentityMatchError); ok {
if expect == "no match" {
if v.expect == "no match" {
t.Log(err)
return
}
t.Fatalf("expected %s, got: %v", expect, err)
t.Fatalf("expected %s, got: %v", v.expect, err)
} else if err != nil {
if expect == "header failure" {
if v.expect == "header failure" {
t.Log(err)
return
}
t.Fatalf("expected %s, got: %v", expect, err)
} else if expect != "success" && expect != "payload failure" &&
expect != "armor failure" {
t.Fatalf("expected %s, got success", expect)
t.Fatalf("expected %s, got: %v", v.expect, err)
} else if v.expect != "success" && v.expect != "payload failure" &&
v.expect != "armor failure" {
t.Fatalf("expected %s, got success", v.expect)
}
out, err := io.ReadAll(r)
if err != nil && expect == "success" {
t.Fatalf("expected %s, got: %v", expect, err)
if err != nil && v.expect == "success" {
t.Fatalf("expected %s, got: %v", v.expect, err)
} else if err != nil {
t.Log(err)
if expect == "armor failure" {
if v.expect == "armor failure" {
if e := new(armor.Error); !errors.As(err, &e) {
t.Errorf("expected armor.Error, got %T", err)
}
}
if payloadHash != nil && sha256.Sum256(out) != *payloadHash {
if v.payloadHash != nil && sha256.Sum256(out) != *v.payloadHash {
t.Error("partial payload hash mismatch")
}
return
} else if expect != "success" {
t.Fatalf("expected %s, got success", expect)
} else if v.expect != "success" {
t.Fatalf("expected %s, got success", v.expect)
}
if sha256.Sum256(out) != *payloadHash {
if sha256.Sum256(out) != *v.payloadHash {
t.Error("payload hash mismatch")
}
}
// TestVectorsRoundTrip checks that any (valid) armor, header, and/or STREAM
// payload in the test vectors re-encodes identically.
func TestVectorsRoundTrip(t *testing.T) {
forEachVector(t, testVectorRoundTrip)
}
func testVectorRoundTrip(t *testing.T, v *vector) {
if v.armored {
if v.expect == "armor failure" {
t.SkipNow()
}
t.Run("armor", func(t *testing.T) {
payload, err := io.ReadAll(armor.NewReader(bytes.NewReader(v.file)))
if err != nil {
t.Fatal(err)
}
buf := &bytes.Buffer{}
w := armor.NewWriter(buf)
if _, err := w.Write(payload); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
// Armor format is not perfectly strict: CRLF ↔ LF and trailing and
// leading spaces are allowed and won't round-trip.
expect := bytes.Replace(v.file, []byte("\r\n"), []byte("\n"), -1)
expect = bytes.TrimSpace(expect)
expect = append(expect, '\n')
if !bytes.Equal(buf.Bytes(), expect) {
t.Error("got a different armor encoding")
}
})
// Armor tests are not interesting beyond their armor encoding.
return
}
if v.expect == "header failure" {
t.SkipNow()
}
hdr, p, err := format.Parse(bytes.NewReader(v.file))
if err != nil {
t.Fatal(err)
}
payload, err := io.ReadAll(p)
if err != nil {
t.Fatal(err)
}
t.Run("header", func(t *testing.T) {
buf := &bytes.Buffer{}
if err := hdr.Marshal(buf); err != nil {
t.Fatal(err)
}
buf.Write(payload)
if !bytes.Equal(buf.Bytes(), v.file) {
t.Error("got a different header+payload encoding")
}
})
if v.expect == "success" {
t.Run("STREAM", func(t *testing.T) {
nonce, payload := payload[:16], payload[16:]
key := streamKey(v.fileKey[:], nonce)
r, err := stream.NewReader(key, bytes.NewReader(payload))
if err != nil {
t.Fatal(err)
}
plaintext, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
buf := &bytes.Buffer{}
w, err := stream.NewWriter(key, buf)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(plaintext); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
if !bytes.Equal(buf.Bytes(), payload) {
t.Error("got a different STREAM ciphertext")
}
})
}
}
func streamKey(fileKey, nonce []byte) []byte {
h := hkdf.New(sha256.New, fileKey, nonce, []byte("payload"))
streamKey := make([]byte, chacha20poly1305.KeySize)
if _, err := io.ReadFull(h, streamKey); err != nil {
panic("age: internal error: failed to read from HKDF: " + err.Error())
}
return streamKey
}