Skip to content

Commit

Permalink
Merge branch 'main' into main
Browse files Browse the repository at this point in the history
  • Loading branch information
kbdharun authored Dec 14, 2023
2 parents 17365f5 + 101cc86 commit 60f8739
Show file tree
Hide file tree
Showing 19 changed files with 616 additions and 132 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ jobs:
strategy:
fail-fast: false
matrix:
go: [1.18.x, 1.19.x]
go: [1.19.x, 1.x]
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,13 @@ $ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
</td>
</tr>
<tr>
<td>Debian 11+ (Bullseye)</td>
<td>Debian 12+ (Bookworm)</td>
<td>
<code>apt install age</code>
</td>
</tr>
<tr>
<td>Debian 11 (Bullseye)</td>
<td>
<code>apt install age/bullseye-backports</code>
(<a href="https://backports.debian.org/Instructions/#index2h2">enable backports</a> for age v1.0.0+)
Expand Down Expand Up @@ -133,7 +139,7 @@ On Windows, Linux, macOS, and FreeBSD you can use the pre-built binaries.

```
https://dl.filippo.io/age/latest?for=linux/amd64
https://dl.filippo.io/age/v1.0.0-rc.1?for=darwin/arm64
https://dl.filippo.io/age/v1.1.1?for=darwin/arm64
...
```

Expand Down
59 changes: 46 additions & 13 deletions age.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// age encrypted files are binary and not malleable. For encoding them as text,
// use the filippo.io/age/armor package.
//
// Key management
// # Key management
//
// age does not have a global keyring. Instead, since age keys are small,
// textual, and cheap, you are encouraged to generate dedicated keys for each
Expand All @@ -34,7 +34,7 @@
// infrastructure, you might want to consider implementing your own Recipient
// and Identity.
//
// Backwards compatibility
// # Backwards compatibility
//
// Files encrypted with a stable version (not alpha, beta, or release candidate)
// of age, or with any v1.0.0 beta or release candidate, will decrypt with any
Expand All @@ -51,6 +51,7 @@ import (
"errors"
"fmt"
"io"
"sort"

"filippo.io/age/internal/format"
"filippo.io/age/internal/stream"
Expand Down Expand Up @@ -84,6 +85,21 @@ type Recipient interface {
Wrap(fileKey []byte) ([]*Stanza, error)
}

// RecipientWithLabels can be optionally implemented by a Recipient, in which
// case Encrypt will use WrapWithLabels instead of Wrap.
//
// Encrypt will succeed only if the labels returned by all the recipients
// (assuming the empty set for those that don't implement RecipientWithLabels)
// are the same.
//
// This can be used to ensure a recipient is only used with other recipients
// with equivalent properties (for example by setting a "postquantum" label) or
// to ensure a recipient is always used alone (by returning a random label, for
// example to preserve its authentication properties).
type RecipientWithLabels interface {
WrapWithLabels(fileKey []byte) (s []*Stanza, labels []string, err error)
}

// A Stanza is a section of the age header that encapsulates the file key as
// encrypted to a specific recipient.
//
Expand Down Expand Up @@ -111,27 +127,24 @@ func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) {
return nil, errors.New("no recipients specified")
}

// As a best effort, prevent an API user from generating a file that the
// ScryptIdentity will refuse to decrypt. This check can't unfortunately be
// implemented as part of the Recipient interface, so it lives as a special
// case in Encrypt.
for _, r := range recipients {
if _, ok := r.(*ScryptRecipient); ok && len(recipients) != 1 {
return nil, errors.New("an ScryptRecipient must be the only one for the file")
}
}

fileKey := make([]byte, fileKeySize)
if _, err := rand.Read(fileKey); err != nil {
return nil, err
}

hdr := &format.Header{}
var labels []string
for i, r := range recipients {
stanzas, err := r.Wrap(fileKey)
stanzas, l, err := wrapWithLabels(r, fileKey)
if err != nil {
return nil, fmt.Errorf("failed to wrap key for recipient #%d: %v", i, err)
}
sort.Strings(l)
if i == 0 {
labels = l
} else if !slicesEqual(labels, l) {
return nil, fmt.Errorf("incompatible recipients")
}
for _, s := range stanzas {
hdr.Recipients = append(hdr.Recipients, (*format.Stanza)(s))
}
Expand All @@ -156,6 +169,26 @@ func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) {
return stream.NewWriter(streamKey(fileKey, nonce), dst)
}

