diff --git a/cmd/age/parse.go b/cmd/age/parse.go index 4a59e7a4..70dcd86d 100644 --- a/cmd/age/parse.go +++ b/cmd/age/parse.go @@ -16,6 +16,7 @@ import ( "filippo.io/age/agessh" "filippo.io/age/armor" "filippo.io/age/plugin" + "filippo.io/age/tag" "golang.org/x/crypto/cryptobyte" "golang.org/x/crypto/ssh" ) @@ -30,6 +31,8 @@ func (gitHubRecipientError) Error() string { func parseRecipient(arg string) (age.Recipient, error) { switch { + case strings.HasPrefix(arg, "age1tag1") || strings.HasPrefix(arg, "age1tagpq1"): + return tag.ParseRecipient(arg) case strings.HasPrefix(arg, "age1") && strings.Count(arg, "1") > 1: return plugin.NewRecipient(arg, pluginTerminalUI) case strings.HasPrefix(arg, "age1"): diff --git a/go.mod b/go.mod index 31df01fe..0a20bc19 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,18 @@ module filippo.io/age -go 1.19 +go 1.24.0 require ( filippo.io/edwards25519 v1.1.0 - golang.org/x/crypto v0.24.0 - golang.org/x/sys v0.21.0 - golang.org/x/term v0.21.0 + filippo.io/hpke v0.2.0 + filippo.io/nistec v0.0.3 + golang.org/x/crypto v0.41.0 + golang.org/x/sys v0.35.0 + golang.org/x/term v0.34.0 ) +require filippo.io/bigmod v0.1.0 // indirect + // Test dependencies. require ( c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 diff --git a/go.sum b/go.sum index fd0f776d..557b1afa 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,20 @@ c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0= c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= +filippo.io/bigmod v0.1.0 h1:UNzDk7y9ADKST+axd9skUpBQeW7fG2KrTZyOE4uGQy8= +filippo.io/bigmod v0.1.0/go.mod h1:OjOXDNlClLblvXdwgFFOQFJEocLhhtai8vGLy0JCZlI= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/hpke v0.2.0 h1:CyMUXx5gHxxCciek5DtzCXDGy2+kOag0GFxXSH0nD0I= +filippo.io/hpke v0.2.0/go.mod h1:Kn5Q71LEUiHIGCCdOm3JXhj2taQWEnAJUKFnO8OzSKs= +filippo.io/nistec v0.0.3 h1:h336Je2jRDZdBCLy2fLDUd9E2unG32JLwcJi0JQE9Cw= +filippo.io/nistec v0.0.3/go.mod h1:84fxC9mi+MhC2AERXI4LSa8cmSVOzrFikg6hZ4IfCyw= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= -golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= -golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= diff --git a/tag/tag.go b/tag/tag.go new file mode 100644 index 00000000..6f5df1fa --- /dev/null +++ b/tag/tag.go @@ -0,0 +1,152 @@ +// Copyright 2025 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 tag + +import ( + "crypto/ecdh" + "crypto/hkdf" + "crypto/mlkem" + "crypto/sha256" + "fmt" + + "filippo.io/age" + "filippo.io/age/internal/format" + "filippo.io/age/plugin" + "filippo.io/hpke" + "filippo.io/nistec" +) + +type Recipient struct { + kem hpke.KEMSender + + mlkem *mlkem.EncapsulationKey768 + compressed [compressedPointSize]byte + uncompressed [uncompressedPointSize]byte +} + +var _ age.Recipient = &Recipient{} + +// ParseRecipient returns a new [Recipient] from a Bech32 public key +// encoding with the "age1tag1" or "age1tagpq1" prefix. +func ParseRecipient(s string) (*Recipient, error) { + t, k, err := plugin.ParseRecipient(s) + if err != nil { + return nil, fmt.Errorf("malformed recipient %q: %v", s, err) + } + switch t { + case "tag": + r, err := NewRecipient(k) + if err != nil { + return nil, fmt.Errorf("malformed recipient %q: %v", s, err) + } + return r, nil + case "tagpq": + r, err := NewHybridRecipient(k) + if err != nil { + return nil, fmt.Errorf("malformed recipient %q: %v", s, err) + } + return r, nil + default: + return nil, fmt.Errorf("malformed recipient %q: invalid type %q", s, t) + } +} + +const compressedPointSize = 1 + 32 +const uncompressedPointSize = 1 + 32 + 32 + +// NewRecipient returns a new [Recipient] from a raw public key. +func NewRecipient(publicKey []byte) (*Recipient, error) { + if len(publicKey) != compressedPointSize { + return nil, fmt.Errorf("invalid tag recipient public key size %d", len(publicKey)) + } + p, err := nistec.NewP256Point().SetBytes(publicKey) + if err != nil { + return nil, fmt.Errorf("invalid tag recipient public key: %v", err) + } + k, err := ecdh.P256().NewPublicKey(p.Bytes()) + if err != nil { + return nil, fmt.Errorf("invalid tag recipient public key: %v", err) + } + kem, err := hpke.DHKEMSender(k) + if err != nil { + return nil, fmt.Errorf("failed to create DHKEM sender: %v", err) + } + r := &Recipient{kem: kem} + copy(r.compressed[:], publicKey) + copy(r.uncompressed[:], p.Bytes()) + return r, nil +} + +// NewHybridRecipient returns a new [Recipient] from raw concatenated public keys. +func NewHybridRecipient(publicKey []byte) (*Recipient, error) { + if len(publicKey) != compressedPointSize+mlkem.EncapsulationKeySize768 { + return nil, fmt.Errorf("invalid tagpq recipient public key size %d", len(publicKey)) + } + p, err := nistec.NewP256Point().SetBytes(publicKey) + if err != nil { + return nil, fmt.Errorf("invalid tagpq recipient DH public key: %v", err) + } + k, err := ecdh.P256().NewPublicKey(p.Bytes()) + if err != nil { + return nil, fmt.Errorf("invalid tagpq recipient DH public key: %v", err) + } + pq, err := mlkem.NewEncapsulationKey768(publicKey[compressedPointSize:]) + if err != nil { + return nil, fmt.Errorf("invalid tagpq recipient PQ public key: %v", err) + } + kem, err := hpke.QSFSender(k, pq) + if err != nil { + return nil, fmt.Errorf("failed to create DHKEM sender: %v", err) + } + r := &Recipient{kem: kem, mlkem: pq} + copy(r.compressed[:], publicKey[:compressedPointSize]) + copy(r.uncompressed[:], p.Bytes()) + return r, nil +} + +var p256TagLabel = []byte("age-encryption.org/p256tag") +var p256MLKEM768TagLabel = []byte("age-encryption.org/p256mlkem768tag") + +func (r *Recipient) Wrap(fileKey []byte) ([]*age.Stanza, error) { + label, arg := p256TagLabel, "p256tag" + if r.mlkem != nil { + label, arg = p256MLKEM768TagLabel, "p256mlkem768tag" + } + + enc, s, err := hpke.NewSender(r.kem, + hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), label) + if err != nil { + return nil, fmt.Errorf("failed to set up HPKE sender: %v", err) + } + ct, err := s.Seal(nil, fileKey) + if err != nil { + return nil, fmt.Errorf("failed to encrypt file key: %v", err) + } + + tag, err := hkdf.Extract(sha256.New, + append(enc[:uncompressedPointSize], r.uncompressed[:]...), label) + if err != nil { + return nil, fmt.Errorf("failed to compute tag: %v", err) + } + + l := &age.Stanza{ + Type: arg, + Args: []string{ + format.EncodeToString(tag[:4]), + format.EncodeToString(enc), + }, + Body: ct, + } + + return []*age.Stanza{l}, nil +} + +// String returns the Bech32 public key encoding of r. +func (r *Recipient) String() string { + if r.mlkem != nil { + return plugin.EncodeRecipient("tagpq", append(r.compressed[:], r.mlkem.Bytes()...)) + } + return plugin.EncodeRecipient("tag", r.compressed[:]) +}