Author SHA1 Message Date
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
18 changed files with 532 additions and 150 deletions

No files matched your search

+1 -1
View File
@@ -6,4 +6,4 @@ freebsd_12_task:
image: freebsd-12-1-release-amd64
install_script: pkg install -y go
build_script: go build -v ./...
test_script: go test -v ./...
test_script: go test -race ./...
+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),
});
}
+1 -2
View File
@@ -1,9 +1,8 @@
name: Interoperability tests
on: push
jobs:
trigger:
name: Trigger
runs-on: ubuntu-latest
steps:
- name: Trigger interoperability tests in str4d/rage
+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
+1 -1
View File
@@ -14,7 +14,7 @@ class Age < Formula
def install
mkdir bin
system "go", "build", "-trimpath", "-o", bin, "filippo.io/age/cmd/..."
system "go", "build", "-trimpath", "-o", bin, "-ldflags", "-X main.Version=v#{version}", "filippo.io/age/cmd/..."
prefix.install_metafiles
end
end
+41 -11
View File
@@ -10,7 +10,7 @@ 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 data.tar.gz.age > data.tar.gz
$ 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](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).
@@ -21,25 +21,32 @@ An alternative interoperable Rust implementation is available at [github.com/str
```
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 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
@@ -51,6 +58,21 @@ $ age -o example.jpg.age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sf
-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.
@@ -68,14 +90,22 @@ Enter passphrase:
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.)
```
$ cat ~/.ssh/id_ed25519.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIZDRcvS8PnhXr30WKSKmf7WKKi92ACUa5nW589WukJz filippo@Bistromath.local
$ age -r "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIZDRcvS8PnhXr30WKSKmf7WKKi92ACUa5nW589WukJz" example.jpg > example.jpg.age
$ 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 or Linux, you can use Homebrew:
+24 -41
View File
@@ -12,7 +12,7 @@
// 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,
// include a short 32-bit ID of the public key.
package agessh
import (
@@ -24,10 +24,10 @@ 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"
@@ -139,20 +139,24 @@ 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 := &Ed25519Recipient{
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 ParseRecipient(s string) (age.Recipient, error) {
@@ -177,35 +181,14 @@ func ParseRecipient(s string) (age.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"
+53 -6
View File
@@ -11,26 +11,74 @@ import (
"fmt"
"log"
"os"
"runtime/debug"
"time"
"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"+
"Consider setting the umask to 066 and trying again.\n")
fmt.Fprintf(os.Stderr, "Warning: writing secret key to a world-readable file.\n")
}
}
+110 -29
View File
@@ -14,6 +14,7 @@ import (
"io"
_log "log"
"os"
"runtime/debug"
"strings"
"filippo.io/age"
@@ -31,42 +32,62 @@ 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 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")
@@ -77,10 +98,25 @@ func main() {
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.")
@@ -99,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 {
@@ -125,26 +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())) {
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.
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 decryptFlag && name != "-" {
// TODO: buffer the output and check it's printable.
} 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 -".`)
}
}
@@ -158,7 +205,7 @@ func main() {
}
encryptPass(pass, in, out, armorFlag)
default:
encryptKeys(recipientFlags, in, out, armorFlag)
encryptKeys(recipientFlags, recipientsFileFlags, in, out, armorFlag)
}
}
@@ -189,7 +236,7 @@ func passphrasePromptForEncryption() (string, error) {
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)
@@ -198,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)
}
@@ -274,6 +328,33 @@ func passphrasePrompt() (string, error) {
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?" +
+2 -1
View File
@@ -19,7 +19,8 @@ import (
func TestVectors(t *testing.T) {
files, _ := filepath.Glob("testdata/*.age")
for _, f := range files {
name := strings.TrimSuffix(strings.TrimPrefix(f, "testdata/"), ".age")
_, 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
+99 -5
View File
@@ -8,14 +8,17 @@ package main
import (
"bufio"
"encoding/base64"
"fmt"
"io"
"io/ioutil"
"log"
"os"
"strings"
"filippo.io/age"
"filippo.io/age/agessh"
"golang.org/x/crypto/cryptobyte"
"golang.org/x/crypto/ssh"
)
@@ -30,12 +33,98 @@ func parseRecipient(arg string) (age.Recipient, error) {
return nil, fmt.Errorf("unknown recipient type: %q", arg)
}
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()
}
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 len(line) > lineLengthLimit {
return nil, fmt.Errorf("%q: line %d is too long", name, n)
}
r, err := parseRecipient(line)
if err != nil {
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)
}
recs = append(recs, r)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("%q: failed to read recipients file: %v", name, err)
}
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) {
f, err := os.Open(name)
if err != nil {
return nil, fmt.Errorf("failed to open file: %v", err)
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()
}
defer f.Close()
b := bufio.NewReader(f)
const pemHeader = "-----BEGIN"
@@ -90,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)
+1
View File
@@ -3,6 +3,7 @@ module filippo.io/age
go 1.13
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
)
+2
View File
@@ -1,3 +1,5 @@
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=
+16 -12
View File
@@ -55,29 +55,33 @@ func NewlineWriter(dst io.Writer) io.Writer {
type newlineWriter struct {
dst io.Writer
written int
buf bytes.Buffer
}
func (w *newlineWriter) Write(p []byte) (n int, err error) {
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 {
remainingInLine := ColumnsPerLine - (w.written % ColumnsPerLine)
if remainingInLine == ColumnsPerLine && w.written != 0 {
if _, err := w.dst.Write([]byte("\n")); err != nil {
return n, err
}
w.buf.Write([]byte("\n"))
}
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
}
n, _ := w.buf.Write(p[:toWrite])
w.written += n
p = p[n:]
}
return n, nil
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"
+1 -1
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 it 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) {
+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
}
-35
View File
@@ -7,7 +7,6 @@
package age
import (
"bufio"
"crypto/rand"
"crypto/sha256"
"errors"
@@ -159,40 +158,6 @@ func ParseX25519Identity(s string) (*X25519Identity, error) {
return r, nil
}
// 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() {
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
}
func (i *X25519Identity) Unwrap(block *Stanza) ([]byte, error) {
if block.Type != "X25519" {
return nil, ErrIncorrectIdentity