@@ -18,6 +18,7 @@ import (
1818 "crypto/mlkem"
1919 "crypto/sha256"
2020 "fmt"
21+ "slices"
2122
2223 "filippo.io/age"
2324 "filippo.io/age/internal/format"
@@ -63,6 +64,7 @@ func ParseRecipient(s string) (*Recipient, error) {
6364}
6465
6566const compressedPointSize = 1 + 32
67+ const uncompressedPointSize = 1 + 32 + 32
6668
6769// NewClassicRecipient returns a new P-256 [Recipient] from a raw public key.
6870func NewClassicRecipient (publicKey []byte ) (* Recipient , error ) {
@@ -100,6 +102,30 @@ func (r *Recipient) Wrap(fileKey []byte) ([]*age.Stanza, error) {
100102 return s , err
101103}
102104
105+ // Tag computes the 4-byte tag for the given ciphertext enc.
106+ //
107+ // This is a low-level method exposed for use by plugins that implement
108+ // identities compatible with tagged recipients.
109+ func (r * Recipient ) Tag (enc []byte ) ([]byte , error ) {
110+ label , tagRecipient := "age-encryption.org/p256tag" , r .Bytes ()
111+ if r .Hybrid () {
112+ label = "age-encryption.org/mlkem768p256tag"
113+ // In hybrid mode, the tag is computed over just the P-256 part.
114+ tagRecipient = tagRecipient [mlkem .EncapsulationKeySize768 :]
115+ if len (enc ) != mlkem .CiphertextSize768 + uncompressedPointSize {
116+ return nil , fmt .Errorf ("invalid ciphertext size" )
117+ }
118+ } else if len (enc ) != uncompressedPointSize {
119+ return nil , fmt .Errorf ("invalid ciphertext size" )
120+ }
121+ rh := sha256 .Sum256 (tagRecipient )
122+ tag , err := hkdf .Extract (sha256 .New , append (slices .Clip (enc ), rh [:4 ]... ), []byte (label ))
123+ if err != nil {
124+ return nil , fmt .Errorf ("failed to compute tag: %v" , err )
125+ }
126+ return tag [:4 ], nil
127+ }
128+
103129// WrapWithLabels implements [age.RecipientWithLabels], returning a single
104130// "postquantum" label if r is a hybrid P-256 + ML-KEM-768 recipient. This
105131// ensures a hybrid Recipient can't be mixed with other recipients that would
@@ -122,13 +148,7 @@ func (r *Recipient) WrapWithLabels(fileKey []byte) ([]*age.Stanza, []string, err
122148 return nil , nil , fmt .Errorf ("failed to encrypt file key: %v" , err )
123149 }
124150
125- tagEnc , tagRecipient := enc , r .pk .Bytes ()
126- if r .Hybrid () {
127- // In hybrid mode, the tag is computed over just the P-256 part.
128- tagEnc = enc [mlkem .CiphertextSize768 :]
129- tagRecipient = tagRecipient [mlkem .EncapsulationKeySize768 :]
130- }
131- tag , err := hkdf .Extract (sha256 .New , append (tagEnc , tagRecipient ... ), []byte (label ))
151+ tag , err := r .Tag (enc )
132152 if err != nil {
133153 return nil , nil , fmt .Errorf ("failed to compute tag: %v" , err )
134154 }
@@ -148,14 +168,22 @@ func (r *Recipient) WrapWithLabels(fileKey []byte) ([]*age.Stanza, []string, err
148168 return []* age.Stanza {l }, nil , nil
149169}
150170
151- // String returns the Bech32 public key encoding of r .
152- func (r * Recipient ) String () string {
171+ // Bytes returns the raw recipient encoding.
172+ func (r * Recipient ) Bytes () [] byte {
153173 if r .Hybrid () {
154- return plugin . EncodeRecipient ( "tagpq" , r .pk .Bytes () )
174+ return r .pk .Bytes ()
155175 }
156176 p , err := nistec .NewP256Point ().SetBytes (r .pk .Bytes ())
157177 if err != nil {
158178 panic ("internal error: invalid P-256 public key" )
159179 }
160- return plugin .EncodeRecipient ("tag" , p .BytesCompressed ())
180+ return p .BytesCompressed ()
181+ }
182+
183+ // String returns the Bech32 public key encoding of r.
184+ func (r * Recipient ) String () string {
185+ if r .Hybrid () {
186+ return plugin .EncodeRecipient ("tagpq" , r .Bytes ())
187+ }
188+ return plugin .EncodeRecipient ("tag" , r .Bytes ())
161189}
0 commit comments