func wrapWithLabels(r Recipient, fileKey []byte) (s []*Stanza, labels []string, err error) {
if r, ok := r.(RecipientWithLabels); ok {
return r.WrapWithLabels(fileKey)
}
s, err = r.Wrap(fileKey)
return
}

func slicesEqual(s1, s2 []string) bool {
if len(s1) != len(s2) {
return false
}
for i := range s1 {
if s1[i] != s2[i] {
return false
}
}
return true
}

// NoIdentityMatchError is returned by Decrypt when none of the supplied
// identities match the encrypted file.
type NoIdentityMatchError struct {
Expand Down
64 changes: 64 additions & 0 deletions age_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,67 @@ AGE-SECRET-KEY--1D6K0SGAX3NU66R4GYFZY0UQWCLM3UUSF3CXLW4KXZM342WQSJ82QKU59Q`},
})
}
}

type testRecipient struct {
labels []string
}

func (testRecipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
panic("expected WrapWithLabels instead")
}

func (t testRecipient) WrapWithLabels(fileKey []byte) (s []*age.Stanza, labels []string, err error) {
return []*age.Stanza{{Type: "test"}}, t.labels, nil
}

func TestLabels(t *testing.T) {
scrypt, err := age.NewScryptRecipient("xxx")
if err != nil {
t.Fatal(err)
}
i, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
x25519 := i.Recipient()
pqc := testRecipient{[]string{"postquantum"}}
pqcAndFoo := testRecipient{[]string{"postquantum", "foo"}}
fooAndPQC := testRecipient{[]string{"foo", "postquantum"}}

if _, err := age.Encrypt(io.Discard, scrypt, scrypt); err == nil {
t.Error("expected two scrypt recipients to fail")
}
if _, err := age.Encrypt(io.Discard, scrypt, x25519); err == nil {
t.Error("expected x25519 mixed with scrypt to fail")
}
if _, err := age.Encrypt(io.Discard, x25519, scrypt); err == nil {
t.Error("expected x25519 mixed with scrypt to fail")
}
if _, err := age.Encrypt(io.Discard, pqc, x25519); err == nil {
t.Error("expected x25519 mixed with pqc to fail")
}
if _, err := age.Encrypt(io.Discard, x25519, pqc); err == nil {
t.Error("expected x25519 mixed with pqc to fail")
}
if _, err := age.Encrypt(io.Discard, pqc, pqc); err != nil {
t.Errorf("expected two pqc to work, got %v", err)
}
if _, err := age.Encrypt(io.Discard, pqc); err != nil {
t.Errorf("expected one pqc to work, got %v", err)
}
if _, err := age.Encrypt(io.Discard, pqcAndFoo, pqc); err == nil {
t.Error("expected pqc+foo mixed with pqc to fail")
}
if _, err := age.Encrypt(io.Discard, pqc, pqcAndFoo); err == nil {
t.Error("expected pqc+foo mixed with pqc to fail")
}
if _, err := age.Encrypt(io.Discard, pqc, pqc, pqcAndFoo); err == nil {
t.Error("expected pqc+foo mixed with pqc to fail")
}
if _, err := age.Encrypt(io.Discard, pqcAndFoo, pqcAndFoo); err != nil {
t.Errorf("expected two pqc+foo to work, got %v", err)
}
if _, err := age.Encrypt(io.Discard, pqcAndFoo, fooAndPQC); err != nil {
t.Errorf("expected pqc+foo mixed with foo+pqc to work, got %v", err)
}
}
2 changes: 1 addition & 1 deletion cmd/age/age.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (
"filippo.io/age"
"filippo.io/age/agessh"
"filippo.io/age/armor"
"filippo.io/age/internal/plugin"
"filippo.io/age/plugin"
"golang.org/x/term"
)

Expand Down
6 changes: 6 additions & 0 deletions cmd/age/age_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,13 @@ func TestMain(m *testing.M) {
scanner := bufio.NewScanner(os.Stdin)
scanner.Scan() // add-recipient
scanner.Scan() // body
scanner.Scan() // grease
scanner.Scan() // body
scanner.Scan() // wrap-file-key
scanner.Scan() // body
fileKey := scanner.Text()
scanner.Scan() // extension-labels
scanner.Scan() // body
scanner.Scan() // done
scanner.Scan() // body
os.Stdout.WriteString("-> recipient-stanza 0 test\n")
Expand All @@ -51,6 +55,8 @@ func TestMain(m *testing.M) {
scanner := bufio.NewScanner(os.Stdin)
scanner.Scan() // add-identity
scanner.Scan() // body
scanner.Scan() // grease
scanner.Scan() // body
scanner.Scan() // recipient-stanza
scanner.Scan() // body
fileKey := scanner.Text()
Expand Down
2 changes: 1 addition & 1 deletion cmd/age/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import (
"filippo.io/age"
"filippo.io/age/agessh"
"filippo.io/age/armor"
"filippo.io/age/internal/plugin"
"filippo.io/age/plugin"
"golang.org/x/crypto/cryptobyte"
"golang.org/x/crypto/ssh"
)
Expand Down
19 changes: 10 additions & 9 deletions cmd/age/testdata/encrypted_keys.txt
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
# TODO: age-encrypted private keys, multiple identities, -i ordering, -e -i,
# age file password prompt during encryption

[windows] skip # no pty support
[!linux] [!darwin] skip # no pty support
[darwin] [go1.20] skip # https://go.dev/issue/61779

# use an encrypted OpenSSH private key without .pub file
age -R key_ed25519.pub -o ed25519.age input
rm key_ed25519.pub
pty terminal
ttyin terminal
age -d -i key_ed25519 ed25519.age
cmp stdout input
! stderr .

# -e -i with an encrypted OpenSSH private key
age -e -i key_ed25519 -o ed25519.age input
pty terminal
ttyin terminal
age -d -i key_ed25519 ed25519.age
cmp stdout input

Expand All @@ -24,7 +25,7 @@ stderr 'no identity matched any of the recipients'

# use an encrypted legacy PEM private key with a .pub file
age -R key_rsa_legacy.pub -o rsa_legacy.age input
pty terminal
ttyin terminal
age -d -i key_rsa_legacy rsa_legacy.age
cmp stdout input
! stderr .
Expand All @@ -34,7 +35,7 @@ stderr 'no identity matched any of the recipients'

# -e -i with an encrypted legacy PEM private key
age -e -i key_rsa_legacy -o rsa_legacy.age input
pty terminal
ttyin terminal
age -d -i key_rsa_legacy rsa_legacy.age
cmp stdout input

Expand All @@ -45,17 +46,17 @@ stderr 'key_rsa_legacy.pub'

# mismatched .pub file causes an error
cp key_rsa_legacy key_rsa_other
pty terminal
ttyin terminal
! age -d -i key_rsa_other rsa_other.age
stderr 'mismatched private and public SSH key'

# buffer armored ciphertext before prompting if stdin is the terminal
pty terminal
ttyin terminal
age -e -i key_ed25519 -a -o test.age input
exec cat test.age terminal # concatenated ciphertext + password
pty -stdin stdout
ttyin -stdin stdout
age -d -i key_ed25519
ptyout 'Enter passphrase'
ttyout 'Enter passphrase'
! stderr .
cmp stdout input

Expand Down
21 changes: 11 additions & 10 deletions cmd/age/testdata/scrypt.txt
Original file line number Diff line number Diff line change
@@ -1,42 +1,43 @@
[windows] skip # no pty support
[!linux] [!darwin] skip # no pty support
[darwin] [go1.20] skip # https://go.dev/issue/61779

# encrypt with a provided passphrase
stdin input
pty terminal
ttyin terminal
age -p -o test.age
ptyout 'Enter passphrase'
ttyout 'Enter passphrase'
! stderr .
! stdout .

# decrypt with a provided passphrase
pty terminal
ttyin terminal
age -d test.age
ptyout 'Enter passphrase'
ttyout 'Enter passphrase'
! stderr .
cmp stdout input

# decrypt with the wrong passphrase
pty wrong
ttyin wrong
! age -d test.age
stderr 'incorrect passphrase'

# encrypt with a generated passphrase
stdin input
pty empty
ttyin empty
age -p -o test.age
! stderr .
! stdout .
pty autogenerated
ttyin autogenerated
age -d test.age
cmp stdout input

# fail when -i is present
pty terminal
ttyin terminal
! age -d -i key.txt test.age
stderr 'file is passphrase-encrypted but identities were specified'

# fail when passphrases don't match
pty wrong
ttyin wrong
! age -p -o fail.age
stderr 'passphrases didn''t match'
! exists fail.age
Expand Down
Loading

0 comments on commit 60f8739

Please sign in to comment.