Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/age/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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"):
Expand Down
12 changes: 8 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
18 changes: 12 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
152 changes: 152 additions & 0 deletions tag/tag.go
Original file line number Diff line number Diff line change
@@ -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[:])
}
Loading