diff --git a/age.go b/age.go index a0ef0bb..c9b17bc 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 3ae95bf..8cf6867 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 53eb799..9291829 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 13f1712..d382a0c 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 9bcb745..98da6be 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 a45587f..dca1a52 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 0000000..c4cad60 --- /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 1346ad1..73d13b7 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