Skip to content

Commit 5b09ea9

Browse files
committed
Implement age1tag1 and age1tagpq1 recipients
1 parent 7218c6c commit 5b09ea9

File tree

2 files changed

+89
-2
lines changed

2 files changed

+89
-2
lines changed

lib/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { hmac } from "@noble/hashes/hmac.js"
22
import { hkdf } from "@noble/hashes/hkdf.js"
33
import { sha256 } from "@noble/hashes/sha2.js"
44
import { randomBytes } from "@noble/hashes/utils.js"
5-
import { HybridIdentity, HybridRecipient, ScryptIdentity, ScryptRecipient, X25519Identity, X25519Recipient } from "./recipients.js"
5+
import { HybridIdentity, HybridRecipient } from "./recipients.js"
6+
import { ScryptIdentity, ScryptRecipient } from "./recipients.js"
7+
import { X25519Identity, X25519Recipient } from "./recipients.js"
8+
import { TagRecipient, HybridTagRecipient } from "./recipients.js"
69
import { encodeHeader, encodeHeaderNoMAC, parseHeader, Stanza } from "./format.js"
710
import { ciphertextSize, decryptSTREAM, encryptSTREAM, plaintextSize } from "./stream.js"
811
import { readAll, stream, read, readAllString, prepend } from "./io.js"
@@ -138,6 +141,10 @@ export class Encrypter {
138141
* Add a recipient to encrypt the file(s) for. This method can be called
139142
* multiple times to encrypt the file(s) for multiple recipients.
140143
*
144+
* This version supports native X25519 recipients (`age1...`), hybrid
145+
* post-quantum recipients (`age1pq1...`), tag recipients (`age1tag1...`),
146+
* and hybrid tag recipients (`age1tagpq1...`).
147+
*
141148
* @param s - The recipient to encrypt the file for. Either a string
142149
* beginning with `age1...` or an object implementing the {@link Recipient}
143150
* interface.
@@ -150,6 +157,10 @@ export class Encrypter {
150157
if (typeof s === "string") {
151158
if (s.startsWith("age1pq1")) {
152159
this.recipients.push(new HybridRecipient(s))
160+
} else if (s.startsWith("age1tag1")) {
161+
this.recipients.push(new TagRecipient(s))
162+
} else if (s.startsWith("age1tagpq1")) {
163+
this.recipients.push(new HybridTagRecipient(s))
153164
} else if (s.startsWith("age1")) {
154165
this.recipients.push(new X25519Recipient(s))
155166
} else {

lib/recipients.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { hkdf, extract, expand } from "@noble/hashes/hkdf.js"
33
import { sha256 } from "@noble/hashes/sha2.js"
44
import { scrypt } from "@noble/hashes/scrypt.js"
55
import { 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"
78
import { randomBytes } from "@noble/hashes/utils.js"
89
import { base64nopad } from "@scure/base"
910
import * as x25519 from "./x25519.js"
@@ -148,6 +149,8 @@ export class HybridIdentity implements Identity {
148149
}
149150

150151
const hpkeMLKEM768X25519 = 0x647a
152+
const hpkeMLKEM768P256 = 0x0050
153+
const hpkeDHKEMP256 = 0x0010
151154

152155
function 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+
209285
export class X25519Recipient implements Recipient {
210286
private recipient: Uint8Array
211287

0 commit comments

Comments
 (0)