Author SHA1 Message Date
Filippo Valsorda 5d96bfa9a9 age: make Identity and Recipient work on multiple stanzas
This is a breaking change, but like the other changes to these
interfaces it should not matter to consumers of the API that don't
implement custom Recipients or Identities, which is all of them so far,
as far as I can tell.

It became clear working on plugins that we might want Recipient to
return multiple recipient stanzas, for example if the plugin recipient
is an alias or a group. The Identity side is less important, but it
might help avoid round-trips and it makes sense to keep things
symmetric.
2021-02-08 19:55:28 +01:00
Filippo Valsorda f04064a41b age: add NoIdentityMatchError
Closes #147
2021-02-08 19:55:28 +01:00
Filippo Valsorda 0fa220e4d7 age: remove IdentityMatcher
It was completely useless: the same checks in Match could be implemented
in Unwrap, returning an early ErrIncorrectIdentity.

Not sure why I added it. It felt clever at the time.
2021-02-08 19:55:28 +01:00
Filippo Valsorda 6546df3bac age: remove Type method from Recipient and Identity interfaces
The Type() method was a mistake, as proven by the fact that I can remove
it without losing any functionality. It gives special meaning to the
"0th argument" of recipient stanzas, when actually it should be left up
to Recipient implementations to make their own stanzas recognizable to
their Identity counterparts.

More importantly, there are totally reasonable Identity (and probably
Recipient) implementations that don't know their own stanza type in
advance. For example, a proxy plugin.

Concretely, it was only used to special-case "scrypt" recipients, and to
skip invoking Unwrap. The former can be done based on the returned
recipient stanza, and the latter is best avoided entirely: the Identity
should start by looking at the stanza and returning ErrIncorrectIdentity
if it's of the wrong type.

This is a breaking API change. However, we are still in beta, and none
of the public downstreams look like they would be affected, as they only
use Recipient and Identity implementations from this package, they only
use them with the interfaces defined in this package, and they don't
directly use the Type() method.
2021-02-08 19:55:28 +01:00
Filippo Valsorda 15df6e2cf7 internal/format: require the last line of stanzas to be short
We are going to reuse the stanza format for IPC in the plugin protocol,
but in that context we need stanzas to be self-closing. Currently they
almost are, but if the body is 0 modulo 48, there is no way to know if
the stanza is over after the last line.

Now, all stanzas have to end with a short line, even if empty.

