From 6c36e167c82e5b52bbce889f5052bdc719fdc487 Mon Sep 17 00:00:00 2001 From: Stepan <121534647+exsplashit@users.noreply.github.com> Date: Fri, 30 Jun 2023 17:05:44 +0400 Subject: [PATCH 01/14] README: update release download link (#512) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cf5efd8c..890cb017 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,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 ... ``` From 4740a92ef92e47ad7b8a0d031690f78f3599ed7f Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Sat, 22 Jul 2023 16:34:17 +0200 Subject: [PATCH 02/14] age: use testkit vectors to test armor, header, and STREAM round-trips Before filippo.io/age/armor coverage: 72.3% of statements in filippo.io/age/... filippo.io/age/internal/format coverage: 86.8% of statements in filippo.io/age/... filippo.io/age/internal/stream coverage: 83.9% of statements in filippo.io/age/... After filippo.io/age/armor coverage: 88.0% of statements in filippo.io/age/... filippo.io/age/internal/format coverage: 87.6% of statements in filippo.io/age/... filippo.io/age/internal/stream coverage: 86.0% of statements in filippo.io/age/... --- testkit_test.go | 193 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 154 insertions(+), 39 deletions(-) diff --git a/testkit_test.go b/testkit_test.go index ab315e57..ae707ac2 100644 --- a/testkit_test.go +++ b/testkit_test.go @@ -19,11 +19,15 @@ import ( "filippo.io/age" "filippo.io/age/armor" + "filippo.io/age/internal/format" + "filippo.io/age/internal/stream" + "golang.org/x/crypto/chacha20poly1305" + "golang.org/x/crypto/hkdf" agetest "c2sp.org/CCTV/age" ) -func TestVectors(t *testing.T) { +func forEachVector(t *testing.T, f func(t *testing.T, v *vector)) { tests, err := fs.ReadDir(agetest.Vectors, ".") if err != nil { t.Fatal(err) @@ -35,25 +39,29 @@ func TestVectors(t *testing.T) { t.Fatal(err) } t.Run(name, func(t *testing.T) { - testVector(t, contents) + t.Parallel() + f(t, parseVector(t, contents)) }) } } -func testVector(t *testing.T, test []byte) { - var ( - expect string - payloadHash *[32]byte - identities []age.Identity - armored bool - ) +type vector struct { + expect string + payloadHash *[32]byte + fileKey *[16]byte + identities []age.Identity + armored bool + file []byte +} +func parseVector(t *testing.T, test []byte) *vector { + v := &vector{file: test} for { - line, rest, ok := bytes.Cut(test, []byte("\n")) + line, rest, ok := bytes.Cut(v.file, []byte("\n")) if !ok { t.Fatal("invalid test file: no payload") } - test = rest + v.file = rest if len(line) == 0 { break } @@ -70,87 +78,194 @@ func testVector(t *testing.T, test []byte) { default: t.Fatal("invalid test file: unknown expect value:", value) } - expect = value + v.expect = value case "payload": h, err := hex.DecodeString(value) if err != nil { t.Fatal(err) } - payloadHash = (*[32]byte)(h) + v.payloadHash = (*[32]byte)(h) + case "file key": + h, err := hex.DecodeString(value) + if err != nil { + t.Fatal(err) + } + v.fileKey = (*[16]byte)(h) case "identity": i, err := age.ParseX25519Identity(value) if err != nil { t.Fatal(err) } - identities = append(identities, i) + v.identities = append(v.identities, i) case "passphrase": i, err := age.NewScryptIdentity(value) if err != nil { t.Fatal(err) } - identities = append(identities, i) + v.identities = append(v.identities, i) case "armored": - armored = true - case "file key": - // Ignored. + v.armored = true case "comment": t.Log(value) default: t.Fatal("invalid test file: unknown header key:", key) } } + return v +} - var in io.Reader = bytes.NewReader(test) - if armored { +func TestVectors(t *testing.T) { + forEachVector(t, testVector) +} + +func testVector(t *testing.T, v *vector) { + var in io.Reader = bytes.NewReader(v.file) + if v.armored { in = armor.NewReader(in) } - r, err := age.Decrypt(in, identities...) + r, err := age.Decrypt(in, v.identities...) if err != nil && strings.HasSuffix(err.Error(), "bad header MAC") { - if expect == "HMAC failure" { + if v.expect == "HMAC failure" { t.Log(err) return } - t.Fatalf("expected %s, got HMAC error", expect) + t.Fatalf("expected %s, got HMAC error", v.expect) } else if e := new(armor.Error); errors.As(err, &e) { - if expect == "armor failure" { + if v.expect == "armor failure" { t.Log(err) return } - t.Fatalf("expected %s, got: %v", expect, err) + t.Fatalf("expected %s, got: %v", v.expect, err) } else if _, ok := err.(*age.NoIdentityMatchError); ok { - if expect == "no match" { + if v.expect == "no match" { t.Log(err) return } - t.Fatalf("expected %s, got: %v", expect, err) + t.Fatalf("expected %s, got: %v", v.expect, err) } else if err != nil { - if expect == "header failure" { + if v.expect == "header failure" { t.Log(err) return } - t.Fatalf("expected %s, got: %v", expect, err) - } else if expect != "success" && expect != "payload failure" && - expect != "armor failure" { - t.Fatalf("expected %s, got success", expect) + t.Fatalf("expected %s, got: %v", v.expect, err) + } else if v.expect != "success" && v.expect != "payload failure" && + v.expect != "armor failure" { + t.Fatalf("expected %s, got success", v.expect) } out, err := io.ReadAll(r) - if err != nil && expect == "success" { - t.Fatalf("expected %s, got: %v", expect, err) + if err != nil && v.expect == "success" { + t.Fatalf("expected %s, got: %v", v.expect, err) } else if err != nil { t.Log(err) - if expect == "armor failure" { + if v.expect == "armor failure" { if e := new(armor.Error); !errors.As(err, &e) { t.Errorf("expected armor.Error, got %T", err) } } - if payloadHash != nil && sha256.Sum256(out) != *payloadHash { + if v.payloadHash != nil && sha256.Sum256(out) != *v.payloadHash { t.Error("partial payload hash mismatch") } return - } else if expect != "success" { - t.Fatalf("expected %s, got success", expect) + } else if v.expect != "success" { + t.Fatalf("expected %s, got success", v.expect) } - if sha256.Sum256(out) != *payloadHash { + if sha256.Sum256(out) != *v.payloadHash { t.Error("payload hash mismatch") } } + +// TestVectorsRoundTrip checks that any (valid) armor, header, and/or STREAM +// payload in the test vectors re-encodes identically. +func TestVectorsRoundTrip(t *testing.T) { + forEachVector(t, testVectorRoundTrip) +} + +func testVectorRoundTrip(t *testing.T, v *vector) { + if v.armored { + t.Run("armor", func(t *testing.T) { + payload, err := io.ReadAll(armor.NewReader(bytes.NewReader(v.file))) + if err != nil { + // If the error is unexpected, it will be caught by TestVectors. + t.Skip(err) + } + buf := &bytes.Buffer{} + w := armor.NewWriter(buf) + if _, err := w.Write(payload); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + // Armor format is not perfectly strict: CRLF ↔ LF and trailing and + // leading spaces are allowed and won't round-trip. + expect := bytes.Replace(v.file, []byte("\r\n"), []byte("\n"), -1) + expect = bytes.TrimSpace(expect) + expect = append(expect, '\n') + if !bytes.Equal(buf.Bytes(), expect) { + t.Error("got a different armor encoding") + } + }) + // Armor tests are not interesting beyond their armor encoding. + return + } + + hdr, p, err := format.Parse(bytes.NewReader(v.file)) + if err != nil { + // If the error is unexpected, it will be caught by TestVectors. + t.Skip(err) + } + payload, err := io.ReadAll(p) + if err != nil { + t.Fatal(err) + } + + t.Run("header", func(t *testing.T) { + buf := &bytes.Buffer{} + if err := hdr.Marshal(buf); err != nil { + t.Fatal(err) + } + buf.Write(payload) + if !bytes.Equal(buf.Bytes(), v.file) { + t.Error("got a different header+payload encoding") + } + }) + + if v.fileKey != nil && len(payload) > 16 { + t.Run("STREAM", func(t *testing.T) { + nonce, payload := payload[:16], payload[16:] + key := streamKey(v.fileKey[:], nonce) + r, err := stream.NewReader(key, bytes.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + plaintext, err := io.ReadAll(r) + if err != nil { + // If the error is unexpected, it will be caught by TestVectors. + t.Skip(err) + } + buf := &bytes.Buffer{} + w, err := stream.NewWriter(key, buf) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(plaintext); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + if !bytes.Equal(buf.Bytes(), payload) { + t.Error("got a different STREAM ciphertext") + } + }) + } +} + +func streamKey(fileKey, nonce []byte) []byte { + h := hkdf.New(sha256.New, fileKey, nonce, []byte("payload")) + streamKey := make([]byte, chacha20poly1305.KeySize) + if _, err := io.ReadFull(h, streamKey); err != nil { + panic("age: internal error: failed to read from HKDF: " + err.Error()) + } + return streamKey +} From 980763a16e30ea5c285c271344d2202fcb18c33b Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Sun, 23 Jul 2023 00:54:40 +0200 Subject: [PATCH 03/14] age: make TestVectorsRoundTrip a little stricter --- testkit_test.go | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/testkit_test.go b/testkit_test.go index ae707ac2..78ccc43d 100644 --- a/testkit_test.go +++ b/testkit_test.go @@ -182,11 +182,13 @@ func TestVectorsRoundTrip(t *testing.T) { func testVectorRoundTrip(t *testing.T, v *vector) { if v.armored { + if v.expect == "armor failure" { + t.SkipNow() + } t.Run("armor", func(t *testing.T) { payload, err := io.ReadAll(armor.NewReader(bytes.NewReader(v.file))) if err != nil { - // If the error is unexpected, it will be caught by TestVectors. - t.Skip(err) + t.Fatal(err) } buf := &bytes.Buffer{} w := armor.NewWriter(buf) @@ -209,10 +211,12 @@ func testVectorRoundTrip(t *testing.T, v *vector) { return } + if v.expect == "header failure" { + t.SkipNow() + } hdr, p, err := format.Parse(bytes.NewReader(v.file)) if err != nil { - // If the error is unexpected, it will be caught by TestVectors. - t.Skip(err) + t.Fatal(err) } payload, err := io.ReadAll(p) if err != nil { @@ -230,7 +234,7 @@ func testVectorRoundTrip(t *testing.T, v *vector) { } }) - if v.fileKey != nil && len(payload) > 16 { + if v.expect == "success" { t.Run("STREAM", func(t *testing.T) { nonce, payload := payload[:16], payload[16:] key := streamKey(v.fileKey[:], nonce) @@ -240,8 +244,7 @@ func testVectorRoundTrip(t *testing.T, v *vector) { } plaintext, err := io.ReadAll(r) if err != nil { - // If the error is unexpected, it will be caught by TestVectors. - t.Skip(err) + t.Fatal(err) } buf := &bytes.Buffer{} w, err := stream.NewWriter(key, buf) From 6976c5fca5d828b3e74e3a9721583af5478e8244 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Tue, 23 May 2023 16:19:41 +0200 Subject: [PATCH 04/14] plugin: expose package --- cmd/age/age.go | 2 +- cmd/age/parse.go | 2 +- cmd/age/tui.go | 2 +- {internal/plugin => plugin}/client.go | 0 4 files changed, 3 insertions(+), 3 deletions(-) rename {internal/plugin => plugin}/client.go (100%) diff --git a/cmd/age/age.go b/cmd/age/age.go index 8d44cf5e..f81d3bba 100644 --- a/cmd/age/age.go +++ b/cmd/age/age.go @@ -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" ) diff --git a/cmd/age/parse.go b/cmd/age/parse.go index 709ee9ee..4a59e7a4 100644 --- a/cmd/age/parse.go +++ b/cmd/age/parse.go @@ -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" ) diff --git a/cmd/age/tui.go b/cmd/age/tui.go index 47a13604..c0b1b13a 100644 --- a/cmd/age/tui.go +++ b/cmd/age/tui.go @@ -23,7 +23,7 @@ import ( "runtime" "filippo.io/age/armor" - "filippo.io/age/internal/plugin" + "filippo.io/age/plugin" "golang.org/x/term" ) diff --git a/internal/plugin/client.go b/plugin/client.go similarity index 100% rename from internal/plugin/client.go rename to plugin/client.go From 02181d83e952153472c6e8cd2b61a583a0e41391 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Tue, 23 May 2023 16:20:29 +0200 Subject: [PATCH 05/14] plugin: add identity and recipient encoding --- plugin/client.go | 24 +++++---------------- plugin/encode.go | 55 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 19 deletions(-) create mode 100644 plugin/encode.go diff --git a/plugin/client.go b/plugin/client.go index 6f971672..b4e0ff7f 100644 --- a/plugin/client.go +++ b/plugin/client.go @@ -14,13 +14,11 @@ import ( "io" "os" "strconv" - "strings" "time" exec "golang.org/x/sys/execabs" "filippo.io/age" - "filippo.io/age/internal/bech32" "filippo.io/age/internal/format" ) @@ -36,14 +34,10 @@ type Recipient struct { var _ age.Recipient = &Recipient{} func NewRecipient(s string, ui *ClientUI) (*Recipient, error) { - hrp, _, err := bech32.Decode(s) + name, _, err := ParseRecipient(s) if err != nil { - return nil, fmt.Errorf("invalid recipient encoding %q: %v", s, err) - } - if !strings.HasPrefix(hrp, "age1") { - return nil, fmt.Errorf("not a plugin recipient %q: %v", s, err) + return nil, err } - name := strings.TrimPrefix(hrp, "age1") return &Recipient{ name: name, encoding: s, ui: ui, }, nil @@ -151,25 +145,17 @@ type Identity struct { var _ age.Identity = &Identity{} func NewIdentity(s string, ui *ClientUI) (*Identity, error) { - hrp, _, err := bech32.Decode(s) + name, _, err := ParseIdentity(s) if err != nil { - return nil, fmt.Errorf("invalid identity encoding: %v", err) - } - if !strings.HasPrefix(hrp, "AGE-PLUGIN-") || !strings.HasSuffix(hrp, "-") { - return nil, fmt.Errorf("not a plugin identity: %v", err) + return nil, err } - name := strings.TrimSuffix(strings.TrimPrefix(hrp, "AGE-PLUGIN-"), "-") - name = strings.ToLower(name) return &Identity{ name: name, encoding: s, ui: ui, }, nil } func NewIdentityWithoutData(name string, ui *ClientUI) (*Identity, error) { - s, err := bech32.Encode("AGE-PLUGIN-"+strings.ToUpper(name)+"-", nil) - if err != nil { - return nil, err - } + s := EncodeIdentity(name, nil) return &Identity{ name: name, encoding: s, ui: ui, }, nil diff --git a/plugin/encode.go b/plugin/encode.go new file mode 100644 index 00000000..5000708a --- /dev/null +++ b/plugin/encode.go @@ -0,0 +1,55 @@ +// Copyright 2023 The age Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package plugin + +import ( + "fmt" + "strings" + + "filippo.io/age/internal/bech32" +) + +// EncodeIdentity encodes a plugin identity string for a plugin with the given +// name. If the name is invalid, it returns an empty string. +func EncodeIdentity(name string, data []byte) string { + s, _ := bech32.Encode("AGE-PLUGIN-"+strings.ToUpper(name)+"-", data) + return s +} + +// ParseIdentity decodes a plugin identity string. It returns the plugin name +// in lowercase and the encoded data. +func ParseIdentity(s string) (name string, data []byte, err error) { + hrp, data, err := bech32.Decode(s) + if err != nil { + return "", nil, fmt.Errorf("invalid identity encoding: %v", err) + } + if !strings.HasPrefix(hrp, "AGE-PLUGIN-") || !strings.HasSuffix(hrp, "-") { + return "", nil, fmt.Errorf("not a plugin identity: %v", err) + } + name = strings.TrimSuffix(strings.TrimPrefix(hrp, "AGE-PLUGIN-"), "-") + name = strings.ToLower(name) + return name, data, nil +} + +// EncodeRecipient encodes a plugin recipient string for a plugin with the given +// name. If the name is invalid, it returns an empty string. +func EncodeRecipient(name string, data []byte) string { + s, _ := bech32.Encode("age1"+strings.ToLower(name), data) + return s +} + +// ParseRecipient decodes a plugin recipient string. It returns the plugin name +// in lowercase and the encoded data. +func ParseRecipient(s string) (name string, data []byte, err error) { + hrp, data, err := bech32.Decode(s) + if err != nil { + return "", nil, fmt.Errorf("invalid recipient encoding: %v", err) + } + if !strings.HasPrefix(hrp, "age1") { + return "", nil, fmt.Errorf("not a plugin recipient: %v", err) + } + name = strings.TrimPrefix(hrp, "age1") + return name, data, nil +} From 004b544d838bbf016ee52109ee5e0cdc63bcb2e0 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Tue, 23 May 2023 17:36:32 +0200 Subject: [PATCH 06/14] plugin: add EncodeX25519Recipient --- plugin/encode.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/plugin/encode.go b/plugin/encode.go index 5000708a..44912e5e 100644 --- a/plugin/encode.go +++ b/plugin/encode.go @@ -5,6 +5,7 @@ package plugin import ( + "crypto/ecdh" "fmt" "strings" @@ -53,3 +54,13 @@ func ParseRecipient(s string) (name string, data []byte, err error) { name = strings.TrimPrefix(hrp, "age1") return name, data, nil } + +// EncodeX25519Recipient encodes a native X25519 recipient from a +// [crypto/ecdh.X25519] public key. It's meant for plugins that implement +// identities that are compatible with native recipients. +func EncodeX25519Recipient(pk *ecdh.PublicKey) (string, error) { + if pk.Curve() != ecdh.X25519() { + return "", fmt.Errorf("wrong ecdh Curve") + } + return bech32.Encode("age", pk.Bytes()) +} From dd733c5c0f64124dba2f51bd65f51f58813acc03 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Sat, 5 Aug 2023 14:55:16 +0200 Subject: [PATCH 07/14] cmd/age: grease the client-controlled plugin phases --- cmd/age/age_test.go | 4 ++++ plugin/client.go | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/cmd/age/age_test.go b/cmd/age/age_test.go index 3d8793b5..53eb7995 100644 --- a/cmd/age/age_test.go +++ b/cmd/age/age_test.go @@ -36,6 +36,8 @@ 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() @@ -51,6 +53,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() diff --git a/plugin/client.go b/plugin/client.go index b4e0ff7f..a45587f0 100644 --- a/plugin/client.go +++ b/plugin/client.go @@ -12,6 +12,7 @@ import ( "bytes" "fmt" "io" + "math/rand" "os" "strconv" "time" @@ -71,6 +72,9 @@ func (r *Recipient) Wrap(fileKey []byte) (stanzas []*age.Stanza, err error) { if err := writeStanza(conn, addType, r.encoding); err != nil { return nil, err } + if err := writeStanza(conn, fmt.Sprintf("grease-%x", rand.Int())); err != nil { + return nil, err + } if err := writeStanzaWithBody(conn, "wrap-file-key", fileKey); err != nil { return nil, err } @@ -197,6 +201,9 @@ func (i *Identity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) { if err := writeStanza(conn, "add-identity", i.encoding); err != nil { return nil, err } + if err := writeStanza(conn, fmt.Sprintf("grease-%x", rand.Int())); err != nil { + return nil, err + } for _, rs := range stanzas { s := &format.Stanza{ Type: "recipient-stanza", From c89f0b932ecd7222179f2fb46bbd033d40af65a0 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Sat, 5 Aug 2023 19:19:26 +0200 Subject: [PATCH 08/14] age,plugin: add RecipientWithLabels --- age.go | 59 ++++++++++++++----- age_test.go | 64 +++++++++++++++++++++ cmd/age/age_test.go | 2 + go.mod | 2 +- go.sum | 4 +- plugin/client.go | 59 +++++++++++++------ plugin/client_test.go | 129 ++++++++++++++++++++++++++++++++++++++++++ scrypt.go | 24 ++++++++ 8 files changed, 310 insertions(+), 33 deletions(-) create mode 100644 plugin/client_test.go diff --git a/age.go b/age.go index a0ef0bb7..c9b17bc8 100644 --- a/age.go +++ b/age.go @@ -13,7 +13,7 @@ // age encrypted files are binary and not malleable. For encoding them as text, // use the filippo.io/age/armor package. // -// Key management +// # Key management // // age does not have a global keyring. Instead, since age keys are small, // textual, and cheap, you are encouraged to generate dedicated keys for each @@ -34,7 +34,7 @@ // infrastructure, you might want to consider implementing your own Recipient // and Identity. // -// Backwards compatibility +// # Backwards compatibility // // Files encrypted with a stable version (not alpha, beta, or release candidate) // of age, or with any v1.0.0 beta or release candidate, will decrypt with any @@ -51,6 +51,7 @@ import ( "errors" "fmt" "io" + "sort" "filippo.io/age/internal/format" "filippo.io/age/internal/stream" @@ -84,6 +85,21 @@ type Recipient interface { Wrap(fileKey []byte) ([]*Stanza, error) } +// RecipientWithLabels can be optionally implemented by a Recipient, in which +// case Encrypt will use WrapWithLabels instead of Wrap. +// +// Encrypt will succeed only if the labels returned by all the recipients +// (assuming the empty set for those that don't implement RecipientWithLabels) +// are the same. +// +// This can be used to ensure a recipient is only used with other recipients +// with equivalent properties (for example by setting a "postquantum" label) or +// to ensure a recipient is always used alone (by returning a random label, for +// example to preserve its authentication properties). +type RecipientWithLabels interface { + WrapWithLabels(fileKey []byte) (s []*Stanza, labels []string, err error) +} + // A Stanza is a section of the age header that encapsulates the file key as // encrypted to a specific recipient. // @@ -111,27 +127,24 @@ func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) { return nil, errors.New("no recipients specified") } - // As a best effort, prevent an API user from generating a file that the - // ScryptIdentity will refuse to decrypt. This check can't unfortunately be - // implemented as part of the Recipient interface, so it lives as a special - // case in Encrypt. - for _, r := range recipients { - if _, ok := r.(*ScryptRecipient); ok && len(recipients) != 1 { - return nil, errors.New("an ScryptRecipient must be the only one for the file") - } - } - fileKey := make([]byte, fileKeySize) if _, err := rand.Read(fileKey); err != nil { return nil, err } hdr := &format.Header{} + var labels []string for i, r := range recipients { - stanzas, err := r.Wrap(fileKey) + stanzas, l, err := wrapWithLabels(r, fileKey) if err != nil { return nil, fmt.Errorf("failed to wrap key for recipient #%d: %v", i, err) } + sort.Strings(l) + if i == 0 { + labels = l + } else if !slicesEqual(labels, l) { + return nil, fmt.Errorf("incompatible recipients") + } for _, s := range stanzas { hdr.Recipients = append(hdr.Recipients, (*format.Stanza)(s)) } @@ -156,6 +169,26 @@ func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) { return stream.NewWriter(streamKey(fileKey, nonce), dst) } +func wrapWithLabels(r Recipient, fileKey []byte) (s []*Stanza, labels []string, err error) { + if r, ok := r.(RecipientWithLabels); ok { + return r.WrapWithLabels(fileKey) + } + s, err = r.Wrap(fileKey) + return +} + +func slicesEqual(s1, s2 []string) bool { + if len(s1) != len(s2) { + return false + } + for i := range s1 { + if s1[i] != s2[i] { + return false + } + } + return true +} + // NoIdentityMatchError is returned by Decrypt when none of the supplied // identities match the encrypted file. type NoIdentityMatchError struct { diff --git a/age_test.go b/age_test.go index 3ae95bf3..8cf68670 100644 --- a/age_test.go +++ b/age_test.go @@ -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) + } +} diff --git a/cmd/age/age_test.go b/cmd/age/age_test.go index 53eb7995..92918299 100644 --- a/cmd/age/age_test.go +++ b/cmd/age/age_test.go @@ -41,6 +41,8 @@ func TestMain(m *testing.M) { 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") diff --git a/go.mod b/go.mod index 13f1712a..d382a0cf 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.19 require ( filippo.io/edwards25519 v1.0.0 golang.org/x/crypto v0.4.0 - golang.org/x/sys v0.3.0 + golang.org/x/sys v0.11.0 golang.org/x/term v0.3.0 ) diff --git a/go.sum b/go.sum index 9bcb7459..98da6be3 100644 --- a/go.sum +++ b/go.sum @@ -8,7 +8,7 @@ github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgc github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= golang.org/x/crypto v0.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8= golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80= -golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= -golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= diff --git a/plugin/client.go b/plugin/client.go index a45587f0..dca1a521 100644 --- a/plugin/client.go +++ b/plugin/client.go @@ -14,6 +14,7 @@ import ( "io" "math/rand" "os" + "path/filepath" "strconv" "time" @@ -33,6 +34,7 @@ type Recipient struct { } var _ age.Recipient = &Recipient{} +var _ age.RecipientWithLabels = &Recipient{} func NewRecipient(s string, ui *ClientUI) (*Recipient, error) { name, _, err := ParseRecipient(s) @@ -52,6 +54,11 @@ func (r *Recipient) Name() string { } func (r *Recipient) Wrap(fileKey []byte) (stanzas []*age.Stanza, err error) { + stanzas, _, err = r.WrapWithLabels(fileKey) + return +} + +func (r *Recipient) WrapWithLabels(fileKey []byte) (stanzas []*age.Stanza, labels []string, err error) { defer func() { if err != nil { err = fmt.Errorf("%s plugin: %w", r.name, err) @@ -60,7 +67,7 @@ func (r *Recipient) Wrap(fileKey []byte) (stanzas []*age.Stanza, err error) { conn, err := openClientConnection(r.name, "recipient-v1") if err != nil { - return nil, fmt.Errorf("couldn't start plugin: %v", err) + return nil, nil, fmt.Errorf("couldn't start plugin: %v", err) } defer conn.Close() @@ -70,16 +77,19 @@ func (r *Recipient) Wrap(fileKey []byte) (stanzas []*age.Stanza, err error) { addType = "add-identity" } if err := writeStanza(conn, addType, r.encoding); err != nil { - return nil, err + return nil, nil, err } if err := writeStanza(conn, fmt.Sprintf("grease-%x", rand.Int())); err != nil { - return nil, err + return nil, nil, err } if err := writeStanzaWithBody(conn, "wrap-file-key", fileKey); err != nil { - return nil, err + return nil, nil, err + } + if err := writeStanza(conn, "extension-labels"); err != nil { + return nil, nil, err } if err := writeStanza(conn, "done"); err != nil { - return nil, err + return nil, nil, err } // Phase 2: plugin responds with stanzas @@ -88,21 +98,21 @@ ReadLoop: for { s, err := r.ui.readStanza(r.name, sr) if err != nil { - return nil, err + return nil, nil, err } switch s.Type { case "recipient-stanza": if len(s.Args) < 2 { - return nil, fmt.Errorf("malformed recipient stanza: unexpected argument count") + return nil, nil, fmt.Errorf("malformed recipient stanza: unexpected argument count") } n, err := strconv.Atoi(s.Args[0]) if err != nil { - return nil, fmt.Errorf("malformed recipient stanza: invalid index") + return nil, nil, fmt.Errorf("malformed recipient stanza: invalid index") } // We only send a single file key, so the index must be 0. if n != 0 { - return nil, fmt.Errorf("malformed recipient stanza: unexpected index") + return nil, nil, fmt.Errorf("malformed recipient stanza: unexpected index") } stanzas = append(stanzas, &age.Stanza{ @@ -112,32 +122,41 @@ ReadLoop: }) if err := writeStanza(conn, "ok"); err != nil { - return nil, err + return nil, nil, err + } + case "labels": + if labels != nil { + return nil, nil, fmt.Errorf("repeated labels stanza") + } + labels = s.Args + + if err := writeStanza(conn, "ok"); err != nil { + return nil, nil, err } case "error": if err := writeStanza(conn, "ok"); err != nil { - return nil, err + return nil, nil, err } - return nil, fmt.Errorf("%s", s.Body) + return nil, nil, fmt.Errorf("%s", s.Body) case "done": break ReadLoop default: if ok, err := r.ui.handle(r.name, conn, s); err != nil { - return nil, err + return nil, nil, err } else if !ok { if err := writeStanza(conn, "unsupported"); err != nil { - return nil, err + return nil, nil, err } } } } if len(stanzas) == 0 { - return nil, fmt.Errorf("received zero recipient stanzas") + return nil, nil, fmt.Errorf("received zero recipient stanzas") } - return stanzas, nil + return stanzas, labels, nil } type Identity struct { @@ -367,8 +386,14 @@ type clientConnection struct { close func() } +var testOnlyPluginPath string + func openClientConnection(name, protocol string) (*clientConnection, error) { - cmd := exec.Command("age-plugin-"+name, "--age-plugin="+protocol) + path := "age-plugin-" + name + if testOnlyPluginPath != "" { + path = filepath.Join(testOnlyPluginPath, path) + } + cmd := exec.Command(path, "--age-plugin="+protocol) stdout, err := cmd.StdoutPipe() if err != nil { diff --git a/plugin/client_test.go b/plugin/client_test.go new file mode 100644 index 00000000..c4cad60c --- /dev/null +++ b/plugin/client_test.go @@ -0,0 +1,129 @@ +// Copyright 2023 The age Authors +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file or at +// https://developers.google.com/open-source/licenses/bsd + +package plugin + +import ( + "bufio" + "io" + "os" + "path/filepath" + "testing" + + "filippo.io/age" + "filippo.io/age/internal/bech32" +) + +func TestMain(m *testing.M) { + switch filepath.Base(os.Args[0]) { + // TODO: deduplicate from cmd/age TestMain. + case "age-plugin-test": + switch os.Args[1] { + case "--age-plugin=recipient-v1": + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() // add-recipient + scanner.Scan() // body + scanner.Scan() // grease + scanner.Scan() // body + scanner.Scan() // wrap-file-key + scanner.Scan() // body + fileKey := scanner.Text() + scanner.Scan() // extension-labels + scanner.Scan() // body + scanner.Scan() // done + scanner.Scan() // body + os.Stdout.WriteString("-> recipient-stanza 0 test\n") + os.Stdout.WriteString(fileKey + "\n") + scanner.Scan() // ok + scanner.Scan() // body + os.Stdout.WriteString("-> done\n\n") + os.Exit(0) + default: + panic(os.Args[1]) + } + case "age-plugin-testpqc": + switch os.Args[1] { + case "--age-plugin=recipient-v1": + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() // add-recipient + scanner.Scan() // body + scanner.Scan() // grease + scanner.Scan() // body + scanner.Scan() // wrap-file-key + scanner.Scan() // body + fileKey := scanner.Text() + scanner.Scan() // extension-labels + scanner.Scan() // body + scanner.Scan() // done + scanner.Scan() // body + os.Stdout.WriteString("-> recipient-stanza 0 test\n") + os.Stdout.WriteString(fileKey + "\n") + scanner.Scan() // ok + scanner.Scan() // body + os.Stdout.WriteString("-> labels postquantum\n\n") + scanner.Scan() // ok + scanner.Scan() // body + os.Stdout.WriteString("-> done\n\n") + os.Exit(0) + default: + panic(os.Args[1]) + } + default: + os.Exit(m.Run()) + } +} + +func TestLabels(t *testing.T) { + temp := t.TempDir() + testOnlyPluginPath = temp + t.Cleanup(func() { testOnlyPluginPath = "" }) + ex, err := os.Executable() + if err != nil { + t.Fatal(err) + } + if err := os.Link(ex, filepath.Join(temp, "age-plugin-test")); err != nil { + t.Fatal(err) + } + if err := os.Chmod(filepath.Join(temp, "age-plugin-test"), 0755); err != nil { + t.Fatal(err) + } + if err := os.Link(ex, filepath.Join(temp, "age-plugin-testpqc")); err != nil { + t.Fatal(err) + } + if err := os.Chmod(filepath.Join(temp, "age-plugin-testpqc"), 0755); err != nil { + t.Fatal(err) + } + + name, err := bech32.Encode("age1test", nil) + if err != nil { + t.Fatal(err) + } + testPlugin, err := NewRecipient(name, &ClientUI{}) + if err != nil { + t.Fatal(err) + } + namePQC, err := bech32.Encode("age1testpqc", nil) + if err != nil { + t.Fatal(err) + } + testPluginPQC, err := NewRecipient(namePQC, &ClientUI{}) + if err != nil { + t.Fatal(err) + } + + if _, err := age.Encrypt(io.Discard, testPluginPQC); err != nil { + t.Errorf("expected one pqc to work, got %v", err) + } + if _, err := age.Encrypt(io.Discard, testPluginPQC, testPluginPQC); err != nil { + t.Errorf("expected two pqc to work, got %v", err) + } + if _, err := age.Encrypt(io.Discard, testPluginPQC, testPlugin); err == nil { + t.Errorf("expected one pqc and one normal to fail") + } + if _, err := age.Encrypt(io.Discard, testPlugin, testPluginPQC); err == nil { + t.Errorf("expected one pqc and one normal to fail") + } +} diff --git a/scrypt.go b/scrypt.go index 1346ad13..73d13b7f 100644 --- a/scrypt.go +++ b/scrypt.go @@ -6,6 +6,7 @@ package age import ( "crypto/rand" + "encoding/hex" "errors" "fmt" "regexp" @@ -87,6 +88,29 @@ func (r *ScryptRecipient) Wrap(fileKey []byte) ([]*Stanza, error) { return []*Stanza{l}, nil } +// WrapWithLabels implements [age.RecipientWithLabels], returning a random +// label. This ensures a ScryptRecipient can't be mixed with other recipients +// (including other ScryptRecipients). +// +// Users reasonably expect files encrypted to a passphrase to be [authenticated] +// by that passphrase, i.e. for it to be impossible to produce a file that +// decrypts successfully with a passphrase without knowing it. If a file is +// encrypted to other recipients, those parties can produce different files that +// would break that expectation. +// +// [authenticated]: https://words.filippo.io/dispatches/age-authentication/ +func (r *ScryptRecipient) WrapWithLabels(fileKey []byte) (stanzas []*Stanza, labels []string, err error) { + stanzas, err = r.Wrap(fileKey) + + random := make([]byte, 16) + if _, err := rand.Read(random); err != nil { + return nil, nil, err + } + labels = []string{hex.EncodeToString(random)} + + return +} + // ScryptIdentity is a password-based identity. type ScryptIdentity struct { password []byte From 9fd564d5430f9e340f9ad01332623dde648ab240 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Sun, 6 Aug 2023 18:29:16 +0200 Subject: [PATCH 09/14] .github/workflows: update and fix CI --- .github/workflows/test.yml | 4 ++-- cmd/age/testdata/encrypted_keys.txt | 1 + cmd/age/testdata/scrypt.txt | 1 + cmd/age/testdata/terminal.txt | 1 + 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 30591989..565963a6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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: @@ -24,7 +24,7 @@ jobs: run: go test -race ./... freebsd: name: Test (FreeBSD) - runs-on: macos-10.15 + runs-on: macos-12 steps: - name: Checkout repository uses: actions/checkout@v2 diff --git a/cmd/age/testdata/encrypted_keys.txt b/cmd/age/testdata/encrypted_keys.txt index 86710795..7b4f7161 100644 --- a/cmd/age/testdata/encrypted_keys.txt +++ b/cmd/age/testdata/encrypted_keys.txt @@ -2,6 +2,7 @@ # age file password prompt during encryption [windows] skip # no pty support +[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 diff --git a/cmd/age/testdata/scrypt.txt b/cmd/age/testdata/scrypt.txt index 987cfbbd..8a1b6848 100644 --- a/cmd/age/testdata/scrypt.txt +++ b/cmd/age/testdata/scrypt.txt @@ -1,4 +1,5 @@ [windows] skip # no pty support +[go1.20] skip # https://go.dev/issue/61779 # encrypt with a provided passphrase stdin input diff --git a/cmd/age/testdata/terminal.txt b/cmd/age/testdata/terminal.txt index cd2f5d4c..849e772a 100644 --- a/cmd/age/testdata/terminal.txt +++ b/cmd/age/testdata/terminal.txt @@ -1,4 +1,5 @@ [windows] skip # no pty support +[go1.20] skip # https://go.dev/issue/61779 # controlling terminal is used instead of stdin/stderr pty terminal From f1f96c25e062c56098f7b91a378e5858af7dbd34 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Sun, 6 Aug 2023 18:36:06 +0200 Subject: [PATCH 10/14] plugin: build tag EncodeX25519Recipient which uses crypto/ecdh --- plugin/encode.go | 11 ----------- plugin/encode_go1.20.go | 24 ++++++++++++++++++++++++ 2 files changed, 24 insertions(+), 11 deletions(-) create mode 100644 plugin/encode_go1.20.go diff --git a/plugin/encode.go b/plugin/encode.go index 44912e5e..5000708a 100644 --- a/plugin/encode.go +++ b/plugin/encode.go @@ -5,7 +5,6 @@ package plugin import ( - "crypto/ecdh" "fmt" "strings" @@ -54,13 +53,3 @@ func ParseRecipient(s string) (name string, data []byte, err error) { name = strings.TrimPrefix(hrp, "age1") return name, data, nil } - -// EncodeX25519Recipient encodes a native X25519 recipient from a -// [crypto/ecdh.X25519] public key. It's meant for plugins that implement -// identities that are compatible with native recipients. -func EncodeX25519Recipient(pk *ecdh.PublicKey) (string, error) { - if pk.Curve() != ecdh.X25519() { - return "", fmt.Errorf("wrong ecdh Curve") - } - return bech32.Encode("age", pk.Bytes()) -} diff --git a/plugin/encode_go1.20.go b/plugin/encode_go1.20.go new file mode 100644 index 00000000..6b171660 --- /dev/null +++ b/plugin/encode_go1.20.go @@ -0,0 +1,24 @@ +// Copyright 2023 The age Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build go1.20 + +package plugin + +import ( + "crypto/ecdh" + "fmt" + + "filippo.io/age/internal/bech32" +) + +// EncodeX25519Recipient encodes a native X25519 recipient from a +// [crypto/ecdh.X25519] public key. It's meant for plugins that implement +// identities that are compatible with native recipients. +func EncodeX25519Recipient(pk *ecdh.PublicKey) (string, error) { + if pk.Curve() != ecdh.X25519() { + return "", fmt.Errorf("wrong ecdh Curve") + } + return bech32.Encode("age", pk.Bytes()) +} From 294b0aa1e35ad48e22fc0a93ca60787714347ee1 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Sun, 6 Aug 2023 19:03:27 +0200 Subject: [PATCH 11/14] plugin: skip execution tests on Windows for now --- plugin/client_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugin/client_test.go b/plugin/client_test.go index c4cad60c..fc28789c 100644 --- a/plugin/client_test.go +++ b/plugin/client_test.go @@ -11,6 +11,7 @@ import ( "io" "os" "path/filepath" + "runtime" "testing" "filippo.io/age" @@ -77,6 +78,9 @@ func TestMain(m *testing.M) { } func TestLabels(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows support is TODO") + } temp := t.TempDir() testOnlyPluginPath = temp t.Cleanup(func() { testOnlyPluginPath = "" }) From 93055632adee9025d196e483d8e2ac43ff9a3fdc Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Sun, 6 Aug 2023 19:28:49 +0200 Subject: [PATCH 12/14] cmd/age: fix FreeBSD tests --- cmd/age/testdata/encrypted_keys.txt | 20 ++++++++++---------- cmd/age/testdata/scrypt.txt | 22 +++++++++++----------- cmd/age/testdata/terminal.txt | 24 ++++++++++++------------ go.mod | 4 ++-- go.sum | 8 ++++---- 5 files changed, 39 insertions(+), 39 deletions(-) diff --git a/cmd/age/testdata/encrypted_keys.txt b/cmd/age/testdata/encrypted_keys.txt index 7b4f7161..050bf132 100644 --- a/cmd/age/testdata/encrypted_keys.txt +++ b/cmd/age/testdata/encrypted_keys.txt @@ -1,20 +1,20 @@ # TODO: age-encrypted private keys, multiple identities, -i ordering, -e -i, # age file password prompt during encryption -[windows] skip # no pty support -[go1.20] skip # https://go.dev/issue/61779 +[!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 @@ -25,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 . @@ -35,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 @@ -46,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 diff --git a/cmd/age/testdata/scrypt.txt b/cmd/age/testdata/scrypt.txt index 8a1b6848..93298855 100644 --- a/cmd/age/testdata/scrypt.txt +++ b/cmd/age/testdata/scrypt.txt @@ -1,43 +1,43 @@ -[windows] skip # no pty support -[go1.20] skip # https://go.dev/issue/61779 +[!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 diff --git a/cmd/age/testdata/terminal.txt b/cmd/age/testdata/terminal.txt index 849e772a..b2cf0078 100644 --- a/cmd/age/testdata/terminal.txt +++ b/cmd/age/testdata/terminal.txt @@ -1,22 +1,22 @@ -[windows] skip # no pty support -[go1.20] skip # https://go.dev/issue/61779 +[!linux] [!darwin] skip # no pty support +[darwin] [go1.20] skip # https://go.dev/issue/61779 # controlling terminal is used instead of stdin/stderr -pty terminal +ttyin terminal age -p -o test.age input ! stderr . # autogenerated passphrase is printed to terminal -pty empty +ttyin empty age -p -o test.age input -ptyout 'autogenerated passphrase' +ttyout 'autogenerated passphrase' ! stderr . # with no controlling terminal, stdin terminal is used ## TODO: enable once https://golang.org/issue/53601 is fixed ## and Noctty is added to testscript. # noctty -# pty -stdin terminal +# ttyin -stdin terminal # age -p -o test.age input # ! stderr . @@ -29,22 +29,22 @@ ptyout 'autogenerated passphrase' # prompt for password before plaintext if stdin is the terminal exec cat terminal input # concatenated password + input -pty -stdin stdout +ttyin -stdin stdout age -p -a -o test.age -ptyout 'Enter passphrase' +ttyout 'Enter passphrase' ! stderr . # check the file was encrypted correctly -pty terminal +ttyin terminal age -d test.age cmp stdout input # buffer armored ciphertext before prompting if stdin is the terminal -pty terminal +ttyin terminal age -p -a -o test.age input exec cat test.age terminal # concatenated ciphertext + password -pty -stdin stdout +ttyin -stdin stdout age -d -ptyout 'Enter passphrase' +ttyout 'Enter passphrase' ! stderr . cmp stdout input diff --git a/go.mod b/go.mod index d382a0cf..4c0eed9a 100644 --- a/go.mod +++ b/go.mod @@ -12,9 +12,9 @@ require ( // Test dependencies. require ( c2sp.org/CCTV/age v0.0.0-20221230231406-5ea85644bd03 - github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e // indirect github.com/rogpeppe/go-internal v1.8.1 + golang.org/x/tools v0.1.12 // indirect ) // https://github.com/rogpeppe/go-internal/pull/172 -replace github.com/rogpeppe/go-internal => github.com/FiloSottile/go-internal v1.8.2-0.20230102123319-d43ebe7f1660 +replace github.com/rogpeppe/go-internal => github.com/FiloSottile/go-internal v1.8.2-0.20230806172430-94b0f0dc0b1e diff --git a/go.sum b/go.sum index 98da6be3..a71308ce 100644 --- a/go.sum +++ b/go.sum @@ -2,13 +2,13 @@ c2sp.org/CCTV/age v0.0.0-20221230231406-5ea85644bd03 h1:0e2QjhWG02SgzlUOvNYaFraf c2sp.org/CCTV/age v0.0.0-20221230231406-5ea85644bd03/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= filippo.io/edwards25519 v1.0.0 h1:0wAIcmJUqRdI8IJ/3eGi5/HwXZWPujYXXlkrQogz0Ek= filippo.io/edwards25519 v1.0.0/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns= -github.com/FiloSottile/go-internal v1.8.2-0.20230102123319-d43ebe7f1660 h1:o9Uw6fW8MF/K9RlbCO5e/5e6uXPdwND1NyRnn1NHjgE= -github.com/FiloSottile/go-internal v1.8.2-0.20230102123319-d43ebe7f1660/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/FiloSottile/go-internal v1.8.2-0.20230806172430-94b0f0dc0b1e h1:1pkMKBSmMMOXQT5lFTmciWn86GGymBssr1bOOOoo2GI= +github.com/FiloSottile/go-internal v1.8.2-0.20230806172430-94b0f0dc0b1e/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= golang.org/x/crypto v0.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8= golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80= golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= From 6ad4560f4afc3fe46b6cda0bc568e50b89a22e4c Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Mon, 7 Aug 2023 18:44:57 -0400 Subject: [PATCH 13/14] .github/workflows: drop FreeBSD tests This is unfortunate, but without a live platform to test on, I can't investigate issues, and CI is now failing with just ? filippo.io/age/cmd/age-keygen [no test files] Killed which really could be anything. --- .github/workflows/test.yml | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 565963a6..55b7ca8a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,23 +22,6 @@ jobs: fetch-depth: 0 - name: Run tests run: go test -race ./... - freebsd: - name: Test (FreeBSD) - runs-on: macos-12 - steps: - - name: Checkout repository - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - name: Run tests - # Unpinned Action allowed with read-only permissions. - uses: vmactions/freebsd-vm@v0 - with: - prepare: | - freebsd-version - pkg install -y go - go version - run: go test -buildvcs=false -race ./... gotip: name: Test (Go tip) strategy: From 101cc8676386b0503571a929a88618cae2f0b1cd Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Wed, 20 Sep 2023 08:41:00 -0400 Subject: [PATCH 14/14] README: Debian 12 installation instructions --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 890cb017..060d6db6 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,13 @@ $ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz - Debian 11+ (Bullseye) + Debian 12+ (Bookworm) + + apt install age + + + + Debian 11 (Bullseye) apt install age/bullseye-backports (enable backports for age v1.0.0+)