Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,20 @@ jobs:
- {GOOS: linux, GOARCH: amd64}
- {GOOS: linux, GOARCH: arm, GOARM: 6}
- {GOOS: linux, GOARCH: arm64}
- {GOOS: darwin, GOARCH: amd64}
- {GOOS: darwin, GOARCH: arm64}
- {GOOS: windows, GOARCH: amd64}
- {GOOS: freebsd, GOARCH: amd64}
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
persist-credentials: false
- name: Install Go
uses: actions/setup-go@v5
with:
go-version: 1.x
cache: false
- name: Build binary
run: |
cp LICENSE "$RUNNER_TEMP/LICENSE"
Expand Down
42 changes: 21 additions & 21 deletions .github/workflows/ronn.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,23 @@ jobs:
name: Ronn
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install ronn
run: sudo apt-get update && sudo apt-get install -y ronn
- name: Run ronn
run: bash -O globstar -c 'ronn **/*.ronn'
- name: Undo email mangling
# rdiscount randomizes the output for no good reason, which causes
# changes to always get committed. Sigh.
# https://github.com/davidfstr/rdiscount/blob/6b1471ec3/ext/generate.c#L781-L795
run: |-
for f in doc/*.html; do
awk '/Filippo Valsorda/ { $0 = "<p>Filippo Valsorda <a href=\"mailto:age@filippo.io\" data-bare-link=\"true\">age@filippo.io</a></p>" } { print }' "$f" > "$f.tmp"
mv "$f.tmp" "$f"
done
- name: Upload generated files
uses: actions/upload-artifact@v4
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: geomys/sandboxed-step@v1.2.1
with:
persist-workspace-changes: true
run: |
sudo apt-get update && sudo apt-get install -y ronn
bash -O globstar -c 'ronn **/*.ronn'
# rdiscount randomizes the output for no good reason, which causes
# changes to always get committed. Sigh.
# https://github.com/davidfstr/rdiscount/blob/6b1471ec3/ext/generate.c#L781-L795
for f in doc/*.html; do
awk '/Filippo Valsorda/ { $0 = "<p>Filippo Valsorda <a href=\"mailto:age@filippo.io\" data-bare-link=\"true\">age@filippo.io</a></p>" } { print }' "$f" > "$f.tmp"
mv "$f.tmp" "$f"
done
- uses: actions/upload-artifact@v4
with:
name: man-pages
path: |
Expand All @@ -42,10 +42,10 @@ jobs:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download generated files
uses: actions/download-artifact@v4
- uses: actions/checkout@v5
with:
persist-credentials: true
- uses: actions/download-artifact@v4
with:
name: man-pages
path: doc/
Expand Down
102 changes: 63 additions & 39 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,55 +1,79 @@
name: Go tests
on: [push, pull_request]
on:
push:
pull_request:
schedule: # daily at 09:42 UTC
- cron: '42 9 * * *'
workflow_dispatch:
permissions:
contents: read
jobs:
test:
name: Test
strategy:
fail-fast: false
matrix:
go: [1.19.x, 1.x]
os: [ubuntu-latest, macos-latest, windows-latest]
go:
- { go-version: stable }
- { go-version: oldstable }
- { go-version-file: go.mod }
os:
- ubuntu-latest
- macos-latest
- windows-latest
runs-on: ${{ matrix.os }}
steps:
- name: Install Go ${{ matrix.go }}
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run tests
run: go test -race ./...
gotip:
name: Test (Go tip)
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go.go-version }}
go-version-file: ${{ matrix.go.go-version-file }}
- run: |
go test -race ./...
test-latest:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
go:
- { go-version: stable }
- { go-version: oldstable }
- { go-version-file: go.mod }
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go.go-version }}
go-version-file: ${{ matrix.go.go-version-file }}
- uses: geomys/sandboxed-step@v1.2.1
with:
run: |
go get -u -t ./...
go test -race ./...
staticcheck:
runs-on: ubuntu-latest
steps:
- name: Install bootstrap Go
uses: actions/setup-go@v5
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-go@v6
with:
go-version: stable
- name: Install Go tip (UNIX)
if: runner.os != 'Windows'
run: |
git clone --filter=tree:0 https://go.googlesource.com/go $HOME/gotip
cd $HOME/gotip/src && ./make.bash
echo "$HOME/gotip/bin" >> $GITHUB_PATH
- name: Install Go tip (Windows)
if: runner.os == 'Windows'
run: |
git clone --filter=tree:0 https://go.googlesource.com/go $HOME/gotip
cd $HOME/gotip/src && ./make.bat
echo "$HOME/gotip/bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- run: go version
- name: Run tests
run: go test -race ./...
- uses: geomys/sandboxed-step@v1.2.1
with:
run: go run honnef.co/go/tools/cmd/staticcheck@latest ./...
govulncheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-go@v6
with:
go-version: stable
- uses: geomys/sandboxed-step@v1.2.1
with:
run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

age is a simple, modern and secure file encryption tool, format, and Go library.

It features small explicit keys, no config options, and UNIX-style composability.
It features small explicit keys, post-quantum support, no config options, and UNIX-style composability.

```
$ age-keygen -o key.txt
Expand All @@ -25,13 +25,13 @@ $ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz

🦀 An alternative interoperable Rust implementation is available at [github.com/str4d/rage](https://github.com/str4d/rage).

🌍 [Typage](https://github.com/FiloSottile/typage) is a TypeScript implementation. It works in the browser, in Node.js, and in Bun.
🌍 [Typage](https://github.com/FiloSottile/typage) is a TypeScript implementation. It works in the browser, Node.js, Deno, and Bun.

🔑 Hardware PIV tokens such as YubiKeys are supported through the [age-plugin-yubikey](https://github.com/str4d/age-plugin-yubikey) plugin.

✨ For more plugins, implementations, tools, and integrations, check out the [awesome age](https://github.com/FiloSottile/awesome-age) list.

💬 The author pronounces it `[aɡe̞]` [with a hard *g*](https://translate.google.com/?sl=it&text=aghe), like GIF, and is always spelled lowercase.
💬 The author pronounces it `[aɡe̞]` [with a hard *g*](https://translate.google.com/?sl=it&text=aghe), like GIF, and it's always spelled lowercase.

## Installation

Expand Down Expand Up @@ -229,6 +229,28 @@ $ age -R recipients.txt example.jpg > example.jpg.age

If the argument to `-R` (or `-i`) is `-`, the file is read from standard input.

### Post-quantum keys

To generate hybrid post-quantum keys, which are secure against future quantum
computer attacks, use the `-pq` flag with `age-keygen`. This may become the
default in the future.

Post-quantum identities start with `AGE-SECRET-KEY-PQ-1...` and recipients with
`age1pq1...`. The recipients are unfortunately ~2000 characters long.

```
$ age-keygen -pq -o key.txt
$ age-keygen -y key.txt > recipient.txt
$ age -R recipient.txt example.jpg > example.jpg.age
$ age -d -i key.txt example.jpg.age > example.jpg
```

Support for post-quantum keys is built into age v1.3.0 and later. Alternatively,
the `age-plugin-pq` binary can be installed and placed in `$PATH` to add support
to any version and implementation of age that supports plugins. Recipients will
work out of the box, while identities will have to be converted to plugin
identities with `age-plugin-pq -identity`.

### Passphrases

Files can be encrypted with a passphrase by using `-p/--passphrase`. By default age will automatically generate a secure passphrase. Passphrase protected files are automatically detected at decrypt time.
Expand Down
51 changes: 40 additions & 11 deletions age.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
// specification.
//
// For most use cases, use the [Encrypt] and [Decrypt] functions with
// [X25519Recipient] and [X25519Identity]. If passphrase encryption is required, use
// [ScryptRecipient] and [ScryptIdentity]. For compatibility with existing SSH keys
// use the filippo.io/age/agessh package.
// [HybridRecipient] and [HybridIdentity]. If passphrase encryption is
// required, use [ScryptRecipient] and [ScryptIdentity]. For compatibility with
// existing SSH keys use the filippo.io/age/agessh package.
//
// age encrypted files are binary and not malleable. For encoding them as text,
// use the filippo.io/age/armor package.
Expand All @@ -26,13 +26,13 @@
// There is no default path for age keys. Instead, they should be stored at
// application-specific paths. The CLI supports files where private keys are
// listed one per line, ignoring empty lines and lines starting with "#". These
// files can be parsed with ParseIdentities.
// files can be parsed with [ParseIdentities].
//
// When integrating age into a new system, it's recommended that you only
// support X25519 keys, and not SSH keys. The latter are supported for manual
// encryption operations. If you need to tie into existing key management
// infrastructure, you might want to consider implementing your own Recipient
// and Identity.
// support native (X25519 and hybrid) keys, and not SSH keys. The latter are
// supported for manual encryption operations. If you need to tie into existing
// key management infrastructure, you might want to consider implementing your
// own [Recipient] and [Identity].
//
// # Backwards compatibility
//
Expand All @@ -52,14 +52,15 @@ import (
"errors"
"fmt"
"io"
"slices"
"sort"

"filippo.io/age/internal/format"
"filippo.io/age/internal/stream"
)

// An Identity is passed to [Decrypt] to unwrap an opaque file key from a
// recipient stanza. It can be for example a secret key like [X25519Identity], a
// recipient stanza. It can be for example a secret key like [HybridIdentity], a
// plugin, or a custom implementation.
type Identity interface {
// Unwrap must return an error wrapping [ErrIncorrectIdentity] if none of
Expand All @@ -76,7 +77,7 @@ type Identity interface {
var ErrIncorrectIdentity = errors.New("incorrect identity for recipient block")

// A Recipient is passed to [Encrypt] to wrap an opaque file key to one or more
// recipient stanza(s). It can be for example a public key like [X25519Recipient],
// recipient stanza(s). It can be for example a public key like [HybridRecipient],
// a plugin, or a custom implementation.
type Recipient interface {
// Most age API users won't need to interact with this method directly, and
Expand Down Expand Up @@ -142,7 +143,7 @@ func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) {
if i == 0 {
labels = l
} else if !slicesEqual(labels, l) {
return nil, fmt.Errorf("incompatible recipients")
return nil, incompatibleLabelsError(labels, l)
}
for _, s := range stanzas {
hdr.Recipients = append(hdr.Recipients, (*format.Stanza)(s))
Expand Down Expand Up @@ -188,6 +189,15 @@ func slicesEqual(s1, s2 []string) bool {
return true
}

func incompatibleLabelsError(l1, l2 []string) error {
hasPQ1 := slices.Contains(l1, "postquantum")
hasPQ2 := slices.Contains(l2, "postquantum")
if hasPQ1 != hasPQ2 {
return fmt.Errorf("incompatible recipients: can't mix post-quantum and classic recipients, or the file would be vulnerable to quantum computers")
}
return fmt.Errorf("incompatible recipients: %q and %q can't be mixed", l1, l2)
}

// NoIdentityMatchError is returned by [Decrypt] when none of the supplied
// identities match the encrypted file.
type NoIdentityMatchError struct {
Expand All @@ -204,6 +214,7 @@ func (*NoIdentityMatchError) Error() string {
//
// It returns a Reader reading the decrypted plaintext of the age file read
// from src. All identities will be tried until one successfully decrypts the file.
// Native, non-interactive identities are tried before any other identities.
//
// If no identity matches the encrypted file, the returned error will be of type
// [NoIdentityMatchError].
Expand All @@ -230,6 +241,24 @@ func decryptHdr(hdr *format.Header, identities ...Identity) ([]byte, error) {
if len(identities) == 0 {
return nil, errors.New("no identities specified")
}
slices.SortStableFunc(identities, func(a, b Identity) int {
var aIsNative, bIsNative bool
switch a.(type) {
case *X25519Identity, *HybridIdentity, *ScryptIdentity:
aIsNative = true
}
switch b.(type) {
case *X25519Identity, *HybridIdentity, *ScryptIdentity:
bIsNative = true
}
if aIsNative && !bIsNative {
return -1
}
if !aIsNative && bIsNative {
return 1
}
return 0
})

stanzas := make([]*Stanza, 0, len(hdr.Recipients))
for _, s := range hdr.Recipients {
Expand Down
Loading