No ciphertexts generated by age in the past are affected, but 3% of the
ciphertexts generated by rage will now stop working. They are still
supported by rage going forward. If it turns out to be a common issue,
we can add an exception.
2021-02-08 19:55:28 +01:00
James Brown 50b61862d6 HomebrewFormula: update to v1.0.0-beta6 (#180) 2021-02-08 19:55:28 +01:00
Filippo Valsorda c418992942 cmd/age: touch up help text 2021-01-15 14:26:33 +01:00
codesoapandFilippo Valsorda 902a3d4e6b cmd/age: decide to buffer output based on stdin source
Buffering only when the armorFlag is set disregards use cases where data
from a tty stdin is decrypted or where binary data goes to a tty stdout.

Buffering is only necessary if stdin is a tty and stdout is a tty.

Co-authored-by: Filippo Valsorda <hi@filippo.io>
2021-01-07 16:51:43 -05:00
codesoapandFilippo Valsorda 4a5a042583 cmd/age: decouple output buffer and output denial decision
Co-authored-by: Filippo Valsorda <hi@filippo.io>
2021-01-07 16:51:43 -05:00
Tom Payne 6fc795057f cmd/age-keygen: add --output option as an alias for -o (#177) 2021-01-07 19:20:55 +01:00
Filippo Valsorda f7011ee36a .github/workflows: include LICENSE in binary builds 2021-01-06 13:49:56 +01:00
Richard Ulmer a8573a5c8d cmd/age: improve help text (#175) 2021-01-04 20:05:33 +01:00
Matthias Schneider a02075a0cc internal/stream: fix readChunk documentation (#163)
r.c was renamed to r.src.
2021-01-03 15:26:48 +01:00
Filippo Valsorda 4147b86ac8 cmd/age: support "-R -" if stdin is not in use 2021-01-03 09:10:21 -05:00
Daan SprenkelsandFilippo Valsorda dc8716d8fc cmd/age: support "-i -" if stdin is not in use
Fixes #143
Closes #145

Co-authored-by: Filippo Valsorda <hi@filippo.io>
2021-01-03 09:10:21 -05:00
Filippo Valsorda 3f2deb5a3b cmd/age: be less clever in the lazyOpener implementation
I am a fan of closures and DoerFunc interface adapters, but the boring
thing is more readable. Sigh.
2021-01-03 09:10:21 -05:00
Filippo Valsorda 97b6569a66 cmd/age: lazily open output file at first write
This avoids leaving behind an empty file when an error occurs before we
write the header (for example, because the passphrase is invalid). Do a
best-effort check before taking user input for whether the file exists
so we don't waste user effort. An error might still happen after user
input if other kind of open errors happen (for example, a permission
issue, or disk full).

Fixes #159
Fixes #57
Closes #169
2021-01-03 09:10:21 -05:00
Filippo Valsorda 02ee8b969a internal/format: buffer newlineWriter writes
Most writes in the cmd/age Writer stack are chunk-sized, so
approximately 64KiB. However, the newlineWriter, which splits lines at
64 columns, was doing a Write on the underlying Writer for each line,
making chunks effectively 48 bytes (before base64). There is no
buffering underneath it, so it was resulting in a lot of write syscalls.

Add a reusable bytes.Buffer to buffer the output of each
(*newlineWriter).Write call, and Write it all at once on the
destination.

This makes --armor just 50% slower than plain, instead of 10x.

Fixes #167
2021-01-03 09:10:21 -05:00
Filippo Valsorda cb4d1de4b7 cmd/age: show usage if no arguments and flags are specified
Fixes #74
2021-01-03 09:10:21 -05:00
Filippo Valsorda e665eeafb0 cmd/age-keygen: make world-readable warning less obscure
Fixes #149
Fixes #75
2021-01-03 09:10:21 -05:00
Filippo Valsorda 4dee0155ee cmd/age,cmd/age-keygen: improve help text
Closes #168
2021-01-03 09:10:21 -05:00
Filippo Valsorda 0522803919 cmd/age,cmd/age-keygen: add -version flag
Fixes #157
Fixes #101
Closes #97
2021-01-03 09:10:21 -05:00
Filippo Valsorda f8507c1cac age,cmd/age: add ParseRecipients and -R for recipient files
Fixes #84
Fixes #66
Closes #165
Closes #158
Closes #115
Closes #64
Closes #43
Closes #20
2021-01-03 09:10:21 -05:00
Filippo Valsorda 7ab2008136 .github/workflows: extend testing matrix 2021-01-03 00:28:03 +01:00
Luca Corbo f5a47fcc9a age: fix typo in ParseX25519Identity docs (#134) 2021-01-03 00:13:41 +01:00
Filippo Valsorda 6f51e96429 .github/workflows: add build.yml
Fixes #164
Fixes #148
Fixes #133
Closes #25
2021-01-02 23:41:45 +01:00
Filippo Valsorda 53ccaf8b71 agessh: use filippo.io/edwards25519 for Ed25519 to Curve25519 conversion 2020-12-04 18:45:52 +01:00
Filippo Valsorda 6593c56e33 .github/workflows: switch from Travis CI to GitHub Actions
So long, and thanks for all the builds!
2020-11-21 01:50:09 +01:00
Filippo Valsorda 31500bfa2f age: clean up examples of hardcoded data 2020-09-20 12:57:16 +02:00
Filippo Valsorda 22e598d458 age: replace ParseX25519Identities with ParseIdentities
The latter returns a []Identity that can be used with Decrypt directly.
2020-09-20 12:54:15 +02:00
Filippo Valsorda 65f171a239 age: add ParseX25519Identities and key management docs 2020-09-20 12:17:15 +02:00
Filippo Valsorda 2194f6962c age: mitigate multi-key attacks on ChaCha20Poly1305
It's possible to craft ChaCha20Poly1305 ciphertexts that decrypt under
multiple keys. (I know, it's wild.)

The impact is different for different recipients, but in general only
applies to Chosen Ciphertext Attacks against online decryption oracles:

* With the scrypt recipient, it lets the attacker make a recipient
  stanza that decrypts with multiple passwords, speeding up a bruteforce
  in terms of oracle queries (but not scrypt work, which can be
  precomputed) to logN by binary search.

  Limiting the ciphertext size limits the keys to two, which makes this
  acceptable: it's a loss of only one bit of security in a scenario
  (online decryption oracles) that is not recommended.

* With the X25519 recipient, it lets the attacker search for accepted
  public keys without using multiple recipient stanzas in the message.
  That lets the attacker bypass the 20 recipients limit (which was not
  actually intended to defend against deanonymization attacks).

  This is not really in the threat model for age: we make no attempt to
  provide anonymity in an online CCA scenario.

  Anyway, limiting the keys to two by enforcing short ciphertexts
  mitigates the attack: it only lets the attacker test 40 keys per
  message instead of 20.

* With the ssh-ed25519 recipient, the attack should be irrelevant, since
  the recipient stanza includes a 32-bit hash of the public key, making
  it decidedly not anonymous.

  Also to avoid breaking the abstraction in the agessh package, we don't
  mitigate the attack for this recipient, but we document the lack of
  anonymity.

This was reported by Paul Grubbs in the context of the upcoming paper
"Partitioning Oracle Attacks", USENIX Security 2021 (to appear), by
Julia Len, Paul Grubbs, and Thomas Ristenpart.
2020-09-19 18:52:59 +02:00
Shimmy Xu 07c72f3b69 internal/bech32: fix vet error by making rune conversion explicit (#141)
Fixes #138
2020-09-07 16:07:10 +02:00
Filippo Valsorda 21a7203f6a README: add link to Go documentation 2020-07-20 19:34:08 -04:00
Filippo Valsorda 0c650f815d age: fix filippo.io/age/agessh name in package docs
Thanks to @Mohammed90 for pointing this out.
2020-06-27 23:47:17 -04:00
Filippo Valsorda 189041b668 age: move package from filippo.io/age/age to filippo.io/age 🤦‍♂️ 2020-06-27 22:06:32 -04:00
Filippo Valsorda e609359651 age,agessh,armor: unleash public API 💥🦑 2020-06-27 21:08:42 -04:00
Filippo Valsorda 33355dcc1c internal/age: unexport NewX25519Recipient and NewX25519Identity 2020-06-27 21:03:35 -04:00
Filippo Valsorda 9a08b7e66f internal/age,internal/armor: add examples 2020-06-27 21:03:09 -04:00
Filippo Valsorda 9b83d948f5 internal/age: surface format.Recipient as type Stanza 2020-06-27 19:44:26 -04:00
Filippo Valsorda c9a35c0727 internal/agessh: move EncryptedSSHIdentity out of cmd/age 2020-05-19 02:07:27 -04:00
Filippo Valsorda 7d608d1219 .github/workflows: add rage interop tests trigger (#125) 2020-05-18 20:44:50 -04:00
Filippo Valsorda 6782356e45 internal/age: add some docs and polish API 2020-05-18 02:53:37 -04:00
Filippo Valsorda 085466567a internal/format: fix a nasty bufio.Reader nesting bug 2020-05-18 02:28:31 -04:00
Filippo Valsorda 292c3aaeea internal/agessh: new package
Move the SSH recipient types out of the main package to declutter the
godoc. This also allows us to drop the x/crypto/ssh build dependency
entirely from the age package import tree.
2020-05-18 01:20:08 -04:00
Filippo Valsorda b32ea4c1f6 cmd/age: add a TODO about not dumping decrypted binary to the terminal 2020-05-18 00:21:41 -04:00
Filippo Valsorda c7c7f1870f internal/armor: new package 2020-05-18 00:12:36 -04:00
Filippo Valsorda a7c4274d23 internal/age: remove EncryptWithArmor and armor support in Decrypt
The caller can take care of the armor. For consistency move the
responsibility to close the armor to the caller, and make the stream
Writer not propagate Close.

This also will also allow us to spin the armor implementation out into
its won package that imports format, without getting an import loop from
format.Parse magically invoking armor decoding.

Less magic in the API, more magic in the CLI.
2020-05-18 00:11:21 -04:00
Filippo Valsorda 7088a73234 internal/age: unexport SSHFingerprint 2020-05-18 00:05:25 -04:00
Filippo Valsorda f0f8092d60 internal/format: update fuzzing target
Fixes #96
2020-03-25 02:23:37 -04:00
Filippo Valsorda f28f85d87b internal/format: require recipients and arguments not to be empty
Also updated the spec to clarify that arbitrary strings can't be empty.
2020-03-25 02:22:58 -04:00
Filippo Valsorda f54bb8daab internal/format: don't generate and reject empty lines in recipient bodies
Detected by https://github.com/str4d/rage/runs/532262359 and by go-fuzz.
2020-03-25 02:22:58 -04:00
Ross Kinsey 35e582514d go.mod: update x/crypto for aes256-cbc support (#111, golang/go#37939) 2020-03-24 01:57:18 -04:00
Filippo Valsorda 111d0fe1f4 go.mod: update x/crypto to bring in fix for golang/go#36609
Updates #78
2020-02-20 11:13:44 -05:00
Filippo Valsorda 9fdb125641 go.mod: switch to upstream golang.org/x/crypto
Fixes #99
2020-02-04 14:14:08 -05:00
Mike SampsonandFilippo Valsorda a798d4ef31 README: add Arch Linux AUR instructions (#76)
Co-authored-by: Filippo Valsorda <1225294+FiloSottile@users.noreply.github.com>
2020-01-10 16:27:00 -05:00
Li-Wen HsuandFilippo Valsorda 68da6c5f50 README: add FreeBSD installation instruction (#79)
Co-authored-by: Filippo Valsorda <1225294+FiloSottile@users.noreply.github.com>
2020-01-10 16:21:19 -05:00
Anand SharmaandFilippo Valsorda 2419b5da58 README: fix a typo in the SSH Keys section (#85)
Co-authored-by: Filippo Valsorda <1225294+FiloSottile@users.noreply.github.com>
2020-01-10 16:19:42 -05:00
Filippo Valsorda 4e84199130 .github: update spec-feedback.md 2020-01-08 14:24:02 -05:00
Filippo Valsorda f9f6d7ca50 .github: add specification feedback issue template 2020-01-08 14:22:08 -05:00
Filippo Valsorda e43cf8b4a2 cmd/age: improve incorrect passphrase error message
Fixes #41
2019-12-31 14:20:39 +01:00
Filippo Valsorda 7935150f35 internal/age: improve empty passphrase error message
Fixes #62
2019-12-31 14:20:39 +01:00
Ed Maste 1110c3b6e1 .cirrus.yml: enable Cirrus CI for FreeBSD CI (#58) 2019-12-31 00:23:34 +01:00
Joe Shaw 1223fbe6da HomebrewFormula: update to beta2 (#60) 2019-12-31 00:14:01 +01:00
Filippo Valsorda b194267aaa README: add more usage examples 2019-12-30 02:24:38 +01:00
Filippo Valsorda 3807646596 internal/age: don't log binary data in tests
Fixes #32
2019-12-30 01:43:42 +01:00
Filippo Valsorda dd887fdc87 cmd/age: confirm encryption passphrase
Fixes #39
2019-12-30 00:41:53 +01:00
Filippo Valsorda 0da94651f3 HomebrewFormula: drop GOPATH and set -trimpath 2019-12-30 00:26:55 +01:00
Filippo Valsorda 80c6b4edd9 cmd/age: offer to autogenerate a secure passphrase
As suggested by @tqbf: https://twitter.com/tqbf/status/1210715916381511680
2019-12-30 00:23:22 +01:00
Christian Stefanescu b3fc5d19ea README: mention Linux for Homebrew installation (#45)
Since Homebrew now works on linux (https://docs.brew.sh/Homebrew-on-Linux), you can use the same installation steps for Linux as you would for macOS. I tested this on Ubuntu 19.10.
2019-12-29 14:47:01 +01:00
Filippo Valsorda c02443e4d0 README: fix build from source instructions 2019-12-28 03:43:26 +01:00
Chris PalmerandFilippo Valsorda a5773f28a3 README: update the installation and build instructions (#31)
Co-authored-by: Filippo Valsorda <1225294+FiloSottile@users.noreply.github.com>
2019-12-28 02:44:44 +01:00
Filippo Valsorda 63301d18b4 internal/age: add a comment about the fixed AEAD nonce 2019-12-28 02:43:51 +01:00
Chris Palmer d6fe1cf5c8 cmd/age: provide a --output option (#28)
For consistency across all options.
2019-12-28 02:33:26 +01:00
Barend 18edf29a75 README: fix typo (#26) 2019-12-28 00:28:49 +01:00
47 changed files with 2121 additions and 946 deletions

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
env:
CIRRUS_CLONE_DEPTH: 1
freebsd_12_task:
freebsd_instance:
image: freebsd-12-1-release-amd64
install_script: pkg install -y go
build_script: go build -v ./...
test_script: go test -race ./...
+15
View File
@@ -0,0 +1,15 @@
---
name: Spec feedback
about: Have a comment about the age spec as it's implemented by this and other tools?
title: 'spec: '
labels: 'spec'
assignees: ''
---
<!-- This is the issue tracker of a specific implementation of
the age format, which is specified at https://age-encryption.org/v1
Please consider using the mailing list to discuss the specification:
https://age-encryption.org/ml -->
+72
View File
@@ -0,0 +1,72 @@
on:
release:
types: [published]
push:
pull_request:
name: Build binaries
jobs:
binaries:
name: Build and upload
runs-on: ubuntu-latest
steps:
- name: Install Go
uses: actions/setup-go@v2
with:
go-version: 1.x
- name: Checkout repository
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Build binaries
run: |
VERSION=$(git describe --tags)
function build_age() {
DIR="$(mktemp -d)"
mkdir "$DIR/age"
cp LICENSE "$DIR/age"
echo -e "\n---\n" >> "$DIR/age/LICENSE"
curl "https://golang.org/LICENSE?m=text" >> "$DIR/age/LICENSE"
go build -o "$DIR/age" -ldflags "-X main.Version=$VERSION" ./cmd/...
if [ "$GOOS" == "windows" ]; then
( cd "$DIR"; zip age.zip -r age )
mv "$DIR/age.zip" "age-$VERSION-$GOOS-$GOARCH.zip"
else
tar -cvzf "age-$VERSION-$GOOS-$GOARCH.tar.gz" -C "$DIR" age
fi
}
export CGO_ENABLED=0
GOOS=linux GOARCH=amd64 build_age
GOOS=linux GOARCH=arm GOARM=6 build_age
GOOS=linux GOARCH=arm64 build_age
GOOS=darwin GOARCH=amd64 build_age
GOOS=windows GOARCH=amd64 build_age
- name: Upload workflow artifacts
uses: actions/upload-artifact@v2
with:
name: age-binaries
path: age-*
- name: Upload release artifacts
uses: actions/github-script@v3
if: ${{ github.event_name == 'release' }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require("fs").promises;
const { repo: { owner, repo }, sha } = context;
const release = await github.repos.getReleaseByTag({
owner, repo,
tag: process.env.GITHUB_REF.replace("refs/tags/", ""),
});
console.log("Release:", { release });
for (let file of await fs.readdir(".")) {
if (!file.startsWith("age-")) continue;
console.log("Uploading", file);
await github.repos.uploadReleaseAsset({
owner, repo,
release_id: release.data.id,
name: file,
data: await fs.readFile(file),
});
}
+13
View File
@@ -0,0 +1,13 @@
name: Interoperability tests
on: push
jobs:
trigger:
name: Trigger
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"'" }}'
+22
View File
@@ -0,0 +1,22 @@
on: [push, pull_request]
name: Go tests
jobs:
test:
name: Test
strategy:
fail-fast: false
matrix:
go: [1.14.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
with:
go-version: ${{ matrix.go }}
- name: Checkout repository
uses: actions/checkout@v2
# - name: Run analyses
# run: go run analysis.go ./...
- name: Run tests
run: go test -race ./...
-5
View File
@@ -1,5 +0,0 @@
os: linux
arch: arm64
dist: bionic
language: go
go: 1.x
+4 -5
View File
@@ -7,15 +7,14 @@
class Age < Formula
desc "Simple, modern, secure file encryption"
homepage "https://filippo.io/age"
url "https://github.com/FiloSottile/age/archive/v1.0.0-beta1.zip"
sha256 "6c7b1de0f312bc6e17e6b26ec27598672d1064e0287f202da5ab7efa9a1bb9d8"
url "https://github.com/FiloSottile/age/archive/v1.0.0-beta6.zip"
sha256 "6ffa23aee0f03c3e00707915e4300591847a2b0c5157ca7a696eb39bfeb7359c"
depends_on "go" => :build
def install
ENV["GOPATH"] = HOMEBREW_CACHE/"go_cache"
system "mkdir", bin
system "go", "build", "-o", bin, "filippo.io/age/cmd/..."
mkdir bin
system "go", "build", "-trimpath", "-o", bin, "-ldflags", "-X main.Version=v#{version}", "filippo.io/age/cmd/..."
prefix.install_metafiles
end
end
+105 -15
View File
@@ -1,57 +1,147 @@
# age
age is a simple, modern and secure file encryption tool.
[![pkg.go.dev](https://pkg.go.dev/badge/filippo.io/age)](https://pkg.go.dev/filippo.io/age)
I features small explicit keys, no config options, and UNIX-style composability.
age is a simple, modern and secure file encryption tool, format, and library.
It features small explicit keys, no config options, and UNIX-style composability.
```
$ age-keygen -o key.txt
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
$ tar cvz ~/data | age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p > data.tar.gz.age
$ age -d -i key.txt -o data.tar.gz data.tar.gz.age
$ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
```
The format specification is at [age-encryption.org/v1](https://age-encryption.org/v1). To discuss the spec or other age related topics, please email the mailing list at age-dev@googlegroups.com. Subscribe at [groups.google.com/d/forum/age-dev](https://groups.google.com/d/forum/age-dev) or by emailing age-dev+subscribe@googlegroups.com.
The format specification is at [age-encryption.org/v1](https://age-encryption.org/v1). To discuss the spec or other age related topics, please email [the mailing list](https://groups.google.com/d/forum/age-dev) at age-dev@googlegroups.com. age was designed by [@Benjojo12](https://twitter.com/Benjojo12) and [@FiloSottile](https://twitter.com/FiloSottile).
An alternative interoperable Rust implementation is available at [github.com/str4d/rage](https://github.com/str4d/rage).
## Usage
```
Usage:
age -r RECIPIENT [-a] [-o OUTPUT] [INPUT]
age --decrypt [-i KEY] [-o OUTPUT] [INPUT]
age (-r RECIPIENT | -R PATH)... [--armor] [-o OUTPUT] [INPUT]
age --passphrase [--armor] [-o OUTPUT] [INPUT]
age --decrypt [-i PATH]... [-o OUTPUT] [INPUT]
Options:
-o OUTPUT Write the result to the file at path OUTPUT.
-o, --output OUTPUT Write the result to the file at path OUTPUT.
-a, --armor Encrypt to a PEM encoded format.
-p, --passphrase Encrypt with a passphrase.
-r, --recipient RECIPIENT Encrypt to the specified RECIPIENT. Can be repeated.
-R, --recipients-file PATH Encrypt to recipients listed at PATH. Can be repeated.
-d, --decrypt Decrypt the input to the output.
-i, --identity KEY Use the private key file at path KEY. Can be repeated.
-i, --identity PATH Use the identity file at PATH. Can be repeated.
INPUT defaults to standard input, and OUTPUT defaults to standard output.
RECIPIENT can be an age public key, as generated by age-keygen, ("age1...")
RECIPIENT can be an age public key generated by age-keygen ("age1...")
or an SSH public key ("ssh-ed25519 AAAA...", "ssh-rsa AAAA...").
KEY is a path to a file with age secret keys, one per line
(ignoring "#" prefixed comments and empty lines), or to an SSH key file.
Multiple keys can be provided, and any unused ones will be ignored.
Recipient files contain one or more recipients, one per line. Empty lines
and lines starting with "#" are ignored as comments. "-" may be used to
read recipients from standard input.
Identity files contain one or more secret keys ("AGE-SECRET-KEY-1..."),
one per line, or an SSH key. Empty lines and lines starting with "#" are
ignored as comments. Multiple key files can be provided, and any unused ones
will be ignored. "-" may be used to read identities from standard input.
```
### Multiple recipients
Files can be encrypted to multiple recipients by repeating `-r/--recipient`. Every recipient will be able to decrypt the file.
```
$ age -o example.jpg.age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p \
-r age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg example.jpg
```
#### Recipient files
Multiple recipients can also be listed one per line in one or more files passed with the `-R/--recipients-file` flag.
```
$ cat recipients.txt
# Alice
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
# Bob
age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg
$ age -R recipients.txt example.jpg > example.jpg.age
```
If the argument to `-R` (or `-i`) is `-`, the file is read from standard input.
### 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.
```
$ 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:
```
### SSH keys
As a convenience feature, age also supports encrypting to `ssh-rsa` and `ssh-ed25519` SSH public keys, and decrypting with the respective private key file. (`ssh-agent` is not supported.)
```
$ age -R ~/.ssh/id_ed25519.pub example.jpg > example.jpg.age
$ age -d -i ~/.ssh/id_ed25519 example.jpg.age > example.jpg
```
Note that SSH key support employs more complex cryptography, and embeds a public key tag in the encrypted file, making it possible to track files that are encrypted to a specific public key.
#### Encrypting to a GitHub user
Combining SSH key support and `-R`, you can easily encrypt a file to the SSH keys listed on a GitHub profile.
```
$ curl https://github.com/benjojo.keys | age -R - example.jpg > example.jpg.age
```
Keep in mind that people might not protect SSH keys long-term, since they are revokable when used only for authentication, and that SSH keys held on YubiKeys can't be used to decrypt files.
## Installation
On macOS, use Homebrew.
On macOS or Linux, you can use Homebrew:
```
brew tap filippo.io/age https://filippo.io/age
brew install age
```
On Linux and Windows, use [the pre-built binaries](https://github.com/FiloSottile/age/releases) or build from source with Go 1.13+.
On Windows, Linux, and macOS, you can use [the pre-built binaries](https://github.com/FiloSottile/age/releases).
If your system has [Go 1.13+](https://golang.org/dl/), you can build from source:
```
git clone https://filippo.io/age && cd age
go build filippo.io/cmd/age/...
go build -o . filippo.io/age/cmd/...
```
On Arch Linux, age is available from AUR as [`age`](https://aur.archlinux.org/packages/age/) or [`age-git`](https://aur.archlinux.org/packages/age-git/):
```bash
git clone https://aur.archlinux.org/age.git
cd age
makepkg -si
```
On OpenBSD -current and 6.7+, you can use the port:
```
pkg_add age
```
On all supported versions of FreeBSD, you can build the security/age port or use pkg:
```
pkg install age
```
Help from new packagers is very welcome.
+234
View File
@@ -0,0 +1,234 @@
// Copyright 2019 Google LLC
//
// 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 age implements file encryption according to the age-encryption.org/v1
// specification.
//
// For most use cases, use the Encrypt and Decrypt functions with
// X25519Recipient and X25519Identity. If passphrase encryption is required, use
// ScryptRecipient and ScryptIdentity. For compatibility with existing SSH keys
// use the filippo.io/age/agessh package.
//
// Age encrypted files are binary and not malleable. For encoding them as text,
// use the filippo.io/age/armor package.
//
// Key management
//
// Age does not have a global keyring. Instead, since age keys are small,
// textual, and cheap, you are encoraged to generate dedicated keys for each
// task and application.
//
// Recipient public keys can be passed around as command line flags and in
// config files, while secret keys should be stored in dedicated files, through
// secret management systems, or as environment variables.
//
// 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.
//
// 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.
package age
import (
"crypto/hmac"
"crypto/rand"
"errors"
"fmt"
"io"
"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
// plugin, or a custom implementation.
//
// Unwrap must return an error wrapping ErrIncorrectIdentity if none of the
// recipient stanzas match the identity, any other error will be considered
// fatal.
//
// Most age API users won't need to interact with this directly, and should
// instead pass Recipient implementations to Encrypt and Identity
// implementations to Decrypt.
type Identity interface {
Unwrap(stanzas []*Stanza) (fileKey []byte, err error)
}
var ErrIncorrectIdentity = errors.New("incorrect identity for recipient block")
// A Recipient is passed to Encrypt to wrap an opaque file key to one or more
// recipient stanza(s). It can be for example a public key like X25519Recipient,
// a plugin, or a custom implementation.
//
// Most age API users won't need to interact with this directly, and should
// instead pass Recipient implementations to Encrypt and Identity
// implementations to Decrypt.
type Recipient interface {
Wrap(fileKey []byte) ([]*Stanza, error)
}
// A Stanza is a section of the age header that encapsulates the file key as
// encrypted to a specific recipient.
//
// Most age API users won't need to interact with this directly, and should
// instead pass Recipient implementations to Encrypt and Identity
// implementations to Decrypt.
type Stanza struct {
Type string
Args []string
Body []byte
}
const fileKeySize = 16
const streamNonceSize = 16
// Encrypt encrypts a file to one or more recipients.
//
// Writes to the returned WriteCloser are encrypted and written to dst as an age
// file. Every recipient will be able to decrypt the file.
//
// The caller must call Close on the WriteCloser when done for the last chunk to
// be encrypted and flushed to dst.
func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) {
if len(recipients) == 0 {
return nil, errors.New("no recipients specified")
}
fileKey := make([]byte, fileKeySize)
if _, err := rand.Read(fileKey); err != nil {
return nil, err
}
hdr := &format.Header{}
for i, r := range recipients {
stanzas, err := r.Wrap(fileKey)
if err != nil {
return nil, fmt.Errorf("failed to wrap key for recipient #%d: %v", i, err)
}
for _, s := range stanzas {
hdr.Recipients = append(hdr.Recipients, (*format.Stanza)(s))
}
}
for _, s := range hdr.Recipients {
if s.Type == "scrypt" && len(hdr.Recipients) != 1 {
return nil, errors.New("an scrypt recipient must be the only one")
}
}
if mac, err := headerMAC(fileKey, hdr); err != nil {
return nil, fmt.Errorf("failed to compute header MAC: %v", err)
} else {
hdr.MAC = mac
}
if err := hdr.Marshal(dst); err != nil {
return nil, fmt.Errorf("failed to write header: %v", err)
}
nonce := make([]byte, streamNonceSize)
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
if _, err := dst.Write(nonce); err != nil {
return nil, fmt.Errorf("failed to write nonce: %v", err)
}
return stream.NewWriter(streamKey(fileKey, nonce), dst)
}
// NoIdentityMatchError is returned by Decrypt when none of the supplied
// identities match the encrypted file.
type NoIdentityMatchError struct {
// Errors is a slice of all the errors returned to Decrypt by the Unwrap
// calls it made. They all wrap ErrIncorrectIdentity.
Errors []error
}
func (*NoIdentityMatchError) Error() string {
return "no identity matched any of the recipients"
}
// Decrypt decrypts a file encrypted to one or more identities.
//
// It returns a Reader reading the decrypted plaintext of the age file read
// from src. All identities will be tried until one successfully decrypts the file.
func Decrypt(src io.Reader, identities ...Identity) (io.Reader, error) {
if len(identities) == 0 {
return nil, errors.New("no identities specified")
}
hdr, payload, err := format.Parse(src)
if err != nil {
return nil, fmt.Errorf("failed to read header: %v", err)
}
if len(hdr.Recipients) > 20 {
return nil, errors.New("too many recipients")
}
for _, r := range hdr.Recipients {
if r.Type == "scrypt" && len(hdr.Recipients) != 1 {
return nil, errors.New("an scrypt recipient must be the only one")
}
}
stanzas := make([]*Stanza, 0, len(hdr.Recipients))
for _, s := range hdr.Recipients {
stanzas = append(stanzas, (*Stanza)(s))
}
errNoMatch := &NoIdentityMatchError{}
var fileKey []byte
for _, id := range identities {
fileKey, err = id.Unwrap(stanzas)
if errors.Is(err, ErrIncorrectIdentity) {
errNoMatch.Errors = append(errNoMatch.Errors, err)
continue
}
if err != nil {
return nil, err
}
break
}
if fileKey == nil {
return nil, errNoMatch
}
if mac, err := headerMAC(fileKey, hdr); err != nil {
return nil, fmt.Errorf("failed to compute header MAC: %v", err)
} else if !hmac.Equal(mac, hdr.MAC) {
return nil, errors.New("bad header MAC")
}
nonce := make([]byte, streamNonceSize)
if _, err := io.ReadFull(payload, nonce); err != nil {
return nil, fmt.Errorf("failed to read nonce: %v", err)
}
return stream.NewReader(streamKey(fileKey, nonce), payload)
}
// multiUnwrap is a helper that implements Identity.Unwrap in terms of a
// function that unwraps a single recipient stanza.
func multiUnwrap(unwrap func(*Stanza) ([]byte, error), stanzas []*Stanza) ([]byte, error) {
for _, s := range stanzas {
fileKey, err := unwrap(s)
if errors.Is(err, ErrIncorrectIdentity) {
// If we ever start returning something interesting wrapping
// ErrIncorrectIdentity, we should let it make its way up through
// Decrypt into NoIdentityMatchError.Errors.
continue
}
if err != nil {
return nil, err
}
return fileKey, nil
}
return nil, ErrIncorrectIdentity
}
+225
View File
@@ -0,0 +1,225 @@
// Copyright 2019 Google LLC
//
// 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 age_test
import (
"bytes"
"fmt"
"io"
"io/ioutil"
"log"
"os"
"strings"
"testing"
"filippo.io/age"
)
func ExampleEncrypt() {
publicKey := "age1cy0su9fwf3gf9mw868g5yut09p6nytfmmnktexz2ya5uqg9vl9sss4euqm"
recipient, err := age.ParseX25519Recipient(publicKey)
if err != nil {
log.Fatalf("Failed to parse public key %q: %v", publicKey, err)
}
out := &bytes.Buffer{}
w, err := age.Encrypt(out, recipient)
if err != nil {
log.Fatalf("Failed to create encrypted file: %v", err)
}
if _, err := io.WriteString(w, "Black lives matter."); err != nil {
log.Fatalf("Failed to write to encrypted file: %v", err)
}
if err := w.Close(); err != nil {
log.Fatalf("Failed to close encrypted file: %v", err)
}
fmt.Printf("Encrypted file size: %d\n", out.Len())
// Output:
// Encrypted file size: 219
}
// DO NOT hardcode the private key. Store it in a secret storage solution,
// on disk if the local machine is trusted, or have the user provide it.
var privateKey string
func init() {
privateKey = "AGE-SECRET-KEY-184JMZMVQH3E6U0PSL869004Y3U2NYV7R30EU99CSEDNPH02YUVFSZW44VU"
}
func ExampleDecrypt() {
identity, err := age.ParseX25519Identity(privateKey)
if err != nil {
log.Fatalf("Failed to parse private key: %v", err)
}
f, err := os.Open("testdata/example.age")
if err != nil {
log.Fatalf("Failed to open file: %v", err)
}
r, err := age.Decrypt(f, identity)
if err != nil {
log.Fatalf("Failed to open encrypted file: %v", err)
}
out := &bytes.Buffer{}
if _, err := io.Copy(out, r); err != nil {
log.Fatalf("Failed to read encrypted file: %v", err)
}
fmt.Printf("File contents: %q\n", out.Bytes())
// Output:
// File contents: "Black lives matter."
}
func ExampleParseIdentities() {
keyFile, err := os.Open("testdata/keys.txt")
if err != nil {
log.Fatalf("Failed to open private keys file: %v", err)
}
identities, err := age.ParseIdentities(keyFile)
if err != nil {
log.Fatalf("Failed to parse private key: %v", err)
}
f, err := os.Open("testdata/example.age")
if err != nil {
log.Fatalf("Failed to open file: %v", err)
}
r, err := age.Decrypt(f, identities...)
if err != nil {
log.Fatalf("Failed to open encrypted file: %v", err)
}
out := &bytes.Buffer{}
if _, err := io.Copy(out, r); err != nil {
log.Fatalf("Failed to read encrypted file: %v", err)
}
fmt.Printf("File contents: %q\n", out.Bytes())
// Output:
// File contents: "Black lives matter."
}
func ExampleGenerateX25519Identity() {
identity, err := age.GenerateX25519Identity()
if err != nil {
log.Fatalf("Failed to generate key pair: %v", err)
}
fmt.Printf("Public key: %s...\n", identity.Recipient().String()[:4])
fmt.Printf("Private key: %s...\n", identity.String()[:16])
// Output:
// Public key: age1...
// Private key: AGE-SECRET-KEY-1...
}
const helloWorld = "Hello, Twitch!"
func TestEncryptDecryptX25519(t *testing.T) {
a, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
b, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
buf := &bytes.Buffer{}
w, err := age.Encrypt(buf, a.Recipient(), b.Recipient())
if err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(w, helloWorld); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
out, err := age.Decrypt(buf, b)
if err != nil {
t.Fatal(err)
}
outBytes, err := ioutil.ReadAll(out)
if err != nil {
t.Fatal(err)
}
if string(outBytes) != helloWorld {
t.Errorf("wrong data: %q, excepted %q", outBytes, helloWorld)
}
}
func TestEncryptDecryptScrypt(t *testing.T) {
password := "twitch.tv/filosottile"
r, err := age.NewScryptRecipient(password)
if err != nil {
t.Fatal(err)
}
r.SetWorkFactor(15)
buf := &bytes.Buffer{}
w, err := age.Encrypt(buf, r)
if err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(w, helloWorld); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
i, err := age.NewScryptIdentity(password)
if err != nil {
t.Fatal(err)
}
out, err := age.Decrypt(buf, i)
if err != nil {
t.Fatal(err)
}
outBytes, err := ioutil.ReadAll(out)
if err != nil {
t.Fatal(err)
}
if string(outBytes) != helloWorld {
t.Errorf("wrong data: %q, excepted %q", outBytes, helloWorld)
}
}
func TestParseIdentities(t *testing.T) {
tests := []struct {
name string
wantCount int
wantErr bool
file string
}{
{"valid", 2, false, `
# this is a comment
# AGE-SECRET-KEY-1705XN76M8EYQ8M9PY4E2G3KA8DN7NSCGT3V4HMN20H3GCX4AS6HSSTG8D3
#
AGE-SECRET-KEY-1D6K0SGAX3NU66R4GYFZY0UQWCLM3UUSF3CXLW4KXZM342WQSJ82QKU59QJ
AGE-SECRET-KEY-19WUMFE89H3928FRJ5U3JYRNHM6CERQGKSQ584AQ8QY7T7R09D32SWE4DYH`},
{"invalid", 0, true, `
AGE-SECRET-KEY-1705XN76M8EYQ8M9PY4E2G3KA8DN7NSCGT3V4HMN20H3GCX4AS6HSSTG8D3
AGE-SECRET-KEY--1D6K0SGAX3NU66R4GYFZY0UQWCLM3UUSF3CXLW4KXZM342WQSJ82QKU59Q`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := age.ParseIdentities(strings.NewReader(tt.file))
if (err != nil) != tt.wantErr {
t.Errorf("ParseIdentities() error = %v, wantErr %v", err, tt.wantErr)
return
}
if len(got) != tt.wantCount {
t.Errorf("ParseIdentities() returned %d identities, want %d", len(got), tt.wantCount)
}
})
}
}
+129 -92
View File
@@ -4,7 +4,16 @@
// license that can be found in the LICENSE file or at
// https://developers.google.com/open-source/licenses/bsd
package age
// Package agessh provides age.Identity and age.Recipient implementations of
// types "ssh-rsa" and "ssh-ed25519", which allow reusing existing SSH keys for
// encryption with age-encryption.org/v1.
//
// These recipient types should only be used for compatibility with existing
// keys, and native X25519 keys should be preferred otherwise.
//
// Note that these recipient types are not anonymous: the encrypted message will
// include a short 32-bit ID of the public key.
package agessh
import (
"crypto/ed25519"
@@ -15,38 +24,35 @@ import (
"errors"
"fmt"
"io"
"math/big"
"filippo.io/age"
"filippo.io/age/internal/format"
"filippo.io/edwards25519"
"golang.org/x/crypto/chacha20poly1305"
"golang.org/x/crypto/curve25519"
"golang.org/x/crypto/hkdf"
"golang.org/x/crypto/ssh"
)
func SSHFingerprint(pk ssh.PublicKey) string {
h := sha256.New()
h.Write(pk.Marshal())
hh := h.Sum(nil)
return format.EncodeToString(hh[:4])
func sshFingerprint(pk ssh.PublicKey) string {
h := sha256.Sum256(pk.Marshal())
return format.EncodeToString(h[:4])
}
const oaepLabel = "age-encryption.org/v1/ssh-rsa"
type SSHRSARecipient struct {
type RSARecipient struct {
sshKey ssh.PublicKey
pubKey *rsa.PublicKey
}
var _ Recipient = &SSHRSARecipient{}
var _ age.Recipient = &RSARecipient{}
func (*SSHRSARecipient) Type() string { return "ssh-rsa" }
func NewSSHRSARecipient(pk ssh.PublicKey) (*SSHRSARecipient, error) {
func NewRSARecipient(pk ssh.PublicKey) (*RSARecipient, error) {
if pk.Type() != "ssh-rsa" {
return nil, errors.New("SSH public key is not an RSA key")
}
r := &SSHRSARecipient{
r := &RSARecipient{
sshKey: pk,
}
@@ -62,10 +68,10 @@ func NewSSHRSARecipient(pk ssh.PublicKey) (*SSHRSARecipient, error) {
return r, nil
}
func (r *SSHRSARecipient) Wrap(fileKey []byte) (*format.Recipient, error) {
l := &format.Recipient{
func (r *RSARecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
l := &age.Stanza{
Type: "ssh-rsa",
Args: []string{SSHFingerprint(r.sshKey)},
Args: []string{sshFingerprint(r.sshKey)},
}
wrappedKey, err := rsa.EncryptOAEP(sha256.New(), rand.Reader,
@@ -75,39 +81,41 @@ func (r *SSHRSARecipient) Wrap(fileKey []byte) (*format.Recipient, error) {
}
l.Body = wrappedKey
return l, nil
return []*age.Stanza{l}, nil
}
type SSHRSAIdentity struct {
type RSAIdentity struct {
k *rsa.PrivateKey
sshKey ssh.PublicKey
}
var _ Identity = &SSHRSAIdentity{}
var _ age.Identity = &RSAIdentity{}
func (*SSHRSAIdentity) Type() string { return "ssh-rsa" }
func NewSSHRSAIdentity(key *rsa.PrivateKey) (*SSHRSAIdentity, error) {
func NewRSAIdentity(key *rsa.PrivateKey) (*RSAIdentity, error) {
s, err := ssh.NewSignerFromKey(key)
if err != nil {
return nil, err
}
i := &SSHRSAIdentity{
i := &RSAIdentity{
k: key, sshKey: s.PublicKey(),
}
return i, nil
}
func (i *SSHRSAIdentity) Unwrap(block *format.Recipient) ([]byte, error) {
func (i *RSAIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
return multiUnwrap(i.unwrap, stanzas)
}
func (i *RSAIdentity) unwrap(block *age.Stanza) ([]byte, error) {
if block.Type != "ssh-rsa" {
return nil, ErrIncorrectIdentity
return nil, age.ErrIncorrectIdentity
}
if len(block.Args) != 1 {
return nil, errors.New("invalid ssh-rsa recipient block")
}
if block.Args[0] != SSHFingerprint(i.sshKey) {
return nil, ErrIncorrectIdentity
if block.Args[0] != sshFingerprint(i.sshKey) {
return nil, age.ErrIncorrectIdentity
}
fileKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, i.k,
@@ -118,47 +126,49 @@ func (i *SSHRSAIdentity) Unwrap(block *format.Recipient) ([]byte, error) {
return fileKey, nil
}
type SSHEd25519Recipient struct {
type Ed25519Recipient struct {
sshKey ssh.PublicKey
theirPublicKey []byte
}
var _ Recipient = &SSHEd25519Recipient{}
var _ age.Recipient = &Ed25519Recipient{}
func (*SSHEd25519Recipient) Type() string { return "ssh-ed25519" }
func NewSSHEd25519Recipient(pk ssh.PublicKey) (*SSHEd25519Recipient, error) {
func NewEd25519Recipient(pk ssh.PublicKey) (*Ed25519Recipient, error) {
if pk.Type() != "ssh-ed25519" {
return nil, errors.New("SSH public key is not an Ed25519 key")
}
r := &SSHEd25519Recipient{
sshKey: pk,
}
if pk, ok := pk.(ssh.CryptoPublicKey); ok {
if pk, ok := pk.CryptoPublicKey().(ed25519.PublicKey); ok {
r.theirPublicKey = ed25519PublicKeyToCurve25519(pk)
} else {
return nil, errors.New("unexpected public key type")
}
} else {
cpk, ok := pk.(ssh.CryptoPublicKey)
if !ok {
return nil, errors.New("pk does not implement ssh.CryptoPublicKey")
}
return r, nil
epk, ok := cpk.CryptoPublicKey().(ed25519.PublicKey)
if !ok {
return nil, errors.New("unexpected public key type")
}
mpk, err := ed25519PublicKeyToCurve25519(epk)
if err != nil {
return nil, fmt.Errorf("invalid Ed25519 public key: %v", err)
}
return &Ed25519Recipient{
sshKey: pk,
theirPublicKey: mpk,
}, nil
}
func ParseSSHRecipient(s string) (Recipient, error) {
func ParseRecipient(s string) (age.Recipient, error) {
pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(s))
if err != nil {
return nil, fmt.Errorf("malformed SSH recipient: %q: %v", s, err)
}
var r Recipient
var r age.Recipient
switch t := pubKey.Type(); t {
case "ssh-rsa":
r, err = NewSSHRSARecipient(pubKey)
r, err = NewRSARecipient(pubKey)
case "ssh-ed25519":
r, err = NewSSHEd25519Recipient(pubKey)
r, err = NewEd25519Recipient(pubKey)
default:
return nil, fmt.Errorf("unknown SSH recipient type: %q", t)
}
@@ -169,40 +179,19 @@ func ParseSSHRecipient(s string) (Recipient, error) {
return r, nil
}
var curve25519P, _ = new(big.Int).SetString("57896044618658097711785492504343953926634992332820282019728792003956564819949", 10)
func ed25519PublicKeyToCurve25519(pk ed25519.PublicKey) []byte {
// ed25519.PublicKey is a little endian representation of the y-coordinate,
// with the most significant bit set based on the sign of the x-coordinate.
bigEndianY := make([]byte, ed25519.PublicKeySize)
for i, b := range pk {
bigEndianY[ed25519.PublicKeySize-i-1] = b
func ed25519PublicKeyToCurve25519(pk ed25519.PublicKey) ([]byte, error) {
// See https://blog.filippo.io/using-ed25519-keys-for-encryption and
// https://pkg.go.dev/filippo.io/edwards25519#Point.BytesMontgomery.
p, err := (&edwards25519.Point{}).SetBytes(pk)
if err != nil {
return nil, err
}
bigEndianY[0] &= 0b0111_1111
// The Montgomery u-coordinate is derived through the bilinear map
//
// u = (1 + y) / (1 - y)
//
// See https://blog.filippo.io/using-ed25519-keys-for-encryption.
y := new(big.Int).SetBytes(bigEndianY)
denom := big.NewInt(1)
denom.ModInverse(denom.Sub(denom, y), curve25519P) // 1 / (1 - y)
u := y.Mul(y.Add(y, big.NewInt(1)), denom)
u.Mod(u, curve25519P)
out := make([]byte, curve25519.PointSize)
uBytes := u.Bytes()
for i, b := range uBytes {
out[len(uBytes)-i-1] = b
}
return out
return p.BytesMontgomery(), nil
}
const ed25519Label = "age-encryption.org/v1/ssh-ed25519"
func (r *SSHEd25519Recipient) Wrap(fileKey []byte) (*format.Recipient, error) {
func (r *Ed25519Recipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
ephemeral := make([]byte, curve25519.ScalarSize)
if _, err := rand.Read(ephemeral); err != nil {
return nil, err
@@ -224,9 +213,9 @@ func (r *SSHEd25519Recipient) Wrap(fileKey []byte) (*format.Recipient, error) {
}
sharedSecret, _ = curve25519.X25519(tweak, sharedSecret)
l := &format.Recipient{
l := &age.Stanza{
Type: "ssh-ed25519",
Args: []string{SSHFingerprint(r.sshKey),
Args: []string{sshFingerprint(r.sshKey),
format.EncodeToString(ourPublicKey[:])},
}
@@ -245,24 +234,22 @@ func (r *SSHEd25519Recipient) Wrap(fileKey []byte) (*format.Recipient, error) {
}
l.Body = wrappedKey
return l, nil
return []*age.Stanza{l}, nil
}
type SSHEd25519Identity struct {
type Ed25519Identity struct {
secretKey, ourPublicKey []byte
sshKey ssh.PublicKey
}
var _ Identity = &SSHEd25519Identity{}
var _ age.Identity = &Ed25519Identity{}
func (*SSHEd25519Identity) Type() string { return "ssh-ed25519" }
func NewSSHEd25519Identity(key ed25519.PrivateKey) (*SSHEd25519Identity, error) {
func NewEd25519Identity(key ed25519.PrivateKey) (*Ed25519Identity, error) {
s, err := ssh.NewSignerFromKey(key)
if err != nil {
return nil, err
}
i := &SSHEd25519Identity{
i := &Ed25519Identity{
sshKey: s.PublicKey(),
secretKey: ed25519PrivateKeyToCurve25519(key),
}
@@ -270,7 +257,7 @@ func NewSSHEd25519Identity(key ed25519.PrivateKey) (*SSHEd25519Identity, error)
return i, nil
}
func ParseSSHIdentity(pemBytes []byte) (Identity, error) {
func ParseIdentity(pemBytes []byte) (age.Identity, error) {
k, err := ssh.ParseRawPrivateKey(pemBytes)
if err != nil {
return nil, err
@@ -278,9 +265,9 @@ func ParseSSHIdentity(pemBytes []byte) (Identity, error) {
switch k := k.(type) {
case *ed25519.PrivateKey:
return NewSSHEd25519Identity(*k)
return NewEd25519Identity(*k)
case *rsa.PrivateKey:
return NewSSHRSAIdentity(k)
return NewRSAIdentity(k)
}
return nil, fmt.Errorf("unsupported SSH identity type: %T", k)
@@ -293,9 +280,13 @@ func ed25519PrivateKeyToCurve25519(pk ed25519.PrivateKey) []byte {
return out[:curve25519.ScalarSize]
}
func (i *SSHEd25519Identity) Unwrap(block *format.Recipient) ([]byte, error) {
func (i *Ed25519Identity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
return multiUnwrap(i.unwrap, stanzas)
}
func (i *Ed25519Identity) unwrap(block *age.Stanza) ([]byte, error) {
if block.Type != "ssh-ed25519" {
return nil, ErrIncorrectIdentity
return nil, age.ErrIncorrectIdentity
}
if len(block.Args) != 2 {
return nil, errors.New("invalid ssh-ed25519 recipient block")
@@ -308,8 +299,8 @@ func (i *SSHEd25519Identity) Unwrap(block *format.Recipient) ([]byte, error) {
return nil, errors.New("invalid ssh-ed25519 recipient block")
}
if block.Args[0] != SSHFingerprint(i.sshKey) {
return nil, ErrIncorrectIdentity
if block.Args[0] != sshFingerprint(i.sshKey) {
return nil, age.ErrIncorrectIdentity
}
sharedSecret, err := curve25519.X25519(i.secretKey, publicKey)
@@ -339,3 +330,49 @@ func (i *SSHEd25519Identity) Unwrap(block *format.Recipient) ([]byte, error) {
}
return fileKey, nil
}
// multiUnwrap is copied from package age. It's a helper that implements
// Identity.Unwrap in terms of a function that unwraps a single recipient
// stanza.
func multiUnwrap(unwrap func(*age.Stanza) ([]byte, error), stanzas []*age.Stanza) ([]byte, error) {
for _, s := range stanzas {
fileKey, err := unwrap(s)
if errors.Is(err, age.ErrIncorrectIdentity) {
// If we ever start returning something interesting wrapping
// ErrIncorrectIdentity, we should let it make its way up through
// Decrypt into NoIdentityMatchError.Errors.
continue
}
if err != nil {
return nil, err
}
return fileKey, nil
}
return nil, age.ErrIncorrectIdentity
}
// aeadEncrypt and aeadDecrypt are copied from package age.
//
// They don't limit the file key size because multi-key attacks are irrelevant
// against the ssh-ed25519 recipient. Being an asymmetric recipient, it would
// only allow a more efficient search for accepted public keys against a
// decryption oracle, but the ssh-X recipients are not anonymous (they have a
// short recipient hash).
func aeadEncrypt(key, plaintext []byte) ([]byte, error) {
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
nonce := make([]byte, chacha20poly1305.NonceSize)
return aead.Seal(nil, nonce, plaintext, nil), nil
}
func aeadDecrypt(key, ciphertext []byte) ([]byte, error) {
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
nonce := make([]byte, chacha20poly1305.NonceSize)
return aead.Open(nil, nonce, ciphertext, nil)
}
+94
View File
@@ -0,0 +1,94 @@
// Copyright 2019 Google LLC
//
// 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 agessh_test
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"testing"
"filippo.io/age/agessh"
"golang.org/x/crypto/ssh"
)
func TestSSHRSARoundTrip(t *testing.T) {
pk, err := rsa.GenerateKey(rand.Reader, 768)
if err != nil {
t.Fatal(err)
}
pub, err := ssh.NewPublicKey(&pk.PublicKey)
if err != nil {
t.Fatal(err)
}
r, err := agessh.NewRSARecipient(pub)
if err != nil {
t.Fatal(err)
}
i, err := agessh.NewRSAIdentity(pk)
if err != nil {
t.Fatal(err)
}
fileKey := make([]byte, 16)
if _, err := rand.Read(fileKey); err != nil {
t.Fatal(err)
}
stanzas, err := r.Wrap(fileKey)
if err != nil {
t.Fatal(err)
}
out, err := i.Unwrap(stanzas)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(fileKey, out) {
t.Errorf("invalid output: %x, expected %x", out, fileKey)
}
}
func TestSSHEd25519RoundTrip(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
sshPubKey, err := ssh.NewPublicKey(pub)
if err != nil {
t.Fatal(err)
}
r, err := agessh.NewEd25519Recipient(sshPubKey)
if err != nil {
t.Fatal(err)
}
i, err := agessh.NewEd25519Identity(priv)
if err != nil {
t.Fatal(err)
}
fileKey := make([]byte, 16)
if _, err := rand.Read(fileKey); err != nil {
t.Fatal(err)
}
stanzas, err := r.Wrap(fileKey)
if err != nil {
t.Fatal(err)
}
out, err := i.Unwrap(stanzas)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(fileKey, out) {
t.Errorf("invalid output: %x, expected %x", out, fileKey)
}
}
+114
View File
@@ -0,0 +1,114 @@
// Copyright 2019 Google LLC
//
// 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 agessh
import (
"crypto/ed25519"
"crypto/rsa"
"fmt"
"filippo.io/age"
"golang.org/x/crypto/ssh"
)
// EncryptedSSHIdentity is an age.Identity implementation based on a passphrase
// encrypted SSH private key.
//
// It requests the passphrase only if the public key matches a recipient stanza.
// If the application knows it will always have to decrypt the private key, it
// would be simpler to use ssh.ParseRawPrivateKeyWithPassphrase directly and
// pass the result to NewEd25519Identity or NewRSAIdentity.
type EncryptedSSHIdentity struct {
pubKey ssh.PublicKey
pemBytes []byte
passphrase func() ([]byte, error)
decrypted age.Identity
}
// NewEncryptedSSHIdentity returns a new EncryptedSSHIdentity.
//
// pubKey must be the public key associated with the encrypted private key, and
// it must have type "ssh-ed25519" or "ssh-rsa". For OpenSSH encrypted files it
// can be extracted from an ssh.PassphraseMissingError, otherwise it can often
// be found in ".pub" files.
//
// pemBytes must be a valid input to ssh.ParseRawPrivateKeyWithPassphrase.
// passphrase is a callback that will be invoked by Unwrap when the passphrase
// is necessary.
func NewEncryptedSSHIdentity(pubKey ssh.PublicKey, pemBytes []byte, passphrase func() ([]byte, error)) (*EncryptedSSHIdentity, error) {
switch t := pubKey.Type(); t {
case "ssh-ed25519", "ssh-rsa":
default:
return nil, fmt.Errorf("unsupported SSH key type: %v", t)
}
return &EncryptedSSHIdentity{
pubKey: pubKey,
pemBytes: pemBytes,
passphrase: passphrase,
}, nil
}
var _ age.Identity = &EncryptedSSHIdentity{}
// Unwrap implements age.Identity. If the private key is still encrypted, and
// any of the stanzas match the public key, it will request the passphrase. The
// decrypted private key will be cached after the first successful invocation.
func (i *EncryptedSSHIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) {
if i.decrypted != nil {
return i.decrypted.Unwrap(stanzas)
}
var match bool
for _, s := range stanzas {
if s.Type != i.pubKey.Type() {
continue
}
if len(s.Args) < 1 {
return nil, fmt.Errorf("invalid %v recipient block", i.pubKey.Type())
}
if s.Args[0] != sshFingerprint(i.pubKey) {
continue
}
match = true
break
}
if !match {
return nil, age.ErrIncorrectIdentity
}
passphrase, err := i.passphrase()
if err != nil {
return nil, fmt.Errorf("failed to obtain passphrase: %v", err)
}
k, err := ssh.ParseRawPrivateKeyWithPassphrase(i.pemBytes, passphrase)
if err != nil {
return nil, fmt.Errorf("failed to decrypt SSH key file: %v", err)
}
switch k := k.(type) {
case *ed25519.PrivateKey:
i.decrypted, err = NewEd25519Identity(*k)
// TODO: here and below, better check that the two public keys match,
// rather than just the type.
if i.pubKey.Type() != ssh.KeyAlgoED25519 {
return nil, fmt.Errorf("mismatched private (%s) and public (%s) SSH key types", ssh.KeyAlgoED25519, i.pubKey.Type())
}
case *rsa.PrivateKey:
i.decrypted, err = NewRSAIdentity(k)
if i.pubKey.Type() != ssh.KeyAlgoRSA {
return nil, fmt.Errorf("mismatched private (%s) and public (%s) SSH key types", ssh.KeyAlgoRSA, i.pubKey.Type())
}
default:
return nil, fmt.Errorf("unexpected SSH key type: %T", k)
}
if err != nil {
return nil, fmt.Errorf("invalid SSH key: %v", err)
}
return i.decrypted.Unwrap(stanzas)
}
+24 -55
View File
@@ -4,7 +4,12 @@
// license that can be found in the LICENSE file or at
// https://developers.google.com/open-source/licenses/bsd
package format
// Package armor provides a strict, streaming implementation of the ASCII
// armoring format for age files.
//
// It's PEM with type "AGE ENCRYPTED FILE", 64 character columns, no headers,
// and strict base64 decoding.
package armor
import (
"bufio"
@@ -12,50 +17,14 @@ import (
"encoding/base64"
"errors"
"io"
"filippo.io/age/internal/format"
)
type newlineWriter struct {
dst io.Writer
written int
}
func (w *newlineWriter) Write(p []byte) (n int, err error) {
for len(p) > 0 {
remainingInLine := columnsPerLine - (w.written % columnsPerLine)
if remainingInLine == columnsPerLine && w.written != 0 {
if _, err := w.dst.Write([]byte("\n")); err != nil {
return n, err
}
}
toWrite := remainingInLine
if toWrite > len(p) {
toWrite = len(p)
}
nn, err := w.dst.Write(p[:toWrite])
n += nn
w.written += nn
p = p[nn:]
if err != nil {
return n, err
}
}
return n, nil
}
type CloserFunc func() error
func (f CloserFunc) Close() error { return f() }
type nopCloser struct {
io.Writer
}
func (nopCloser) Close() error { return nil }
func NopCloser(w io.Writer) io.WriteCloser { return nopCloser{w} }
const armorPreamble = "-----BEGIN AGE ENCRYPTED FILE-----"
const armorEnd = "-----END AGE ENCRYPTED FILE-----"
const (
Header = "-----BEGIN AGE ENCRYPTED FILE-----"
Footer = "-----END AGE ENCRYPTED FILE-----"
)
type armoredWriter struct {
started, closed bool
@@ -65,7 +34,7 @@ type armoredWriter struct {
func (a *armoredWriter) Write(p []byte) (int, error) {
if !a.started {
if _, err := io.WriteString(a.dst, armorPreamble+"\n"); err != nil {
if _, err := io.WriteString(a.dst, Header+"\n"); err != nil {
return 0, err
}
}
@@ -81,26 +50,26 @@ func (a *armoredWriter) Close() error {
if err := a.encoder.Close(); err != nil {
return err
}
_, err := io.WriteString(a.dst, "\n"+armorEnd+"\n")
_, err := io.WriteString(a.dst, "\n"+Footer+"\n")
return err
}
func ArmoredWriter(dst io.Writer) io.WriteCloser {
func NewWriter(dst io.Writer) io.WriteCloser {
// TODO: write a test with aligned and misaligned sizes, and 8 and 10 steps.
return &armoredWriter{dst: dst,
encoder: base64.NewEncoder(base64.StdEncoding.Strict(),
&newlineWriter{dst: dst})}
format.NewlineWriter(dst))}
}
type armoredReader struct {
r *bufio.Reader
started bool
unread []byte // backed by buf
buf [bytesPerLine]byte
buf [format.BytesPerLine]byte
err error
}
func ArmoredReader(r io.Reader) io.Reader {
func NewReader(r io.Reader) io.Reader {
return &armoredReader{r: bufio.NewReader(r)}
}
@@ -130,7 +99,7 @@ func (r *armoredReader) Read(p []byte) (int, error) {
if err != nil {
return 0, r.setErr(err)
}
if string(line) != armorPreamble {
if string(line) != Header {
return 0, r.setErr(errors.New("invalid armor first line: " + string(line)))
}
r.started = true
@@ -139,25 +108,25 @@ func (r *armoredReader) Read(p []byte) (int, error) {
if err != nil {
return 0, r.setErr(err)
}
if string(line) == armorEnd {
if string(line) == Footer {
return 0, r.setErr(io.EOF)
}
if len(line) > columnsPerLine {
if len(line) > format.ColumnsPerLine {
return 0, r.setErr(errors.New("invalid armor: column limit exceeded"))
}
r.unread = r.buf[:]
n, err := base64.StdEncoding.Strict().Decode(r.unread, line)
if err != nil {
return 0, r.setErr(err)
return 0, r.setErr(errors.New("invalid armor: " + err.Error()))
}
r.unread = r.unread[:n]
if n < bytesPerLine {
if n < format.BytesPerLine {
line, err := getLine()
if err != nil {
return 0, r.setErr(err)
}
if string(line) != armorEnd {
if string(line) != Footer {
return 0, r.setErr(errors.New("invalid armor closing line: " + string(line)))
}
r.err = io.EOF
+116
View File
@@ -0,0 +1,116 @@
// Copyright 2019 Google LLC
//
// 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 armor_test
import (
"bytes"
"encoding/pem"
"fmt"
"io"
"io/ioutil"
"log"
"strings"
"testing"
"filippo.io/age"
"filippo.io/age/armor"
)
func ExampleNewWriter() {
publicKey := "age1cy0su9fwf3gf9mw868g5yut09p6nytfmmnktexz2ya5uqg9vl9sss4euqm"
recipient, err := age.ParseX25519Recipient(publicKey)
if err != nil {
log.Fatalf("Failed to parse public key %q: %v", publicKey, err)
}
buf := &bytes.Buffer{}
armorWriter := armor.NewWriter(buf)
w, err := age.Encrypt(armorWriter, recipient)
if err != nil {
log.Fatalf("Failed to create encrypted file: %v", err)
}
if _, err := io.WriteString(w, "Black lives matter."); err != nil {
log.Fatalf("Failed to write to encrypted file: %v", err)
}
if err := w.Close(); err != nil {
log.Fatalf("Failed to close encrypted file: %v", err)
}
if err := armorWriter.Close(); err != nil {
log.Fatalf("Failed to close armor: %v", err)
}
fmt.Printf("%s[...]", buf.Bytes()[:35])
// Output:
// -----BEGIN AGE ENCRYPTED FILE-----
// [...]
}
var privateKey = "AGE-SECRET-KEY-184JMZMVQH3E6U0PSL869004Y3U2NYV7R30EU99CSEDNPH02YUVFSZW44VU"
func ExampleNewReader() {
fileContents := `-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4YWdhZHZ0WG1PZldDT1hD
K3RPRzFkUlJnWlFBQlUwemtjeXFRMFp6V1VFCnRzZFV3a3Vkd1dSUWw2eEtrRkVv
SHcvZnp6Q3lqLy9HMkM4ZjUyUGdDZjQKLS0tIDlpVUpuVUQ5YUJyUENFZ0lNSTB2
ekUvS3E5WjVUN0F5ZWR1ejhpeU5rZUUKsvPGYt7vf0o1kyJ1eVFMz1e4JnYYk1y1
kB/RRusYjn+KVJ+KTioxj0THtzZPXcjFKuQ1
-----END AGE ENCRYPTED FILE-----`
// DO NOT hardcode the private key. Store it in a secret storage solution,
// on disk if the local machine is trusted, or have the user provide it.
identity, err := age.ParseX25519Identity(privateKey)
if err != nil {
log.Fatalf("Failed to parse private key %q: %v", privateKey, err)
}
out := &bytes.Buffer{}
f := strings.NewReader(fileContents)
armorReader := armor.NewReader(f)
r, err := age.Decrypt(armorReader, identity)
if err != nil {
log.Fatalf("Failed to open encrypted file: %v", err)
}
if _, err := io.Copy(out, r); err != nil {
log.Fatalf("Failed to read encrypted file: %v", err)
}
fmt.Printf("File contents: %q\n", out.Bytes())
// Output:
// File contents: "Black lives matter."
}
func TestArmor(t *testing.T) {
buf := &bytes.Buffer{}
w := armor.NewWriter(buf)
plain := make([]byte, 611)
if _, err := w.Write(plain); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
block, _ := pem.Decode(buf.Bytes())
if block == nil {
t.Fatal("PEM decoding failed")
}
if !bytes.Equal(block.Bytes, plain) {
t.Error("PEM decoded value doesn't match")
}
r := armor.NewReader(buf)
out, err := ioutil.ReadAll(r)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(out, plain) {
t.Error("decoded value doesn't match")
}
}
+54 -7
View File
@@ -11,26 +11,74 @@ import (
"fmt"
"log"
"os"
"runtime/debug"
"time"
"filippo.io/age/internal/age"
"filippo.io/age"
"golang.org/x/crypto/ssh/terminal"
)
const usage = `Usage:
age-keygen [-o OUTPUT]
Options:
-o, --output OUTPUT Write the key to the file at path OUTPUT.
age-keygen generates a new standard X25519 key pair, and outputs it to
standard output or to the OUTPUT file.
If an OUTPUT file is specified, the public key is printed to standard error.
If OUTPUT already exists, it is not overwritten.
Examples:
$ age-keygen
# created: 2021-01-02T15:30:45+01:00
# public key: age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z
AGE-SECRET-KEY-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9
$ age-keygen -o key.txt
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p`
// Version can be set at link time to override debug.BuildInfo.Main.Version,
// which is "(devel)" when building from within the module. See
// golang.org/issue/29814 and golang.org/issue/29228.
var Version string
func main() {
log.SetFlags(0)
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
outFlag := flag.String("o", "", "output to `FILE` (default stdout)")
var (
versionFlag bool
outFlag string
)
flag.BoolVar(&versionFlag, "version", false, "print the version")
flag.StringVar(&outFlag, "o", "", "output to `FILE` (default stdout)")
flag.StringVar(&outFlag, "output", "", "output to `FILE` (default stdout)")
flag.Parse()
if len(flag.Args()) != 0 {
log.Fatalf("age-keygen takes no arguments")
}
if versionFlag {
if Version != "" {
fmt.Println(Version)
return
}
if buildInfo, ok := debug.ReadBuildInfo(); ok {
fmt.Println(buildInfo.Main.Version)
return
}
fmt.Println("(unknown)")
return
}
out := os.Stdout
if name := *outFlag; name != "" {
f, err := os.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if outFlag != "" {
f, err := os.OpenFile(outFlag, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
log.Fatalf("Failed to open output file %q: %v", name, err)
log.Fatalf("Failed to open output file %q: %v", outFlag, err)
}
defer f.Close()
out = f
@@ -38,8 +86,7 @@ func main() {
if fi, err := out.Stat(); err == nil {
if fi.Mode().IsRegular() && fi.Mode().Perm()&0004 != 0 {
fmt.Fprintf(os.Stderr, "Warning: writing to a world-readable file.\n")
fmt.Fprintf(os.Stderr, "Consider setting the umask to 066 and trying again.\n")
fmt.Fprintf(os.Stderr, "Warning: writing secret key to a world-readable file.\n")
}
}
+167 -40
View File
@@ -7,14 +7,18 @@
package main
import (
"bufio"
"bytes"
"flag"
"fmt"
"io"
_log "log"
"os"
"runtime/debug"
"strings"
"filippo.io/age/internal/age"
"filippo.io/age"
"filippo.io/age/armor"
"golang.org/x/crypto/ssh/terminal"
)
@@ -28,55 +32,91 @@ func (f *multiFlag) Set(value string) error {
}
const usage = `Usage:
age -r RECIPIENT [-a] [-o OUTPUT] [INPUT]
age --decrypt [-i KEY] [-o OUTPUT] [INPUT]
age (-r RECIPIENT | -R PATH)... [--armor] [-o OUTPUT] [INPUT]
age --passphrase [--armor] [-o OUTPUT] [INPUT]
age --decrypt [-i PATH]... [-o OUTPUT] [INPUT]
Options:
-o OUTPUT Write the result to the file at path OUTPUT.
-o, --output OUTPUT Write the result to the file at path OUTPUT.
-a, --armor Encrypt to a PEM encoded format.
-p, --passphrase Encrypt with a passphrase.
-r, --recipient RECIPIENT Encrypt to the specified RECIPIENT. Can be repeated.
-R, --recipients-file PATH Encrypt to recipients listed at PATH. Can be repeated.
-d, --decrypt Decrypt the input to the output.
-i, --identity KEY Use the private key file at path KEY. Can be repeated.
-i, --identity PATH Use the identity file at PATH. Can be repeated.
INPUT defaults to standard input, and OUTPUT defaults to standard output.
RECIPIENT can be an age public key, as generated by age-keygen, ("age1...")
RECIPIENT can be an age public key generated by age-keygen ("age1...")
or an SSH public key ("ssh-ed25519 AAAA...", "ssh-rsa AAAA...").
KEY is a path to a file with age secret keys, one per line
(ignoring "#" prefixed comments and empty lines), or to an SSH key file.
Multiple keys can be provided, and any unused ones will be ignored.
Recipient files contain one or more recipients, one per line. Empty lines
and lines starting with "#" are ignored as comments. "-" may be used to
read recipients from standard input.
Identity files contain one or more secret keys ("AGE-SECRET-KEY-1..."),
one per line, or an SSH key. Empty lines and lines starting with "#" are
ignored as comments. Multiple key files can be provided, and any unused ones
will be ignored. "-" may be used to read identities from standard input.
Example:
$ age-keygen -o key.txt
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
$ tar cvz ~/data | age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p > data.tar.gz.age
$ age -d -i key.txt -o data.tar.gz data.tar.gz.age`
$ age --decrypt -i key.txt -o data.tar.gz data.tar.gz.age`
// Version can be set at link time to override debug.BuildInfo.Main.Version,
// which is "(devel)" when building from within the module. See
// golang.org/issue/29814 and golang.org/issue/29228.
var Version string
func main() {
_log.SetFlags(0)
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
if len(os.Args) == 1 {
flag.Usage()
os.Exit(1)
}
var (
outFlag string
decryptFlag, armorFlag, passFlag bool
recipientFlags, identityFlags multiFlag
outFlag string
decryptFlag, armorFlag bool
passFlag, versionFlag bool
recipientFlags, identityFlags multiFlag
recipientsFileFlags multiFlag
)
flag.BoolVar(&versionFlag, "version", false, "print the version")
flag.BoolVar(&decryptFlag, "d", false, "decrypt the input")
flag.BoolVar(&decryptFlag, "decrypt", false, "decrypt the input")
flag.BoolVar(&passFlag, "p", false, "use a passphrase")
flag.BoolVar(&passFlag, "passphrase", false, "use a passphrase")
flag.StringVar(&outFlag, "o", "", "output to `FILE` (default stdout)")
flag.StringVar(&outFlag, "output", "", "output to `FILE` (default stdout)")
flag.BoolVar(&armorFlag, "a", false, "generate an armored file")
flag.BoolVar(&armorFlag, "armor", false, "generate an armored file")
flag.Var(&recipientFlags, "r", "recipient (can be repeated)")
flag.Var(&recipientFlags, "recipient", "recipient (can be repeated)")
flag.Var(&recipientsFileFlags, "R", "recipients file (can be repeated)")
flag.Var(&recipientsFileFlags, "recipients-file", "recipients file (can be repeated)")
flag.Var(&identityFlags, "i", "identity (can be repeated)")
flag.Var(&identityFlags, "identity", "identity (can be repeated)")
flag.Parse()
if versionFlag {
if Version != "" {
fmt.Println(Version)
return
}
if buildInfo, ok := debug.ReadBuildInfo(); ok {
fmt.Println(buildInfo.Main.Version)
return
}
fmt.Println("(unknown)")
return
}
if flag.NArg() > 1 {
logFatalf("Error: too many arguments.\n" +
"age accepts a single optional argument for the input file.")
@@ -95,21 +135,29 @@ func main() {
logFatalf("Error: -r/--recipient can't be used with -d/--decrypt.\n" +
"Did you mean to use -i/--identity to specify a private key?")
}
if len(recipientsFileFlags) > 0 {
logFatalf("Error: -R/--recipients-file can't be used with -d/--decrypt.\n" +
"Did you mean to use -i/--identity to specify a private key?")
}
default: // encrypt
if len(identityFlags) > 0 {
logFatalf("Error: -i/--identity can't be used in encryption mode.\n" +
"Did you forget to specify -d/--decrypt?")
}
if len(recipientFlags) == 0 && !passFlag {
if len(recipientFlags) == 0 && len(recipientsFileFlags) == 0 && !passFlag {
logFatalf("Error: missing recipients.\n" +
"Did you forget to specify -r/--recipient or -p/--passphrase?")
"Did you forget to specify -r/--recipient, -R/--recipients-file or -p/--passphrase?")
}
if len(recipientFlags) > 0 && passFlag {
logFatalf("Error: -p/--passphrase can't be combined with -r/--recipient.")
}
if len(recipientsFileFlags) > 0 && passFlag {
logFatalf("Error: -p/--passphrase can't be combined with -R/--recipients-file.")
}
}
var in, out io.ReadWriter = os.Stdin, os.Stdout
var in io.Reader = os.Stdin
var out io.Writer = os.Stdout
if name := flag.Arg(0); name != "" && name != "-" {
f, err := os.Open(name)
if err != nil {
@@ -121,24 +169,29 @@ func main() {
stdinInUse = true
}
if name := outFlag; name != "" && name != "-" {
f, err := os.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0666)
if err != nil {
logFatalf("Error: failed to open output file %q: %v", name, err)
if _, err := os.Stat(name); err == nil {
logFatalf("Error: output file %q exists", name)
}
f := newLazyOpener(name)
defer f.Close()
out = f
} else if terminal.IsTerminal(int(os.Stdout.Fd())) && !decryptFlag {
if armorFlag {
// If the output will go to a TTY, and it will be armored, buffer it
// up so it doesn't get in the way of typing the input.
} else if terminal.IsTerminal(int(os.Stdout.Fd())) {
if name != "-" {
if decryptFlag {
// TODO: buffer the output and check it's printable.
} else if !armorFlag {
// If the output wouldn't be armored, refuse to send binary to
// the terminal unless explicitly requested with "-o -".
logFatalf("Error: refusing to output binary to the terminal.\n" +
`Did you mean to use -a/--armor? Force with "-o -".`)
}
}
if in == os.Stdin && terminal.IsTerminal(int(os.Stdin.Fd())) {
// If the input comes from a TTY and output will go to a TTY,
// buffer it up so it doesn't get in the way of typing the input.
buf := &bytes.Buffer{}
defer func() { io.Copy(os.Stdout, buf) }()
out = buf
} else if name != "-" {
// If the output wouldn't be armored, refuse to send binary to the
// terminal unless explicitly requested with "-o -".
logFatalf("Error: refusing to output binary to the terminal.\n" +
`Did you mean to use -a/--armor? Force with "-o -".`)
}
}
@@ -146,26 +199,44 @@ func main() {
case decryptFlag:
decrypt(identityFlags, in, out)
case passFlag:
pass, err := passphrasePrompt()
pass, err := passphrasePromptForEncryption()
if err != nil {
logFatalf("Error: %v", err)
}
encryptPass(pass, in, out, armorFlag)
default:
encryptKeys(recipientFlags, in, out, armorFlag)
encryptKeys(recipientFlags, recipientsFileFlags, in, out, armorFlag)
}
}
func passphrasePrompt() (string, error) {
fmt.Fprintf(os.Stderr, "Enter passphrase: ")
func passphrasePromptForEncryption() (string, error) {
fmt.Fprintf(os.Stderr, "Enter passphrase (leave empty to autogenerate a secure one): ")
pass, err := readPassphrase()
if err != nil {
return "", fmt.Errorf("could not read passphrase: %v", err)
}
return string(pass), nil
p := string(pass)
if p == "" {
var words []string
for i := 0; i < 10; i++ {
words = append(words, randomWord())
}
p = strings.Join(words, "-")
fmt.Fprintf(os.Stderr, "Using the autogenerated passphrase %q.\n", p)
} else {
fmt.Fprintf(os.Stderr, "Confirm passphrase: ")
confirm, err := readPassphrase()
if err != nil {
return "", fmt.Errorf("could not read passphrase: %v", err)
}
if string(confirm) != p {
return "", fmt.Errorf("passphrases didn't match")
}
}
return p, nil
}
func encryptKeys(keys []string, in io.Reader, out io.Writer, armor bool) {
func encryptKeys(keys, files []string, in io.Reader, out io.Writer, armor bool) {
var recipients []age.Recipient
for _, arg := range keys {
r, err := parseRecipient(arg)
@@ -174,6 +245,13 @@ func encryptKeys(keys []string, in io.Reader, out io.Writer, armor bool) {
}
recipients = append(recipients, r)
}
for _, name := range files {
recs, err := parseRecipientsFile(name)
if err != nil {
logFatalf("Error: failed to parse recipient file %q: %v", name, err)
}
recipients = append(recipients, recs...)
}
encrypt(recipients, in, out, armor)
}
@@ -185,10 +263,16 @@ func encryptPass(pass string, in io.Reader, out io.Writer, armor bool) {
encrypt([]age.Recipient{r}, in, out, armor)
}
func encrypt(recipients []age.Recipient, in io.Reader, out io.Writer, armor bool) {
func encrypt(recipients []age.Recipient, in io.Reader, out io.Writer, withArmor bool) {
ageEncrypt := age.Encrypt
if armor {
ageEncrypt = age.EncryptWithArmor
if withArmor {
a := armor.NewWriter(out)
defer func() {
if err := a.Close(); err != nil {
logFatalf("Error: %v", err)
}
}()
out = a
}
w, err := ageEncrypt(out, recipients...)
if err != nil {
@@ -209,16 +293,23 @@ func decrypt(keys []string, in io.Reader, out io.Writer) {
&LazyScryptIdentity{passphrasePrompt},
}
// TODO: use the default location if no arguments are provided:
// os.UserConfigDir()/age/keys.txt, ~/.ssh/id_rsa, ~/.ssh/id_ed25519
// TODO: check the default SSH location if no arguments are provided
// (~/.ssh/id_rsa, ~/.ssh/id_ed25519).
for _, name := range keys {
ids, err := parseIdentitiesFile(name)
if err != nil {
logFatalf("Error: %v", err)
logFatalf("Error reading %q: %v", name, err)
}
identities = append(identities, ids...)
}
rr := bufio.NewReader(in)
if start, _ := rr.Peek(len(armor.Header)); string(start) == armor.Header {
in = armor.NewReader(rr)
} else {
in = rr
}
r, err := age.Decrypt(in, identities...)
if err != nil {
logFatalf("Error: %v", err)
@@ -228,6 +319,42 @@ func decrypt(keys []string, in io.Reader, out io.Writer) {
}
}
func passphrasePrompt() (string, error) {
fmt.Fprintf(os.Stderr, "Enter passphrase: ")
pass, err := readPassphrase()
if err != nil {
return "", fmt.Errorf("could not read passphrase: %v", err)
}
return string(pass), nil
}
type lazyOpener struct {
name string
f *os.File
err error
}
func newLazyOpener(name string) io.WriteCloser {
return &lazyOpener{name: name}
}
func (l *lazyOpener) Write(p []byte) (n int, err error) {
if l.f == nil && l.err == nil {
l.f, l.err = os.OpenFile(l.name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0666)
}
if l.err != nil {
return 0, l.err
}
return l.f.Write(p)
}
func (l *lazyOpener) Close() error {
if l.f != nil {
return l.f.Close()
}
return nil
}
func logFatalf(format string, v ...interface{}) {
_log.Printf(format, v...)
_log.Fatalf("[ Did age not do what you expected? Could an error be more useful?" +
+61
View File
@@ -0,0 +1,61 @@
// Copyright 2019 Google LLC
//
// 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 main
import (
"io/ioutil"
"os"
"path/filepath"
"strings"
"testing"
"filippo.io/age"
)
func TestVectors(t *testing.T) {
files, _ := filepath.Glob("testdata/*.age")
for _, f := range files {
_, name := filepath.Split(f)
name = strings.TrimSuffix(name, ".age")
expectFailure := strings.HasPrefix(name, "fail_")
t.Run(name, func(t *testing.T) {
var identities []age.Identity
ids, err := parseIdentitiesFile("testdata/" + name + "_key.txt")
if err == nil {
identities = append(identities, ids...)
}
password, err := ioutil.ReadFile("testdata/" + name + "_password.txt")
if err == nil {
i, err := age.NewScryptIdentity(string(password))
if err != nil {
t.Fatal(err)
}
identities = append(identities, i)
}
in, err := os.Open("testdata/" + name + ".age")
if err != nil {
t.Fatal(err)
}
r, err := age.Decrypt(in, identities...)
if expectFailure {
if err == nil {
t.Fatal("expected Decrypt failure")
}
} else {
if err != nil {
t.Fatal(err)
}
out, err := ioutil.ReadAll(r)
if err != nil {
t.Fatal(err)
}
t.Logf("%s", out)
}
})
}
}
+16 -84
View File
@@ -7,101 +7,24 @@
package main
import (
"crypto/ed25519"
"crypto/rsa"
"errors"
"fmt"
"os"
"filippo.io/age/internal/age"
"filippo.io/age/internal/format"
"golang.org/x/crypto/ssh"
"filippo.io/age"
"golang.org/x/crypto/ssh/terminal"
)
type EncryptedSSHIdentity struct {
pubKey ssh.PublicKey
pemBytes []byte
passphrase func() ([]byte, error)
decrypted age.Identity
}
func NewEncryptedSSHIdentity(pubKey ssh.PublicKey, pemBytes []byte, passphrase func() ([]byte, error)) (*EncryptedSSHIdentity, error) {
switch t := pubKey.Type(); t {
case "ssh-ed25519", "ssh-rsa":
default:
return nil, fmt.Errorf("unsupported SSH key type: %v", t)
}
return &EncryptedSSHIdentity{
pubKey: pubKey,
pemBytes: pemBytes,
passphrase: passphrase,
}, nil
}
var _ age.IdentityMatcher = &EncryptedSSHIdentity{}
func (i *EncryptedSSHIdentity) Type() string {
return i.pubKey.Type()
}
func (i *EncryptedSSHIdentity) Unwrap(block *format.Recipient) (fileKey []byte, err error) {
if i.decrypted != nil {
return i.decrypted.Unwrap(block)
}
passphrase, err := i.passphrase()
if err != nil {
return nil, fmt.Errorf("failed to obtain passphrase: %v", err)
}
k, err := ssh.ParseRawPrivateKeyWithPassphrase(i.pemBytes, passphrase)
if err != nil {
return nil, fmt.Errorf("failed to decrypt SSH key file: %v", err)
}
switch k := k.(type) {
case *ed25519.PrivateKey:
i.decrypted, err = age.NewSSHEd25519Identity(*k)
case *rsa.PrivateKey:
i.decrypted, err = age.NewSSHRSAIdentity(k)
default:
return nil, fmt.Errorf("unexpected SSH key type: %T", k)
}
if err != nil {
return nil, fmt.Errorf("invalid SSH key: %v", err)
}
if i.decrypted.Type() != i.pubKey.Type() {
return nil, fmt.Errorf("mismatched SSH key type: got %q, expected %q", i.decrypted.Type(), i.pubKey.Type())
}
return i.decrypted.Unwrap(block)
}
func (i *EncryptedSSHIdentity) Matches(block *format.Recipient) error {
if block.Type != i.Type() {
return age.ErrIncorrectIdentity
}
if len(block.Args) < 1 {
return fmt.Errorf("invalid %v recipient block", i.Type())
}
if block.Args[0] != age.SSHFingerprint(i.pubKey) {
return age.ErrIncorrectIdentity
}
return nil
}
type LazyScryptIdentity struct {
Passphrase func() (string, error)
}
var _ age.Identity = &LazyScryptIdentity{}
func (i *LazyScryptIdentity) Type() string {
return "scrypt"
}
func (i *LazyScryptIdentity) Unwrap(block *format.Recipient) (fileKey []byte, err error) {
func (i *LazyScryptIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) {
if len(stanzas) != 1 || stanzas[0].Type != "scrypt" {
return nil, age.ErrIncorrectIdentity
}
pass, err := i.Passphrase()
if err != nil {
return nil, fmt.Errorf("could not read passphrase: %v", err)
@@ -110,7 +33,16 @@ func (i *LazyScryptIdentity) Unwrap(block *format.Recipient) (fileKey []byte, er
if err != nil {
return nil, err
}
return ii.Unwrap(block)
fileKey, err = ii.Unwrap(stanzas)
if errors.Is(err, age.ErrIncorrectIdentity) {
// ScryptIdentity returns ErrIncorrectIdentity for an incorrect
// passphrase, which would lead Decrypt to returning "no identity
// matched any recipient". That makes sense in the API, where there
// might be multiple configured ScryptIdentity. Since in cmd/age there
// can be only one, return a better error message.
return nil, fmt.Errorf("incorrect passphrase")
}
return fileKey, err
}
// stdinInUse is set in main. It's a singleton like os.Stdin.
+110 -39
View File
@@ -8,14 +8,17 @@ package main
import (
"bufio"
"bytes"
"encoding/base64"
"fmt"
"io"
"io/ioutil"
"log"
"os"
"strings"
"filippo.io/age/internal/age"
"filippo.io/age"
"filippo.io/age/agessh"
"golang.org/x/crypto/cryptobyte"
"golang.org/x/crypto/ssh"
)
@@ -24,66 +27,129 @@ func parseRecipient(arg string) (age.Recipient, error) {
case strings.HasPrefix(arg, "age1"):
return age.ParseX25519Recipient(arg)
case strings.HasPrefix(arg, "ssh-"):
return age.ParseSSHRecipient(arg)
return agessh.ParseRecipient(arg)
}
return nil, fmt.Errorf("unknown recipient type: %q", arg)
}
const privateKeySizeLimit = 1 << 24 // 16 MiB
func parseIdentitiesFile(name string) ([]age.Identity, error) {
f, err := os.Open(name)
if err != nil {
return nil, fmt.Errorf("failed to open file: %v", err)
}
defer f.Close()
contents, err := ioutil.ReadAll(io.LimitReader(f, privateKeySizeLimit))
if err != nil {
return nil, fmt.Errorf("failed to read %q: %v", name, err)
}
if len(contents) == privateKeySizeLimit {
return nil, fmt.Errorf("failed to read %q: file too long", name)
func parseRecipientsFile(name string) ([]age.Recipient, error) {
var f *os.File
if name == "-" {
if stdinInUse {
return nil, fmt.Errorf("standard input is used for multiple purposes")
}
stdinInUse = true
f = os.Stdin
} else {
var err error
f, err = os.Open(name)
if err != nil {
return nil, fmt.Errorf("failed to open recipient file: %v", err)
}
defer f.Close()
}
var ids []age.Identity
var ageParsingError error
scanner := bufio.NewScanner(bytes.NewReader(contents))
const recipientFileSizeLimit = 16 << 20 // 16 MiB
const lineLengthLimit = 8 << 10 // 8 KiB, same as sshd(8)
var recs []age.Recipient
scanner := bufio.NewScanner(io.LimitReader(f, recipientFileSizeLimit))
var n int
for scanner.Scan() {
n++
line := scanner.Text()
if strings.HasPrefix(line, "#") || line == "" {
continue
}
if strings.HasPrefix(line, "-----BEGIN") {
return parseSSHIdentity(name, contents)
if len(line) > lineLengthLimit {
return nil, fmt.Errorf("%q: line %d is too long", name, n)
}
if ageParsingError != nil {
continue
}
i, err := age.ParseX25519Identity(line)
r, err := parseRecipient(line)
if err != nil {
ageParsingError = fmt.Errorf("malformed secret keys file %q: %v", name, err)
continue
if t, ok := sshKeyType(line); ok {
// Skip unsupported but valid SSH public keys with a warning.
log.Printf("Warning: recipients file %q: ignoring unsupported SSH key of type %q at line %d", name, t, n)
continue
}
// Hide the error since it might unintentionally leak the contents
// of confidential files.
return nil, fmt.Errorf("%q: malformed recipient at line %d", name, n)
}
ids = append(ids, i)
recs = append(recs, r)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read %q: %v", name, err)
return nil, fmt.Errorf("%q: failed to read recipients file: %v", name, err)
}
if ageParsingError != nil {
return nil, ageParsingError
if len(recs) == 0 {
return nil, fmt.Errorf("%q: no recipients found", name)
}
return recs, nil
}
func sshKeyType(s string) (string, bool) {
// TODO: also ignore options? And maybe support multiple spaces and tabs as
// field separators like OpenSSH?
fields := strings.Split(s, " ")
if len(fields) < 2 {
return "", false
}
key, err := base64.StdEncoding.DecodeString(fields[1])
if err != nil {
return "", false
}
k := cryptobyte.String(key)
var typeLen uint32
var typeBytes []byte
if !k.ReadUint32(&typeLen) || !k.ReadBytes(&typeBytes, int(typeLen)) {
return "", false
}
if t := fields[0]; t == string(typeBytes) {
return t, true
}
return "", false
}
func parseIdentitiesFile(name string) ([]age.Identity, error) {
var f *os.File
if name == "-" {
if stdinInUse {
return nil, fmt.Errorf("standard input is used for multiple purposes")
}
stdinInUse = true
f = os.Stdin
} else {
var err error
f, err = os.Open(name)
if err != nil {
return nil, fmt.Errorf("failed to open file: %v", err)
}
defer f.Close()
}
if len(ids) == 0 {
return nil, fmt.Errorf("no secret keys found in %q", name)
b := bufio.NewReader(f)
const pemHeader = "-----BEGIN"
if peeked, _ := b.Peek(len(pemHeader)); string(peeked) == pemHeader {
const privateKeySizeLimit = 1 << 14 // 16 KiB
contents, err := ioutil.ReadAll(io.LimitReader(b, privateKeySizeLimit))
if err != nil {
return nil, fmt.Errorf("failed to read %q: %v", name, err)
}
if len(contents) == privateKeySizeLimit {
return nil, fmt.Errorf("failed to read %q: file too long", name)
}
return parseSSHIdentity(name, contents)
}
ids, err := age.ParseIdentities(b)
if err != nil {
return nil, fmt.Errorf("failed to read %q: %v", name, err)
}
return ids, nil
}
func parseSSHIdentity(name string, pemBytes []byte) ([]age.Identity, error) {
id, err := age.ParseSSHIdentity(pemBytes)
if sshErr, ok := err.(*ssh.PassphraseNeededError); ok {
id, err := agessh.ParseIdentity(pemBytes)
if sshErr, ok := err.(*ssh.PassphraseMissingError); ok {
pubKey := sshErr.PublicKey
if pubKey == nil {
pubKey, err = readPubFile(name)
@@ -99,7 +165,7 @@ func parseSSHIdentity(name string, pemBytes []byte) ([]age.Identity, error) {
}
return pass, nil
}
i, err := NewEncryptedSSHIdentity(pubKey, pemBytes, passphrasePrompt)
i, err := agessh.NewEncryptedSSHIdentity(pubKey, pemBytes, passphrasePrompt)
if err != nil {
return nil, err
}
@@ -113,11 +179,16 @@ func parseSSHIdentity(name string, pemBytes []byte) ([]age.Identity, error) {
}
func readPubFile(name string) (ssh.PublicKey, error) {
if name == "-" {
return nil, fmt.Errorf(`failed to obtain public key for "-" SSH key
Use a file for which the corresponding ".pub" file exists, or convert the private key to a modern format with "ssh-keygen -p -m RFC4716"`)
}
f, err := os.Open(name + ".pub")
if err != nil {
return nil, fmt.Errorf(`failed to obtain public key for %q SSH key: %v
Ensure %q exists, or convert the private key %q to a modern format with "ssh-keygen -p -m RFC4716"`, name, err, name+".pub", name)
Ensure %q exists, or convert the private key %q to a modern format with "ssh-keygen -p -m RFC4716"`, name, err, name+".pub", name)
}
defer f.Close()
contents, err := ioutil.ReadAll(f)
+7
View File
@@ -0,0 +1,7 @@
age-encryption.org/v1
-> X25519 alRneDshIh43nwyD5+fhuTD5TReSn88f2us4hzZPyzU
pGduNK5MUhnuzMxW0qbZnC2k7mRzz69bbJpKQrRc7uc
-> A7)h-grease !,_
--- 5bA0uXjBxI6wuI5SseCRgD5/G8LkSVISRe/hnrQMb9s
¦Ã 1‘·¬Žä6_RÙäÚ…›…U<ï1ús®ÿ?`Ö+–’$§HÖW‚v?w8ZW
+1
View File
@@ -0,0 +1 @@
AGE-SECRET-KEY-1TRYTV7PQS5XPUYSTAQZCD7DQCWC7Q77YJD7UVFJRMW4J82Q6930QS70MRX
+6
View File
@@ -0,0 +1,6 @@
age-encryption.org/v1
-> scrypt qeKad+OgIkBbr/ndSa7J3Q 1
C2tmV7/uZjRafxqaQd1JhYkM2KxuHHBy3/d2dJNEZEh8rZCqYfvE/eJUXqiqZsZa
6kWgG1qa6Q6sXPz0vIIpYHGf4gzxG9oTVonMke2kHC4
--- FQeacPQobvFBd0tuIQnQDd/NEDR4G4MfylkXiq9ZqZ0
Ãù€pÛÂt€t�˜Ë3qòÆ)¬ÌÈŒv´¨QÆoÌšK÷¶7ÜØ)ø%a
@@ -0,0 +1 @@
dog-old-little-breeze-novel-razor-battle-replace-lake-horse
+6
View File
@@ -0,0 +1,6 @@
age-encryption.org/v1
-> X25519 /Gt0E6JT7yuYHlwsGW5LbpEEJawOc+QMeMAS+hoOIgw
XU/4Zkz4MksDhge0kosiMTJF8tHnOP0ZSi+6aaMqLMS1PlMIs95nKz3H7JGesTwA
tsxuQrj+TuoGouNB1O0VshA9vsHGurn0Dtw5e7bkw9Q
--- jQNSF6blozj2QFYJ/2iqy0wUcPuz/8vCS7RgKH8wjNI
Þ9­¶‡yË_£§R\¯ÿm\¥û©Uv6Qȶ»ñmK»a¢©êv®©˜2
+3
View File
@@ -0,0 +1,3 @@
# created: 2020-09-19T18:42:11+02:00
# public key: age1uc8zlurjyjpenrslc2thyl28u7ylz6x8c2g9yphvjha6xm8ppf3slq0l25
AGE-SECRET-KEY-1D8JAD8SXNFVQEFHAUNNAX4QCE3K5CUKMT7YYHNGTUSSP97YGWL4STV89UH
+26
View File
@@ -0,0 +1,26 @@
// Copyright 2019 Google LLC
//
// 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 main
import (
"crypto/rand"
"encoding/binary"
"strings"
)
func randomWord() string {
buf := make([]byte, 2)
if _, err := rand.Read(buf); err != nil {
panic(err)
}
n := binary.BigEndian.Uint16(buf)
return wordlist[int(n)%2048]
}
// wordlist is the BIP39 list of 2048 english words, and it's used to generate
// the suggested passphrases.
var wordlist = strings.Split(`abandon ability able about above absent absorb abstract absurd abuse access accident account accuse achieve acid acoustic acquire across act action actor actress actual adapt add addict address adjust admit adult advance advice aerobic affair afford afraid again age agent agree ahead aim air airport aisle alarm album alcohol alert alien all alley allow almost alone alpha already also alter always amateur amazing among amount amused analyst anchor ancient anger angle angry animal ankle announce annual another answer antenna antique anxiety any apart apology appear apple approve april arch arctic area arena argue arm armed armor army around arrange arrest arrive arrow art artefact artist artwork ask aspect assault asset assist assume asthma athlete atom attack attend attitude attract auction audit august aunt author auto autumn average avocado avoid awake aware away awesome awful awkward axis baby bachelor bacon badge bag balance balcony ball bamboo banana banner bar barely bargain barrel base basic basket battle beach bean beauty because become beef before begin behave behind believe below belt bench benefit best betray better between beyond bicycle bid bike bind biology bird birth bitter black blade blame blanket blast bleak bless blind blood blossom blouse blue blur blush board boat body boil bomb bone bonus book boost border boring borrow boss bottom bounce box boy bracket brain brand brass brave bread breeze brick bridge brief bright bring brisk broccoli broken bronze broom brother brown brush bubble buddy budget buffalo build bulb bulk bullet bundle bunker burden burger burst bus business busy butter buyer buzz cabbage cabin cable cactus cage cake call calm camera camp can canal cancel candy cannon canoe canvas canyon capable capital captain car carbon card cargo carpet carry cart case cash casino castle casual cat catalog catch category cattle caught cause caution cave ceiling celery cement census century cereal certain chair chalk champion change chaos chapter charge chase chat cheap check cheese chef cherry chest chicken chief child chimney choice choose chronic chuckle chunk churn cigar cinnamon circle citizen city civil claim clap clarify claw clay clean clerk clever click client cliff climb clinic clip clock clog close cloth cloud clown club clump cluster clutch coach coast coconut code coffee coil coin collect color column combine come comfort comic common company concert conduct confirm congress connect consider control convince cook cool copper copy coral core corn correct cost cotton couch country couple course cousin cover coyote crack cradle craft cram crane crash crater crawl crazy cream credit creek crew cricket crime crisp critic crop cross crouch crowd crucial cruel cruise crumble crunch crush cry crystal cube culture cup cupboard curious current curtain curve cushion custom cute cycle dad damage damp dance danger daring dash daughter dawn day deal debate debris decade december decide decline decorate decrease deer defense define defy degree delay deliver demand demise denial dentist deny depart depend deposit depth deputy derive describe desert design desk despair destroy detail detect develop device devote diagram dial diamond diary dice diesel diet differ digital dignity dilemma dinner dinosaur direct dirt disagree discover disease dish dismiss disorder display distance divert divide divorce dizzy doctor document dog doll dolphin domain donate donkey donor door dose double dove draft dragon drama drastic draw dream dress drift drill drink drip drive drop drum dry duck dumb dune during dust dutch duty dwarf dynamic eager eagle early earn earth easily east easy echo ecology economy edge edit educate effort egg eight either elbow elder electric elegant element elephant elevator elite else embark embody embrace emerge emotion employ empower empty enable enact end endless endorse enemy energy enforce engage engine enhance enjoy enlist enough enrich enroll ensure enter entire entry envelope episode equal equip era erase erode erosion error erupt escape essay essence estate eternal ethics evidence evil evoke evolve exact example excess exchange excite exclude excuse execute exercise exhaust exhibit exile exist exit exotic expand expect expire explain expose express extend extra eye eyebrow fabric face faculty fade faint faith fall false fame family famous fan fancy fantasy farm fashion fat fatal father fatigue fault favorite feature february federal fee feed feel female fence festival fetch fever few fiber fiction field figure file film filter final find fine finger finish fire firm first fiscal fish fit fitness fix flag flame flash flat flavor flee flight flip float flock floor flower fluid flush fly foam focus fog foil fold follow food foot force forest forget fork fortune forum forward fossil foster found fox fragile frame frequent fresh friend fringe frog front frost frown frozen fruit fuel fun funny furnace fury future gadget gain galaxy gallery game gap gLine truncated
+5 -3
View File
@@ -2,6 +2,8 @@ module filippo.io/age
go 1.13
require golang.org/x/crypto v0.0.0-20191002192127-34f69633bfdc
replace golang.org/x/crypto => github.com/Filosottile/go v0.0.0-20191122011136-9090b284250b
require (
filippo.io/edwards25519 v1.0.0-alpha.2
github.com/sergi/go-diff v1.1.0
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59
)
+27 -2
View File
@@ -1,6 +1,31 @@
github.com/Filosottile/go v0.0.0-20191122011136-9090b284250b h1:4AVIiSN9FRvfh7Oq7NhMHoU4oDhNkpfq4q9prQNlq7k=
github.com/Filosottile/go v0.0.0-20191122011136-9090b284250b/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
filippo.io/edwards25519 v1.0.0-alpha.2 h1:EWbZLqGEPSIj2W69gx04KtNVkyPIfe3uj0DhDQJonbQ=
filippo.io/edwards25519 v1.0.0-alpha.2/go.mod h1:X+pm78QAUPtFLi1z9PYIlS/bdDnvbCOGKtZ+ACWEf7o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59 h1:3zb4D3T4G8jdExgVU/95+vQXfpEPiMdCaZgmGVxjNHM=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-154
View File
@@ -1,154 +0,0 @@
// Copyright 2019 Google LLC
//
// 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 age implements file encryption according to age-encryption.org/v1.
package age
import (
"crypto/hmac"
"crypto/rand"
"errors"
"fmt"
"io"
"filippo.io/age/internal/format"
"filippo.io/age/internal/stream"
)
type Identity interface {
Type() string
Unwrap(block *format.Recipient) (fileKey []byte, err error)
}
type IdentityMatcher interface {
Identity
Matches(block *format.Recipient) error
}
var ErrIncorrectIdentity = errors.New("incorrect identity for recipient block")
type Recipient interface {
Type() string
Wrap(fileKey []byte) (*format.Recipient, error)
}
func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) {
// stream.Writer takes a WriteCloser, and will propagate Close calls (so
// that the ArmoredWriter will get closed), but we don't want to expose
// that behavior to our caller.
dstCloser := format.NopCloser(dst)
return encrypt(dstCloser, recipients...)
}
func EncryptWithArmor(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) {
dstCloser := format.ArmoredWriter(dst)
return encrypt(dstCloser, recipients...)
}
func encrypt(dst io.WriteCloser, recipients ...Recipient) (io.WriteCloser, error) {
if len(recipients) == 0 {
return nil, errors.New("no recipients specified")
}
fileKey := make([]byte, 16)
if _, err := rand.Read(fileKey); err != nil {
return nil, err
}
hdr := &format.Header{}
for i, r := range recipients {
if r.Type() == "scrypt" && len(recipients) != 1 {
return nil, errors.New("an scrypt recipient must be the only one")
}
block, err := r.Wrap(fileKey)
if err != nil {
return nil, fmt.Errorf("failed to wrap key for recipient #%d: %v", i, err)
}
hdr.Recipients = append(hdr.Recipients, block)
}
if mac, err := headerMAC(fileKey, hdr); err != nil {
return nil, fmt.Errorf("failed to compute header MAC: %v", err)
} else {
hdr.MAC = mac
}
if err := hdr.Marshal(dst); err != nil {
return nil, fmt.Errorf("failed to write header: %v", err)
}
nonce := make([]byte, 16)
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
if _, err := dst.Write(nonce); err != nil {
return nil, fmt.Errorf("failed to write nonce: %v", err)
}
return stream.NewWriter(streamKey(fileKey, nonce), dst)
}
func Decrypt(src io.Reader, identities ...Identity) (io.Reader, error) {
if len(identities) == 0 {
return nil, errors.New("no identities specified")
}
hdr, payload, err := format.Parse(src)
if err != nil {
return nil, fmt.Errorf("failed to read header: %v", err)
}
if len(hdr.Recipients) > 20 {
return nil, errors.New("too many recipients")
}
var fileKey []byte
RecipientsLoop:
for _, r := range hdr.Recipients {
if r.Type == "scrypt" && len(hdr.Recipients) != 1 {
return nil, errors.New("an scrypt recipient must be the only one")
}
for _, i := range identities {
if i.Type() != r.Type {
continue
}
if i, ok := i.(IdentityMatcher); ok {
err := i.Matches(r)
if err != nil {
if err == ErrIncorrectIdentity {
continue
}
return nil, err
}
}
fileKey, err = i.Unwrap(r)
if err != nil {
if err == ErrIncorrectIdentity {
continue
}
return nil, err
}
break RecipientsLoop
}
}
if fileKey == nil {
return nil, errors.New("no identity matched a recipient")
}
if mac, err := headerMAC(fileKey, hdr); err != nil {
return nil, fmt.Errorf("failed to compute header MAC: %v", err)
} else if !hmac.Equal(mac, hdr.MAC) {
return nil, errors.New("bad header MAC")
}
nonce := make([]byte, 16)
if _, err := io.ReadFull(payload, nonce); err != nil {
return nil, fmt.Errorf("failed to read nonce: %v", err)
}
return stream.NewReader(streamKey(fileKey, nonce), payload)
}
-110
View File
@@ -1,110 +0,0 @@
// Copyright 2019 Google LLC
//
// 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 age_test
import (
"bytes"
"crypto/rand"
"io"
"io/ioutil"
"testing"
"filippo.io/age/internal/age"
"golang.org/x/crypto/curve25519"
)
const helloWorld = "Hello, Twitch!"
func TestEncryptDecryptX25519(t *testing.T) {
secretKeyA := make([]byte, curve25519.ScalarSize)
secretKeyB := make([]byte, curve25519.ScalarSize)
if _, err := rand.Read(secretKeyA); err != nil {
t.Fatal(err)
}
if _, err := rand.Read(secretKeyB); err != nil {
t.Fatal(err)
}
publicKeyA, _ := curve25519.X25519(secretKeyA, curve25519.Basepoint)
publicKeyB, _ := curve25519.X25519(secretKeyB, curve25519.Basepoint)
rA, err := age.NewX25519Recipient(publicKeyA)
if err != nil {
t.Fatal(err)
}
rB, err := age.NewX25519Recipient(publicKeyB)
if err != nil {
t.Fatal(err)
}
buf := &bytes.Buffer{}
w, err := age.Encrypt(buf, rA, rB)
if err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(w, helloWorld); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
t.Logf("%s", buf.Bytes())
i, err := age.NewX25519Identity(secretKeyB)
if err != nil {
t.Fatal(err)
}
out, err := age.Decrypt(buf, i)
if err != nil {
t.Fatal(err)
}
outBytes, err := ioutil.ReadAll(out)
if err != nil {
t.Fatal(err)
}
if string(outBytes) != helloWorld {
t.Errorf("wrong data: %q, excepted %q", outBytes, helloWorld)
}
}
func TestEncryptDecryptScrypt(t *testing.T) {
password := "twitch.tv/filosottile"
r, err := age.NewScryptRecipient(password)
if err != nil {
t.Fatal(err)
}
r.SetWorkFactor(15)
buf := &bytes.Buffer{}
w, err := age.Encrypt(buf, r)
if err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(w, helloWorld); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
t.Logf("%s", buf.Bytes())
i, err := age.NewScryptIdentity(password)
if err != nil {
t.Fatal(err)
}
out, err := age.Decrypt(buf, i)
if err != nil {
t.Fatal(err)
}
outBytes, err := ioutil.ReadAll(out)
if err != nil {
t.Fatal(err)
}
if string(outBytes) != helloWorld {
t.Errorf("wrong data: %q, excepted %q", outBytes, helloWorld)
}
}
-201
View File
@@ -1,201 +0,0 @@
// Copyright 2019 Google LLC
//
// 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 age_test
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"testing"
"filippo.io/age/internal/age"
"golang.org/x/crypto/curve25519"
"golang.org/x/crypto/ssh"
)
func TestX25519RoundTrip(t *testing.T) {
secretKey := make([]byte, curve25519.ScalarSize)
if _, err := rand.Read(secretKey); err != nil {
t.Fatal(err)
}
publicKey, _ := curve25519.X25519(secretKey, curve25519.Basepoint)
r, err := age.NewX25519Recipient(publicKey)
if err != nil {
t.Fatal(err)
}
i, err := age.NewX25519Identity(secretKey)
if err != nil {
t.Fatal(err)
}
if r.Type() != i.Type() || r.Type() != "X25519" {
t.Errorf("invalid Type values: %v, %v", r.Type(), i.Type())
}
if r1, err := age.ParseX25519Recipient(r.String()); err != nil {
t.Fatal(err)
} else if r1.String() != r.String() {
t.Errorf("recipient did not round-trip through parsing: got %q, want %q", r1, r)
}
if i1, err := age.ParseX25519Identity(i.String()); err != nil {
t.Fatal(err)
} else if i1.String() != i.String() {
t.Errorf("identity did not round-trip through parsing: got %q, want %q", i1, i)
}
fileKey := make([]byte, 16)
if _, err := rand.Read(fileKey); err != nil {
t.Fatal(err)
}
block, err := r.Wrap(fileKey)
if err != nil {
t.Fatal(err)
}
b := &bytes.Buffer{}
block.Marshal(b)
t.Logf("%s", b.Bytes())
out, err := i.Unwrap(block)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(fileKey, out) {
t.Errorf("invalid output: %x, expected %x", out, fileKey)
}
}
func TestScryptRoundTrip(t *testing.T) {
password := "twitch.tv/filosottile"
r, err := age.NewScryptRecipient(password)
if err != nil {
t.Fatal(err)
}
r.SetWorkFactor(15)
i, err := age.NewScryptIdentity(password)
if err != nil {
t.Fatal(err)
}
if r.Type() != i.Type() || r.Type() != "scrypt" {
t.Errorf("invalid Type values: %v, %v", r.Type(), i.Type())
}
fileKey := make([]byte, 16)
if _, err := rand.Read(fileKey); err != nil {
t.Fatal(err)
}
block, err := r.Wrap(fileKey)
if err != nil {
t.Fatal(err)
}
b := &bytes.Buffer{}
block.Marshal(b)
t.Logf("%s", b.Bytes())
out, err := i.Unwrap(block)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(fileKey, out) {
t.Errorf("invalid output: %x, expected %x", out, fileKey)
}
}
func TestSSHRSARoundTrip(t *testing.T) {
pk, err := rsa.GenerateKey(rand.Reader, 768)
if err != nil {
t.Fatal(err)
}
pub, err := ssh.NewPublicKey(&pk.PublicKey)
if err != nil {
t.Fatal(err)
}
r, err := age.NewSSHRSARecipient(pub)
if err != nil {
t.Fatal(err)
}
i, err := age.NewSSHRSAIdentity(pk)
if err != nil {
t.Fatal(err)
}
if r.Type() != i.Type() || r.Type() != "ssh-rsa" {
t.Errorf("invalid Type values: %v, %v", r.Type(), i.Type())
}
fileKey := make([]byte, 16)
if _, err := rand.Read(fileKey); err != nil {
t.Fatal(err)
}
block, err := r.Wrap(fileKey)
if err != nil {
t.Fatal(err)
}
b := &bytes.Buffer{}
block.Marshal(b)
t.Logf("%s", b.Bytes())
out, err := i.Unwrap(block)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(fileKey, out) {
t.Errorf("invalid output: %x, expected %x", out, fileKey)
}
}
func TestSSHEd25519RoundTrip(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
sshPubKey, err := ssh.NewPublicKey(pub)
if err != nil {
t.Fatal(err)
}
r, err := age.NewSSHEd25519Recipient(sshPubKey)
if err != nil {
t.Fatal(err)
}
i, err := age.NewSSHEd25519Identity(priv)
if err != nil {
t.Fatal(err)
}
if r.Type() != i.Type() || r.Type() != "ssh-ed25519" {
t.Errorf("invalid Type values: %v, %v", r.Type(), i.Type())
}
fileKey := make([]byte, 16)
if _, err := rand.Read(fileKey); err != nil {
t.Fatal(err)
}
block, err := r.Wrap(fileKey)
if err != nil {
t.Fatal(err)
}
b := &bytes.Buffer{}
block.Marshal(b)
t.Logf("%s", b.Bytes())
out, err := i.Unwrap(block)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(fileKey, out) {
t.Errorf("invalid output: %x, expected %x", out, fileKey)
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ func TestBech32(t *testing.T) {
{"split1a2y9w", false}, // too short data part
{"1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", false}, // empty hrp
// invalid character (DEL) in hrp
{"spl" + string(127) + "t1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", false},
{"spl" + string(rune(127)) + "t1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", false},
// too long
{"11qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqc8247j", false},
+21
View File
@@ -0,0 +1,21 @@
# Copyright 2019 Google LLC
#
# 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
# docker run --rm -v $PWD/workdir:/workdir $(docker build -q -f internal/format/Dockerfile.go-fuzz .)
FROM golang:1.14-alpine3.11
RUN apk add --no-cache git
RUN go get github.com/dvyukov/go-fuzz/...
ADD . $GOPATH/src/filippo.io/age/
WORKDIR $GOPATH/src/filippo.io/age
RUN go-fuzz-build ./internal/format
VOLUME /workdir
ENTRYPOINT ["go-fuzz", "-workdir", "/workdir", "-bin", "format-fuzz.zip"]
-45
View File
@@ -1,45 +0,0 @@
// Copyright 2019 Google LLC
//
// 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 format_test
import (
"bytes"
"encoding/pem"
"io/ioutil"
"testing"
"filippo.io/age/internal/format"
)
func TestArmor(t *testing.T) {
buf := &bytes.Buffer{}
w := format.ArmoredWriter(buf)
plain := make([]byte, 611)
if _, err := w.Write(plain); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
block, _ := pem.Decode(buf.Bytes())
if block == nil {
t.Fatal("PEM decoding failed")
}
if !bytes.Equal(block.Bytes, plain) {
t.Error("PEM decoded value doesn't match")
}
r := format.ArmoredReader(buf)
out, err := ioutil.ReadAll(r)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(out, plain) {
t.Error("decoded value doesn't match")
}
}
+81 -19
View File
@@ -18,11 +18,13 @@ import (
)
type Header struct {
Recipients []*Recipient
Recipients []*Stanza
MAC []byte
}
type Recipient struct {
// Stanza is assignable to age.Stanza, and if this package is made public,
// age.Stanza can be made a type alias of this type.
type Stanza struct {
Type string
Args []string
Body []byte
@@ -40,15 +42,54 @@ func DecodeString(s string) ([]byte, error) {
var EncodeToString = b64.EncodeToString
const columnsPerLine = 64
const bytesPerLine = columnsPerLine / 4 * 3
const ColumnsPerLine = 64
const BytesPerLine = ColumnsPerLine / 4 * 3
// NewlineWriter returns a Writer that writes to dst, inserting an LF character
// every ColumnsPerLine bytes. It does not insert a newline neither at the
// beginning nor at the end of the stream, but it ensures the last line is
// shorter than ColumnsPerLine, which means it might be empty.
func NewlineWriter(dst io.Writer) io.Writer {
return &newlineWriter{dst: dst}
}
type newlineWriter struct {
dst io.Writer
written int
buf bytes.Buffer
}
func (w *newlineWriter) Write(p []byte) (int, error) {
if w.buf.Len() != 0 {
panic("age: internal error: non-empty newlineWriter.buf")
}
for len(p) > 0 {
toWrite := ColumnsPerLine - (w.written % ColumnsPerLine)
if toWrite > len(p) {
toWrite = len(p)
}
n, _ := w.buf.Write(p[:toWrite])
w.written += n
p = p[n:]
if w.written%ColumnsPerLine == 0 {
w.buf.Write([]byte("\n"))
}
}
if _, err := w.buf.WriteTo(w.dst); err != nil {
// We always return n = 0 on error because it's hard to work back to the
// input length that ended up written out. Not ideal, but Write errors
// are not recoverable anyway.
return 0, err
}
return len(p), nil
}
const intro = "age-encryption.org/v1\n"
var recipientPrefix = []byte("->")
var footerPrefix = []byte("---")
func (r *Recipient) Marshal(w io.Writer) error {
func (r *Stanza) Marshal(w io.Writer) error {
if _, err := w.Write(recipientPrefix); err != nil {
return err
}
@@ -60,7 +101,7 @@ func (r *Recipient) Marshal(w io.Writer) error {
if _, err := io.WriteString(w, "\n"); err != nil {
return err
}
ww := base64.NewEncoder(b64, &newlineWriter{dst: w})
ww := base64.NewEncoder(b64, NewlineWriter(w))
if _, err := ww.Write(r.Body); err != nil {
return err
}
@@ -109,13 +150,6 @@ func Parse(input io.Reader) (*Header, io.Reader, error) {
h := &Header{}
rr := bufio.NewReader(input)
// TODO: find a way to communicate to the caller that the file was armored,
// as they might not appreciate the malleability.
if start, _ := rr.Peek(len(armorPreamble)); string(start) == armorPreamble {
input = ArmoredReader(rr)
rr = bufio.NewReader(input)
}
line, err := rr.ReadString('\n')
if err != nil {
return nil, nil, errorf("failed to read intro: %v", err)
@@ -124,7 +158,7 @@ func Parse(input io.Reader) (*Header, io.Reader, error) {
return nil, nil, errorf("unexpected intro: %q", line)
}
var r *Recipient
var r *Stanza
for {
line, err := rr.ReadBytes('\n')
if err != nil {
@@ -132,6 +166,9 @@ func Parse(input io.Reader) (*Header, io.Reader, error) {
}
if bytes.HasPrefix(line, footerPrefix) {
if r != nil {
return nil, nil, errorf("malformed body line %q: reached footer without previous stanza being closed\nNote: this might be a file encrypted with an old beta version of rage. Use rage to decrypt it.", line)
}
prefix, args := splitArgs(line)
if prefix != string(footerPrefix) || len(args) != 1 {
return nil, nil, errorf("malformed closing line: %q", line)
@@ -143,11 +180,19 @@ func Parse(input io.Reader) (*Header, io.Reader, error) {
break
} else if bytes.HasPrefix(line, recipientPrefix) {
r = &Recipient{}
if r != nil {
return nil, nil, errorf("malformed body line %q: new stanza started without previous stanza being closed\nNote: this might be a file encrypted with an old beta version of rage. Use rage to decrypt it.", line)
}
r = &Stanza{}
prefix, args := splitArgs(line)
if prefix != string(recipientPrefix) || len(args) < 1 {
return nil, nil, errorf("malformed recipient: %q", line)
}
for _, a := range args {
if !isValidString(a) {
return nil, nil, errorf("malformed recipient: %q", line)
}
}
r.Type = args[0]
r.Args = args[1:]
h.Recipients = append(h.Recipients, r)
@@ -157,11 +202,11 @@ func Parse(input io.Reader) (*Header, io.Reader, error) {
if err != nil {
return nil, nil, errorf("malformed body line %q: %v", line, err)
}
if len(b) > bytesPerLine {
if len(b) > BytesPerLine {
return nil, nil, errorf("malformed body line %q: too long", line)
}
r.Body = append(r.Body, b...)
if len(b) < bytesPerLine {
if len(b) < BytesPerLine {
// Only the last line of a body can be short.
r = nil
}
@@ -171,13 +216,18 @@ func Parse(input io.Reader) (*Header, io.Reader, error) {
}
}
// Unwind the bufio overread and return the unbuffered input.
// If input is a bufio.Reader, rr might be equal to input because
// bufio.NewReader short-circuits. In this case we can just return it (and
// we would end up reading the buffer twice if we prepended the peek below).
if rr == input {
return h, rr, nil
}
// Otherwise, unwind the bufio overread and return the unbuffered input.
buf, err := rr.Peek(rr.Buffered())
if err != nil {
return nil, nil, errorf("internal error: %v", err)
}
payload := io.MultiReader(bytes.NewReader(buf), input)
return h, payload, nil
}
@@ -186,3 +236,15 @@ func splitArgs(line []byte) (string, []string) {
parts := strings.Split(l, " ")
return parts[0], parts[1:]
}
func isValidString(s string) bool {
if len(s) == 0 {
return false
}
for _, c := range s {
if c < 33 || c > 126 {
return false
}
}
return true
}
+32 -7
View File
@@ -1,3 +1,9 @@
// Copyright 2019 Google LLC
//
// 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
// +build gofuzz
package format
@@ -6,10 +12,13 @@ import (
"bytes"
"fmt"
"io"
"os"
"strings"
"github.com/sergi/go-diff/diffmatchpatch"
)
func Fuzz(data []byte) int {
isArmored := bytes.HasPrefix(data, []byte("-----BEGIN AGE ENCRYPTED FILE-----"))
h, payload, err := Parse(bytes.NewReader(data))
if err != nil {
if h != nil {
@@ -21,14 +30,30 @@ func Fuzz(data []byte) int {
return 0
}
w := &bytes.Buffer{}
if err := h.Marshal(w); err != nil {
panic(err)
}
if _, err := io.Copy(w, payload); err != nil {
panic(err)
if isArmored {
w := ArmoredWriter(w)
if err := h.Marshal(w); err != nil {
panic(err)
}
if _, err := io.Copy(w, payload); err != nil {
if strings.Contains(err.Error(), "invalid armor") {
return 0
}
panic(err)
}
w.Close()
} else {
if err := h.Marshal(w); err != nil {
panic(err)
}
if _, err := io.Copy(w, payload); err != nil {
panic(err)
}
}
if !bytes.Equal(w.Bytes(), data) {
fmt.Fprintf(os.Stderr, "%s\n%q\n%q\n\n", w, data, w)
dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(string(data), string(w.Bytes()), false)
fmt.Println(dmp.DiffToDelta(diffs))
panic("Marshal output different from input")
}
return 1
+41
View File
@@ -0,0 +1,41 @@
// Copyright 2021 Google LLC
//
// 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 format_test
import (
"bytes"
"testing"
"filippo.io/age/internal/format"
)
func TestStanzaMarshal(t *testing.T) {
s := &format.Stanza{
Type: "test",
Args: []string{"1", "2", "3"},
Body: nil, // empty
}
buf := &bytes.Buffer{}
s.Marshal(buf)
if exp := "-> test 1 2 3\n\n"; buf.String() != exp {
t.Errorf("wrong empty stanza encoding: expected %q, got %q", exp, buf.String())
}
buf.Reset()
s.Body = []byte("AAA")
s.Marshal(buf)
if exp := "-> test 1 2 3\nQUFB\n"; buf.String() != exp {
t.Errorf("wrong normal stanza encoding: expected %q, got %q", exp, buf.String())
}
buf.Reset()
s.Body = bytes.Repeat([]byte("A"), format.BytesPerLine)
s.Marshal(buf)
if exp := "-> test 1 2 3\nQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB\n\n"; buf.String() != exp {
t.Errorf("wrong 64 columns stanza encoding: expected %q, got %q", exp, buf.String())
}
}
+10 -12
View File
@@ -74,7 +74,7 @@ func (r *Reader) Read(p []byte) (int, error) {
return n, nil
}
// readChunk reads the next chunk of ciphertext from r.c and makes in available
// readChunk reads the next chunk of ciphertext from r.src and makes it available
// in r.unread. last is true if the chunk was marked as the end of the message.
// readChunk must not be called again after returning a last chunk or an error.
func (r *Reader) readChunk() (last bool, err error) {
@@ -106,7 +106,7 @@ func (r *Reader) readChunk() (last bool, err error) {
out, err = r.a.Open(outBuf, r.nonce[:], in, nil)
}
if err != nil {
return false, err
return false, errors.New("failed to decrypt and authenticate payload chunk")
}
incNonce(&r.nonce)
@@ -132,14 +132,14 @@ func setLastChunkFlag(nonce *[chacha20poly1305.NonceSize]byte) {
type Writer struct {
a cipher.AEAD
dst io.WriteCloser
dst io.Writer
unwritten []byte // backed by buf
buf [encChunkSize]byte
nonce [chacha20poly1305.NonceSize]byte
err error
}
func NewWriter(key []byte, dst io.WriteCloser) (*Writer, error) {
func NewWriter(key []byte, dst io.Writer) (*Writer, error) {
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
@@ -178,21 +178,19 @@ func (w *Writer) Write(p []byte) (n int, err error) {
return total, nil
}
// Close will flush the last chunk and call the underlying
// WriteCloser's Close method.
// Close flushes the last chunk. It does not close the underlying Writer.
func (w *Writer) Close() error {
if w.err != nil {
return w.err
}
err := w.flushChunk(lastChunk)
if err != nil {
w.err = err
return err
w.err = w.flushChunk(lastChunk)
if w.err != nil {
return w.err
}
w.err = errors.New("stream.Writer is already closed")
return w.dst.Close()
w.err = errors.New("stream.Writer is already closed")
return nil
}
const (
+1 -18
View File
@@ -10,10 +10,8 @@ import (
"bytes"
"crypto/rand"
"fmt"
"io"
"testing"
"filippo.io/age/internal/format"
"filippo.io/age/internal/stream"
"golang.org/x/crypto/chacha20poly1305"
)
@@ -40,19 +38,7 @@ func testRoundTrip(t *testing.T, stepSize, length int) {
t.Fatal(err)
}
var closed bool
bufCloser := struct {
io.Writer
format.CloserFunc
}{
Writer: buf,
CloserFunc: func() error {
closed = true
return nil
},
}
w, err := stream.NewWriter(key, bufCloser)
w, err := stream.NewWriter(key, buf)
if err != nil {
t.Fatal(err)
}
@@ -84,9 +70,6 @@ func testRoundTrip(t *testing.T, stepSize, length int) {
if err := w.Close(); err != nil {
t.Error("Close returned an error:", err)
}
if !closed {
t.Error("(*stream.Writer).Close didn't close the underlying WriteCloser")
}
t.Logf("buffer size: %d", buf.Len())
+86
View File
@@ -0,0 +1,86 @@
// Copyright 2021 Google LLC
//
// 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 age
import (
"bufio"
"fmt"
"io"
"strings"
)
// ParseIdentities parses a file with one or more private key encodings, one per
// line. Empty lines and lines starting with "#" are ignored.
//
// This is the same syntax as the private key files accepted by the CLI, except
// the CLI also accepts SSH private keys, which are not recommended for the
// average application.
//
// Currently, all returned values are of type *X25519Identity, but different
// types might be returned in the future.
func ParseIdentities(f io.Reader) ([]Identity, error) {
const privateKeySizeLimit = 1 << 24 // 16 MiB
var ids []Identity
scanner := bufio.NewScanner(io.LimitReader(f, privateKeySizeLimit))
var n int
for scanner.Scan() {
n++
line := scanner.Text()
if strings.HasPrefix(line, "#") || line == "" {
continue
}
i, err := ParseX25519Identity(line)
if err != nil {
return nil, fmt.Errorf("error at line %d: %v", n, err)
}
ids = append(ids, i)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read secret keys file: %v", err)
}
if len(ids) == 0 {
return nil, fmt.Errorf("no secret keys found")
}
return ids, nil
}
// ParseRecipients parses a file with one or more public key encodings, one per
// line. Empty lines and lines starting with "#" are ignored.
//
// This is the same syntax as the recipients files accepted by the CLI, except
// the CLI also accepts SSH recipients, which are not recommended for the
// average application.
//
// Currently, all returned values are of type *X25519Recipient, but different
// types might be returned in the future.
func ParseRecipients(f io.Reader) ([]Recipient, error) {
const recipientFileSizeLimit = 1 << 24 // 16 MiB
var recs []Recipient
scanner := bufio.NewScanner(io.LimitReader(f, recipientFileSizeLimit))
var n int
for scanner.Scan() {
n++
line := scanner.Text()
if strings.HasPrefix(line, "#") || line == "" {
continue
}
r, err := ParseX25519Recipient(line)
if err != nil {
// Hide the error since it might unintentionally leak the contents
// of confidential files.
return nil, fmt.Errorf("malformed recipient at line %d", n)
}
recs = append(recs, r)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read recipients file: %v", err)
}
if len(recs) == 0 {
return nil, fmt.Errorf("no recipients found")
}
return recs, nil
}
+16 -1
View File
@@ -9,6 +9,7 @@ package age
import (
"crypto/hmac"
"crypto/sha256"
"fmt"
"io"
"filippo.io/age/internal/format"
@@ -16,20 +17,34 @@ import (
"golang.org/x/crypto/hkdf"
)
// aeadEncrypt encrypts a message with a one-time key.
func aeadEncrypt(key, plaintext []byte) ([]byte, error) {
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
// The nonce is fixed because this function is only used in places where the
// spec guarantees each key is only used once (by deriving it from values
// that include fresh randomness), allowing us to save the overhead.
// For the code that encrypts the actual payload, look at the
// filippo.io/age/internal/stream package.
nonce := make([]byte, chacha20poly1305.NonceSize)
return aead.Seal(nil, nonce, plaintext, nil), nil
}
func aeadDecrypt(key, ciphertext []byte) ([]byte, error) {
// aeadDecrypt decrypts a message of an expected fixed size.
//
// The message size is limited to mitigate multi-key attacks, where a ciphertext
// can be crafted that decrypts successfully under multiple keys. Short
// ciphertexts can only target two keys, which has limited impact.
func aeadDecrypt(key []byte, size int, ciphertext []byte) ([]byte, error) {
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
if len(ciphertext) != size+aead.Overhead() {
return nil, fmt.Errorf("encrypted message has unexpected length")
}
nonce := make([]byte, chacha20poly1305.NonceSize)
return aead.Open(nil, nonce, ciphertext, nil)
}
+84
View File
@@ -0,0 +1,84 @@
// Copyright 2019 Google LLC
//
// 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 age_test
import (
"bytes"
"crypto/rand"
"testing"
"filippo.io/age"
)
func TestX25519RoundTrip(t *testing.T) {
i, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
r := i.Recipient()
if r1, err := age.ParseX25519Recipient(r.String()); err != nil {
t.Fatal(err)
} else if r1.String() != r.String() {
t.Errorf("recipient did not round-trip through parsing: got %q, want %q", r1, r)
}
if i1, err := age.ParseX25519Identity(i.String()); err != nil {
t.Fatal(err)
} else if i1.String() != i.String() {
t.Errorf("identity did not round-trip through parsing: got %q, want %q", i1, i)
}
fileKey := make([]byte, 16)
if _, err := rand.Read(fileKey); err != nil {
t.Fatal(err)
}
stanzas, err := r.Wrap(fileKey)
if err != nil {
t.Fatal(err)
}
out, err := i.Unwrap(stanzas)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(fileKey, out) {
t.Errorf("invalid output: %x, expected %x", out, fileKey)
}
}
func TestScryptRoundTrip(t *testing.T) {
password := "twitch.tv/filosottile"
r, err := age.NewScryptRecipient(password)
if err != nil {
t.Fatal(err)
}
r.SetWorkFactor(15)
i, err := age.NewScryptIdentity(password)
if err != nil {
t.Fatal(err)
}
fileKey := make([]byte, 16)
if _, err := rand.Read(fileKey); err != nil {
t.Fatal(err)
}
stanzas, err := r.Wrap(fileKey)
if err != nil {
t.Fatal(err)
}
out, err := i.Unwrap(stanzas)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(fileKey, out) {
t.Errorf("invalid output: %x, expected %x", out, fileKey)
}
}
+41 -14
View File
@@ -19,6 +19,15 @@ import (
const scryptLabel = "age-encryption.org/v1/scrypt"
// ScryptRecipient is a password-based recipient. Anyone with the password can
// decrypt the message.
//
// If a ScryptRecipient is used, it must be the only recipient for the file: it
// can't be mixed with other recipient types and can't be used multiple times
// for the same file.
//
// Its use is not recommended for automated systems, which should prefer
// X25519Recipient.
type ScryptRecipient struct {
password []byte
workFactor int
@@ -26,11 +35,10 @@ type ScryptRecipient struct {
var _ Recipient = &ScryptRecipient{}
func (*ScryptRecipient) Type() string { return "scrypt" }
// NewScryptRecipient returns a new ScryptRecipient with the provided password.
func NewScryptRecipient(password string) (*ScryptRecipient, error) {
if len(password) == 0 {
return nil, errors.New("empty scrypt password")
return nil, errors.New("passphrase can't be empty")
}
r := &ScryptRecipient{
password: []byte(password),
@@ -42,6 +50,8 @@ func NewScryptRecipient(password string) (*ScryptRecipient, error) {
// SetWorkFactor sets the scrypt work factor to 2^logN.
// It must be called before Wrap.
//
// If SetWorkFactor is not called, a reasonable default is used.
func (r *ScryptRecipient) SetWorkFactor(logN int) {
if logN > 30 || logN < 1 {
panic("age: SetWorkFactor called with illegal value")
@@ -49,14 +59,16 @@ func (r *ScryptRecipient) SetWorkFactor(logN int) {
r.workFactor = logN
}
func (r *ScryptRecipient) Wrap(fileKey []byte) (*format.Recipient, error) {
salt := make([]byte, 16)
const scryptSaltSize = 16
func (r *ScryptRecipient) Wrap(fileKey []byte) ([]*Stanza, error) {
salt := make([]byte, scryptSaltSize)
if _, err := rand.Read(salt[:]); err != nil {
return nil, err
}
logN := r.workFactor
l := &format.Recipient{
l := &Stanza{
Type: "scrypt",
Args: []string{format.EncodeToString(salt), strconv.Itoa(logN)},
}
@@ -73,9 +85,10 @@ func (r *ScryptRecipient) Wrap(fileKey []byte) (*format.Recipient, error) {
}
l.Body = wrappedKey
return l, nil
return []*Stanza{l}, nil
}
// ScryptIdentity is a password-based identity.
type ScryptIdentity struct {
password []byte
maxWorkFactor int
@@ -83,11 +96,10 @@ type ScryptIdentity struct {
var _ Identity = &ScryptIdentity{}
func (*ScryptIdentity) Type() string { return "scrypt" }
// NewScryptIdentity returns a new ScryptIdentity with the provided password.
func NewScryptIdentity(password string) (*ScryptIdentity, error) {
if len(password) == 0 {
return nil, errors.New("empty scrypt password")
return nil, errors.New("passphrase can't be empty")
}
i := &ScryptIdentity{
password: []byte(password),
@@ -96,8 +108,12 @@ func NewScryptIdentity(password string) (*ScryptIdentity, error) {
return i, nil
}
// SetWorkFactor sets the maximum accepted scrypt work factor to 2^logN.
// SetMaxWorkFactor sets the maximum accepted scrypt work factor to 2^logN.
// It must be called before Unwrap.
//
// This caps the amount of work that Decrypt might have to do to process
// received files. If SetMaxWorkFactor is not called, a fairly high default is
// used, which might not be suitable for systems processing untrusted files.
func (i *ScryptIdentity) SetMaxWorkFactor(logN int) {
if logN > 30 || logN < 1 {
panic("age: SetMaxWorkFactor called with illegal value")
@@ -105,7 +121,11 @@ func (i *ScryptIdentity) SetMaxWorkFactor(logN int) {
i.maxWorkFactor = logN
}
func (i *ScryptIdentity) Unwrap(block *format.Recipient) ([]byte, error) {
func (i *ScryptIdentity) Unwrap(stanzas []*Stanza) ([]byte, error) {
return multiUnwrap(i.unwrap, stanzas)
}
func (i *ScryptIdentity) unwrap(block *Stanza) ([]byte, error) {
if block.Type != "scrypt" {
return nil, ErrIncorrectIdentity
}
@@ -116,7 +136,7 @@ func (i *ScryptIdentity) Unwrap(block *format.Recipient) ([]byte, error) {
if err != nil {
return nil, fmt.Errorf("failed to parse scrypt salt: %v", err)
}
if len(salt) != 16 {
if len(salt) != scryptSaltSize {
return nil, errors.New("invalid scrypt recipient block")
}
logN, err := strconv.Atoi(block.Args[1])
@@ -136,7 +156,14 @@ func (i *ScryptIdentity) Unwrap(block *format.Recipient) ([]byte, error) {
return nil, fmt.Errorf("failed to generate scrypt hash: %v", err)
}
fileKey, err := aeadDecrypt(k, block.Body)
// This AEAD is not robust, so an attacker could craft a message that
// decrypts under two different keys (meaning two different passphrases) and
// then use an error side-channel in an online decryption oracle to learn if
// either key is correct. This is deemed acceptable because the use case (an
// online decryption oracle) is not recommended, and the security loss is
// only one bit. This also does not bypass any scrypt work, although that work
// can be precomputed in an online oracle scenario.
fileKey, err := aeadDecrypt(k, fileKeySize, block.Body)
if err != nil {
return nil, ErrIncorrectIdentity
}
+5
View File
@@ -0,0 +1,5 @@
age-encryption.org/v1
-> X25519 8hrlM+ZBG3Dd4fF2+a583zdTIWDk8/R41kCYZsvwTW4
yO4PYdlMWDJ+CxgUNRqY5Z0T/m+g3FCh5jIxGLbCVXc
--- I/imevZzy8120JSzmJnmn/KMk3p5A11V83Nk41m9NPE
pĹĺ6$ˇRSů,ZŃʲsşMa€wč8 AzňĘ"rČř\…w4ˇ1;u鍯
+2
View File
@@ -0,0 +1,2 @@
# Test key for ExampleParseX25519Identities.
AGE-SECRET-KEY-184JMZMVQH3E6U0PSL869004Y3U2NYV7R30EU99CSEDNPH02YUVFSZW44VU
+34 -17
View File
@@ -23,15 +23,19 @@ import (
const x25519Label = "age-encryption.org/v1/X25519"
// X25519Recipient is the standard age public key. Messages encrypted to this
// recipient can be decrypted with the corresponding X25519Identity.
//
// This recipient is anonymous, in the sense that an attacker can't tell from
// the message alone if it is encrypted to a certain recipient.
type X25519Recipient struct {
theirPublicKey []byte
}
var _ Recipient = &X25519Recipient{}
func (*X25519Recipient) Type() string { return "X25519" }
func NewX25519Recipient(publicKey []byte) (*X25519Recipient, error) {
// newX25519RecipientFromPoint returns a new X25519Recipient from a raw Curve25519 point.
func newX25519RecipientFromPoint(publicKey []byte) (*X25519Recipient, error) {
if len(publicKey) != curve25519.PointSize {
return nil, errors.New("invalid X25519 public key")
}
@@ -42,6 +46,8 @@ func NewX25519Recipient(publicKey []byte) (*X25519Recipient, error) {
return r, nil
}
// ParseX25519Recipient returns a new X25519Recipient from a Bech32 public key
// encoding with the "age1" prefix.
func ParseX25519Recipient(s string) (*X25519Recipient, error) {
t, k, err := bech32.Decode(s)
if err != nil {
@@ -50,14 +56,14 @@ func ParseX25519Recipient(s string) (*X25519Recipient, error) {
if t != "age" {
return nil, fmt.Errorf("malformed recipient %q: invalid type %q", s, t)
}
r, err := NewX25519Recipient(k)
r, err := newX25519RecipientFromPoint(k)
if err != nil {
return nil, fmt.Errorf("malformed recipient %q: %v", s, err)
}
return r, nil
}
func (r *X25519Recipient) Wrap(fileKey []byte) (*format.Recipient, error) {
func (r *X25519Recipient) Wrap(fileKey []byte) ([]*Stanza, error) {
ephemeral := make([]byte, curve25519.ScalarSize)
if _, err := rand.Read(ephemeral); err != nil {
return nil, err
@@ -72,7 +78,7 @@ func (r *X25519Recipient) Wrap(fileKey []byte) (*format.Recipient, error) {
return nil, err
}
l := &format.Recipient{
l := &Stanza{
Type: "X25519",
Args: []string{format.EncodeToString(ourPublicKey)},
}
@@ -92,23 +98,25 @@ func (r *X25519Recipient) Wrap(fileKey []byte) (*format.Recipient, error) {
}
l.Body = wrappedKey
return l, nil
return []*Stanza{l}, nil
}
// String returns the Bech32 public key encoding of r.
func (r *X25519Recipient) String() string {
s, _ := bech32.Encode("age", r.theirPublicKey)
return s
}
// X25519Identity is the standard age private key, which can decrypt messages
// encrypted to the corresponding X25519Recipient.
type X25519Identity struct {
secretKey, ourPublicKey []byte
}
var _ Identity = &X25519Identity{}
func (*X25519Identity) Type() string { return "X25519" }
func NewX25519Identity(secretKey []byte) (*X25519Identity, error) {
// newX25519IdentityFromScalar returns a new X25519Identity from a raw Curve25519 scalar.
func newX25519IdentityFromScalar(secretKey []byte) (*X25519Identity, error) {
if len(secretKey) != curve25519.ScalarSize {
return nil, errors.New("invalid X25519 secret key")
}
@@ -120,30 +128,37 @@ func NewX25519Identity(secretKey []byte) (*X25519Identity, error) {
return i, nil
}
// GenerateX25519Identity randomly generates a new X25519Identity.
func GenerateX25519Identity() (*X25519Identity, error) {
secretKey := make([]byte, curve25519.ScalarSize)
if _, err := rand.Read(secretKey); err != nil {
return nil, fmt.Errorf("internal error: %v", err)
}
return NewX25519Identity(secretKey)
return newX25519IdentityFromScalar(secretKey)
}
// ParseX25519Identity returns a new X25519Identity from a Bech32 private key
// encoding with the "AGE-SECRET-KEY-1" prefix.
func ParseX25519Identity(s string) (*X25519Identity, error) {
t, k, err := bech32.Decode(s)
if err != nil {
return nil, fmt.Errorf("malformed secret key %q: %v", s, err)
return nil, fmt.Errorf("malformed secret key: %v", err)
}
if t != "AGE-SECRET-KEY-" {
return nil, fmt.Errorf("malformed secret key %q: invalid type %q", s, t)
return nil, fmt.Errorf("malformed secret key: unknown type %q", t)
}
r, err := NewX25519Identity(k)
r, err := newX25519IdentityFromScalar(k)
if err != nil {
return nil, fmt.Errorf("malformed secret key %q: %v", s, err)
return nil, fmt.Errorf("malformed secret key: %v", err)
}
return r, nil
}
func (i *X25519Identity) Unwrap(block *format.Recipient) ([]byte, error) {
func (i *X25519Identity) Unwrap(stanzas []*Stanza) ([]byte, error) {
return multiUnwrap(i.unwrap, stanzas)
}
func (i *X25519Identity) unwrap(block *Stanza) ([]byte, error) {
if block.Type != "X25519" {
return nil, ErrIncorrectIdentity
}
@@ -172,19 +187,21 @@ func (i *X25519Identity) Unwrap(block *format.Recipient) ([]byte, error) {
return nil, err
}
fileKey, err := aeadDecrypt(wrappingKey, block.Body)
fileKey, err := aeadDecrypt(wrappingKey, fileKeySize, block.Body)
if err != nil {
return nil, ErrIncorrectIdentity
}
return fileKey, nil
}
// Recipient returns the public X25519Recipient value corresponding to i.
func (i *X25519Identity) Recipient() *X25519Recipient {
r := &X25519Recipient{}
r.theirPublicKey = i.ourPublicKey
return r
}
// String returns the Bech32 private key encoding of i.
func (i *X25519Identity) String() string {
s, _ := bech32.Encode("AGE-SECRET-KEY-", i.secretKey)
return strings.ToUpper(s)