@@ -3,7 +3,8 @@ import { hkdf, extract, expand } from "@noble/hashes/hkdf.js"
33import { sha256 } from "@noble/hashes/sha2.js"
44import { scrypt } from "@noble/hashes/scrypt.js"
55import { chacha20poly1305 } from "@noble/ciphers/chacha.js"
6- import { MLKEM768X25519 } from "@noble/post-quantum/hybrid.js"
6+ import { MLKEM768P256 , MLKEM768X25519 } from "@noble/post-quantum/hybrid.js"
7+ import { p256 } from "@noble/curves/nist.js"
78import { randomBytes } from "@noble/hashes/utils.js"
89import { base64nopad } from "@scure/base"
910import * as x25519 from "./x25519.js"
@@ -148,6 +149,8 @@ export class HybridIdentity implements Identity {
148149}
149150
150151const hpkeMLKEM768X25519 = 0x647a
152+ const hpkeMLKEM768P256 = 0x0050
153+ const hpkeDHKEMP256 = 0x0010
151154
152155function hpkeContext ( kemID : number , sharedSecret : Uint8Array , info : Uint8Array ) : { key : Uint8Array ; nonce : Uint8Array } {
153156 const suiteID = hpkeSuiteID ( kemID )
@@ -206,6 +209,79 @@ function hpkeLabeledExpand(suiteID: Uint8Array, prk: Uint8Array, label: string,
206209 return expand ( sha256 , prk , labeledInfo , length )
207210}
208211
212+ function hpkeDHKEMP256Encapsulate ( recipient : Uint8Array ) : { encapsulatedKey : Uint8Array ; sharedSecret : Uint8Array } {
213+ if ( recipient . length !== p256 . lengths . publicKeyUncompressed ) {
214+ recipient = p256 . Point . fromBytes ( recipient ) . toBytes ( false )
215+ }
216+ const ephemeral = p256 . utils . randomSecretKey ( )
217+ const encapsulatedKey = p256 . getPublicKey ( ephemeral , false )
218+ const ss = p256 . getSharedSecret ( ephemeral , recipient , true ) . subarray ( 1 )
219+ const kemContext = new Uint8Array ( encapsulatedKey . length + recipient . length )
220+ kemContext . set ( encapsulatedKey , 0 )
221+ kemContext . set ( recipient , encapsulatedKey . length )
222+ const suiteID = new Uint8Array ( 5 )
223+ suiteID . set ( new TextEncoder ( ) . encode ( "KEM" ) , 0 )
224+ suiteID [ 3 ] = hpkeDHKEMP256 >> 8
225+ suiteID [ 4 ] = hpkeDHKEMP256 & 0xff
226+ const eaePRK = hpkeLabeledExtract ( suiteID , undefined , "eae_prk" , ss )
227+ const sharedSecret = hpkeLabeledExpand ( suiteID , eaePRK , "shared_secret" , kemContext , 32 )
228+ return { encapsulatedKey, sharedSecret }
229+ }
230+
231+ export class TagRecipient implements Recipient {
232+ private recipient : Uint8Array
233+
234+ constructor ( s : string ) {
235+ const res = bech32 . decodeToBytes ( s )
236+ if ( ! s . startsWith ( "age1tag1" ) ||
237+ res . prefix . toLowerCase ( ) !== "age1tag" ||
238+ res . bytes . length !== 33 ) { throw Error ( "invalid recipient" ) }
239+ this . recipient = res . bytes
240+ }
241+
242+ wrapFileKey ( fileKey : Uint8Array ) : Stanza [ ] {
243+ const { encapsulatedKey, sharedSecret } = hpkeDHKEMP256Encapsulate ( this . recipient )
244+ const label = new TextEncoder ( ) . encode ( "age-encryption.org/p256tag" )
245+ const tag = ( ( ) => {
246+ const recipientHash = sha256 ( this . recipient ) . subarray ( 0 , 4 )
247+ const ikm = new Uint8Array ( encapsulatedKey . length + recipientHash . length )
248+ ikm . set ( encapsulatedKey , 0 )
249+ ikm . set ( recipientHash , encapsulatedKey . length )
250+ return extract ( sha256 , ikm , label ) . subarray ( 0 , 4 )
251+ } ) ( )
252+ const { key, nonce } = hpkeContext ( hpkeDHKEMP256 , sharedSecret , label )
253+ const ciphertext = chacha20poly1305 ( key , nonce ) . encrypt ( fileKey )
254+ return [ new Stanza ( [ "p256tag" , base64nopad . encode ( tag ) , base64nopad . encode ( encapsulatedKey ) ] , ciphertext ) ]
255+ }
256+ }
257+
258+ export class HybridTagRecipient implements Recipient {
259+ private recipient : Uint8Array
260+
261+ constructor ( s : string ) {
262+ const res = bech32 . decodeToBytes ( s )
263+ if ( ! s . startsWith ( "age1tagpq1" ) ||
264+ res . prefix . toLowerCase ( ) !== "age1tagpq" ||
265+ res . bytes . length !== 1249 ) { throw Error ( "invalid recipient" ) }
266+ this . recipient = res . bytes
267+ }
268+
269+ wrapFileKey ( fileKey : Uint8Array ) : Stanza [ ] {
270+ const { cipherText : encapsulatedKey , sharedSecret } = MLKEM768P256 . encapsulate ( this . recipient )
271+ const label = new TextEncoder ( ) . encode ( "age-encryption.org/mlkem768p256tag" )
272+ const tag = ( ( ) => {
273+ const recipientHash = sha256 ( this . recipient . subarray ( 1184 ) ) . subarray ( 0 , 4 )
274+ const ikm = new Uint8Array ( encapsulatedKey . length + recipientHash . length )
275+ ikm . set ( encapsulatedKey , 0 )
276+ ikm . set ( recipientHash , encapsulatedKey . length )
277+ return extract ( sha256 , ikm , label ) . subarray ( 0 , 4 )
278+ } ) ( )
279+ const { key, nonce } = hpkeContext ( hpkeMLKEM768P256 , sharedSecret , label )
280+ const ciphertext = chacha20poly1305 ( key , nonce ) . encrypt ( fileKey )
281+ return [ new Stanza ( [ "mlkem768p256tag" , base64nopad . encode ( tag ) , base64nopad . encode ( encapsulatedKey ) ] , ciphertext ) ]
282+ }
283+ }
284+
209285export class X25519Recipient implements Recipient {
210286 private recipient : Uint8Array
211287
0 commit comments