simple-hpke 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,6 +38,9 @@ the plaintext is.
38
38
  - [`decrypt`](#decrypt)
39
39
  - [`decrypt.asString`](#decryptasstring)
40
40
  - [`decrypt.fromString`](#decryptfromstring)
41
+ - [Extractable keys](#extractable-keys)
42
+ - [`keyId`](#keyid)
43
+ - [Errors](#errors)
41
44
  - [Modules](#modules)
42
45
  * [ESM](#esm)
43
46
  * [Common JS](#common-js)
@@ -77,20 +80,24 @@ const keypair = await crypto.subtle.generateKey(
77
80
 
78
81
  // Create a new AES key and encrypt it to your public key.
79
82
  // `key` is the unencrypted new AES key
80
- // `enc` is the AES key encrypted to the given public key
81
- const { enc, key } = await create(keypair.publicKey) // or a buffer or string
83
+ // `wrapped` is the AES key encrypted to the given public key
84
+ const { wrapped, key } = await create(keypair.publicKey) // or bytes or string
85
+
86
+ // `key` is non-extractable by default. It still encrypts and decrypts;
87
+ // it just cannot be exported. Pass `{ extractable: true }` if you need
88
+ // the raw bytes. See [Extractable keys](#extractable-keys).
82
89
 
83
90
  //
84
91
  // Or wrap an existing AES key. The supplied AES key must be extractable.
85
92
  //
86
93
  const aesKey = await crypto.subtle.generateKey(
87
94
  { name: 'AES-GCM', length: 256 },
88
- true, // not extractable
95
+ true, // extractable -- its raw bytes are what get sealed
89
96
  ['encrypt', 'decrypt']
90
97
  )
91
98
 
92
99
  // Wrap the existing key.
93
- const { enc: wrappedKey } = await encryptKey(keypair, aesKey)
100
+ const { wrapped: wrappedKey } = await encryptKey(keypair, aesKey)
94
101
 
95
102
  // or pass in just a public key
96
103
 
@@ -121,7 +128,7 @@ const recipient = await crypto.subtle.generateKey(
121
128
  )
122
129
 
123
130
  // Create a fresh AES-GCM key, and encrypt a message with it.
124
- const { enc, key } = await create(recipient)
131
+ const { wrapped, key } = await create(recipient)
125
132
  const iv = crypto.getRandomValues(new Uint8Array(12))
126
133
  const ciphertextBuffer = await crypto.subtle.encrypt(
127
134
  { name: 'AES-GCM', iv },
@@ -129,25 +136,28 @@ const ciphertextBuffer = await crypto.subtle.encrypt(
129
136
  new TextEncoder().encode('attack at dawn')
130
137
  )
131
138
 
132
- // Send `enc`, `iv`, and `ciphertext` together. `create(...)` defaults to a
133
- // 256-bit AES key, so `enc.length` is 80 bytes in this example.
139
+ // Send `wrapped`, `iv`, and `ciphertext` together. `create(...)` defaults
140
+ // to a 256-bit AES key, so `wrapped.length` is 80 bytes in this example.
134
141
  //
135
- // If your protocol allows different wrapped-key sizes, prefix `enc.length`
136
- // (or otherwise transmit it) so the recipient can split the payload safely.
142
+ // If your protocol allows different wrapped-key sizes, prefix
143
+ // `wrapped.length` (or otherwise transmit it) so the recipient can split
144
+ // the payload safely.
137
145
  const ciphertext = new Uint8Array(ciphertextBuffer)
138
- const encLength = enc.length
139
- const message = new Uint8Array(encLength + iv.length + ciphertext.length)
140
- message.set(enc, 0)
141
- message.set(iv, encLength)
142
- message.set(ciphertext, encLength + iv.length)
146
+ const wrappedLength = wrapped.length
147
+ const message = new Uint8Array(
148
+ wrappedLength + iv.length + ciphertext.length
149
+ )
150
+ message.set(wrapped, 0)
151
+ message.set(iv, wrappedLength)
152
+ message.set(ciphertext, wrappedLength + iv.length)
143
153
 
144
154
  // On the other side, split the payload back into its parts.
145
- const enc2 = message.subarray(0, encLength)
146
- const iv2 = message.subarray(encLength, encLength + 12)
147
- const ciphertext2 = message.subarray(encLength + 12)
155
+ const wrapped2 = message.subarray(0, wrappedLength)
156
+ const iv2 = message.subarray(wrappedLength, wrappedLength + 12)
157
+ const ciphertext2 = message.subarray(wrappedLength + 12)
148
158
 
149
159
  // Recover the key, then decrypt the message.
150
- const recovered = await open(recipient, enc2)
160
+ const recovered = await open(recipient, wrapped2)
151
161
  const plaintext = await crypto.subtle.decrypt(
152
162
  { name: 'AES-GCM', iv: iv2 },
153
163
  recovered,
@@ -334,6 +344,100 @@ decrypt.fromString = async function decryptFromString (
334
344
 
335
345
  ---------------------------------------------------------------
336
346
 
347
+ ## Extractable keys
348
+
349
+ `create`, `encryptKey`, and `open` return a **non-extractable** AES-GCM
350
+ `CryptoKey` by default. It encrypts and decrypts normally; it just cannot
351
+ be exported, so its raw bytes cannot leak out of the runtime.
352
+
353
+ Opt in when you genuinely need the bytes:
354
+
355
+ ```ts
356
+ const { wrapped, key } = await create(recipient, { extractable: true })
357
+ const bytes = new Uint8Array(await crypto.subtle.exportKey('raw', key))
358
+
359
+ // same option on the way back out
360
+ const recovered = await open(keypair, wrapped, { extractable: true })
361
+ ```
362
+
363
+ This is separate from the `aesKey` you may pass **in** to `encryptKey` /
364
+ `encrypt`. That one must always be extractable, because its raw bytes are
365
+ what get sealed.
366
+
367
+ If you only need to tell two keys apart, you do not need the bytes --
368
+ use [`keyId`](#keyid).
369
+
370
+ ## `keyId`
371
+
372
+ A stable fingerprint for an AES-GCM key, so you can ask "is this the key I
373
+ have?" without holding its bytes.
374
+
375
+ ```ts
376
+ async function keyId (key:CryptoKey):Promise<string>
377
+ ```
378
+
379
+ ```ts
380
+ import { create, open, keyId } from 'simple-hpke'
381
+
382
+ const { wrapped, key } = await create(recipient)
383
+ const id = await keyId(key) // 43-char base64url string
384
+
385
+ const recovered = await open(keypair, wrapped)
386
+ await keyId(recovered) === id // true
387
+ ```
388
+
389
+ It works on a **non-extractable** key -- including one rehydrated from
390
+ IndexedDB -- which is the whole point: the obvious implementation
391
+ (`exportKey('raw')` then SHA-256) would force every key you want to
392
+ identify to be extractable.
393
+
394
+ The derivation is fixed, and is treated as wire format:
395
+
396
+ ```
397
+ LABEL = utf8("simple-hpke/keyId/v1")
398
+ N = SHA-256(LABEL)[0..12] // fixed nonce
399
+ C = AES-GCM(key, N, LABEL) // ciphertext || 16-byte tag
400
+ keyId = base64url(SHA-256(LABEL || C))
401
+ ```
402
+
403
+ `key` must be an AES-GCM key with the `encrypt` usage; anything else
404
+ throws [`ERR_INVALID_AES_KEY`](#errors).
405
+
406
+ ## Errors
407
+
408
+ Every error this package throws is an `HpkeError`: an ordinary `Error`
409
+ with a stable `code`. Branch on the code, not on the message text -- the
410
+ codes are part of the API, the prose is not. Where a WebCrypto failure is
411
+ being wrapped, the original is kept on `cause`.
412
+
413
+ ```ts
414
+ import { decrypt, HpkeError } from 'simple-hpke'
415
+
416
+ try {
417
+ await decrypt(keypair, envelope)
418
+ } catch (err) {
419
+ if (err instanceof HpkeError && err.code === 'ERR_DECRYPT_FAILED') {
420
+ // wrong keypair, wrong `info`, or a tampered envelope
421
+ }
422
+ throw err
423
+ }
424
+ ```
425
+
426
+ | Code | Raised when |
427
+ | --- | --- |
428
+ | `ERR_INVALID_RECIPIENT_KEY` | The recipient is not a usable X25519 public key: wrong length, a private key, the wrong algorithm, or an unrecognized form. |
429
+ | `ERR_INVALID_KEYPAIR` | `keypair.privateKey` is not an X25519 private key. |
430
+ | `ERR_SMALL_ORDER_KEY` | The X25519 shared secret came out all-zero (a small-order public key). Conforming runtimes reject this first. |
431
+ | `ERR_MALFORMED_ENVELOPE` | An `open` / `open.raw` envelope is too short to hold `enc` plus an AEAD tag. |
432
+ | `ERR_MALFORMED_MESSAGE` | A `decrypt` envelope is too short to hold its own declared segments. |
433
+ | `ERR_INVALID_KEYSIZE` | `opts.size` is neither `128` nor `256`. |
434
+ | `ERR_INVALID_AES_KEY` | A supplied `aesKey` is the wrong length or non-extractable, or `keyId` got a key it cannot use. |
435
+ | `ERR_DECRYPT_FAILED` | AES-GCM authentication failed: wrong keypair, wrong `info`, or a tampered ciphertext. |
436
+
437
+ `HpkeErrorCode` is exported as a type if you want to switch exhaustively.
438
+
439
+ ---------------------------------------------------------------
440
+
337
441
  ## Modules
338
442
 
339
443
  This exposes ESM and common JS via
@@ -341,7 +445,15 @@ This exposes ESM and common JS via
341
445
 
342
446
  ### ESM
343
447
  ```js
344
- import { create, encryptKey, open, encrypt, decrypt } from 'simple-hpke'
448
+ import {
449
+ create,
450
+ encryptKey,
451
+ open,
452
+ encrypt,
453
+ decrypt,
454
+ keyId,
455
+ HpkeError
456
+ } from 'simple-hpke'
345
457
  ```
346
458
 
347
459
  ### Common JS
@@ -29,6 +29,8 @@ __export(constants_exports, {
29
29
  KDF_ID: () => KDF_ID,
30
30
  KEM_ID: () => KEM_ID,
31
31
  KEM_SUITE_ID: () => KEM_SUITE_ID,
32
+ KEY_ID_LABEL: () => KEY_ID_LABEL,
33
+ KEY_ID_NONCE_LENGTH: () => KEY_ID_NONCE_LENGTH,
32
34
  MODE_BASE: () => MODE_BASE,
33
35
  NK: () => NK,
34
36
  NN: () => NN,
@@ -1315,6 +1317,19 @@ function toString2(array, encoding = "utf8") {
1315
1317
  }
1316
1318
  __name(toString2, "toString");
1317
1319
 
1320
+ // src/errors.ts
1321
+ var HpkeError = class extends Error {
1322
+ static {
1323
+ __name(this, "HpkeError");
1324
+ }
1325
+ code;
1326
+ constructor(code2, message, opts) {
1327
+ super(message, opts);
1328
+ this.name = "HpkeError";
1329
+ this.code = code2;
1330
+ }
1331
+ };
1332
+
1318
1333
  // src/index.ts
1319
1334
  var subtle = globalThis.crypto.subtle;
1320
1335
  var open = Object.assign(openBytes, {
@@ -1329,13 +1344,19 @@ var decrypt = Object.assign(decryptBytes, {
1329
1344
  });
1330
1345
  async function decryptBytes(keypair, message, opts) {
1331
1346
  if (message.byteLength < WRAPPED_LEN_PREFIX) {
1332
- throw new Error("malformed message: too short");
1347
+ throw new HpkeError(
1348
+ "ERR_MALFORMED_MESSAGE",
1349
+ "malformed message: too short"
1350
+ );
1333
1351
  }
1334
1352
  const wrappedLen = message[0] << 8 | message[1];
1335
1353
  const ivStart = WRAPPED_LEN_PREFIX + wrappedLen;
1336
1354
  const ctStart = ivStart + NN;
1337
1355
  if (message.byteLength < ctStart + AEAD_TAG_LENGTH) {
1338
- throw new Error("malformed message: too short");
1356
+ throw new HpkeError(
1357
+ "ERR_MALFORMED_MESSAGE",
1358
+ "malformed message: too short"
1359
+ );
1339
1360
  }
1340
1361
  const wrapped = message.slice(WRAPPED_LEN_PREFIX, ivStart);
1341
1362
  const iv = message.slice(ivStart, ctStart);
@@ -1346,11 +1367,20 @@ async function decryptBytes(keypair, message, opts) {
1346
1367
  opts?.info !== void 0 ? { info: opts.info } : void 0
1347
1368
  );
1348
1369
  const key = await importAesKeyDecryptOnly(keyBytes);
1349
- const pt = await subtle.decrypt(
1350
- { name: "AES-GCM", iv },
1351
- key,
1352
- ciphertext
1353
- );
1370
+ let pt;
1371
+ try {
1372
+ pt = await subtle.decrypt(
1373
+ { name: "AES-GCM", iv },
1374
+ key,
1375
+ ciphertext
1376
+ );
1377
+ } catch (err) {
1378
+ throw new HpkeError(
1379
+ "ERR_DECRYPT_FAILED",
1380
+ "failed to decrypt the message: tampered ciphertext",
1381
+ { cause: err }
1382
+ );
1383
+ }
1354
1384
  return new Uint8Array(pt);
1355
1385
  }
1356
1386
  __name(decryptBytes, "decryptBytes");
@@ -1371,7 +1401,8 @@ async function decryptFromString(keypair, message, opts) {
1371
1401
  __name(decryptFromString, "decryptFromString");
1372
1402
  function validateKeysize(keysize) {
1373
1403
  if (keysize !== 128 && keysize !== 256) {
1374
- throw new Error(
1404
+ throw new HpkeError(
1405
+ "ERR_INVALID_KEYSIZE",
1375
1406
  `invalid keysize: ${keysize} (expected 128 or 256)`
1376
1407
  );
1377
1408
  }
@@ -1379,7 +1410,8 @@ function validateKeysize(keysize) {
1379
1410
  __name(validateKeysize, "validateKeysize");
1380
1411
  function validateRawKeyBytes(raw) {
1381
1412
  if (raw.length !== 16 && raw.length !== 32) {
1382
- throw new Error(
1413
+ throw new HpkeError(
1414
+ "ERR_INVALID_AES_KEY",
1383
1415
  `invalid aesKey length: ${raw.length} bytes (expected 16 or 32)`
1384
1416
  );
1385
1417
  }
@@ -1393,19 +1425,20 @@ function normalizeInfo(info) {
1393
1425
  __name(normalizeInfo, "normalizeInfo");
1394
1426
  async function exportAesKeyBytes(key) {
1395
1427
  if (!key.extractable) {
1396
- throw new Error(
1428
+ throw new HpkeError(
1429
+ "ERR_INVALID_AES_KEY",
1397
1430
  "aesKey must be extractable: its raw bytes are what get sealed"
1398
1431
  );
1399
1432
  }
1400
1433
  return new Uint8Array(await subtle.exportKey("raw", key));
1401
1434
  }
1402
1435
  __name(exportAesKeyBytes, "exportAesKeyBytes");
1403
- async function importAesKey(raw) {
1436
+ async function importAesKey(raw, extractable) {
1404
1437
  return subtle.importKey(
1405
1438
  "raw",
1406
1439
  raw,
1407
1440
  { name: "AES-GCM" },
1408
- true,
1441
+ extractable,
1409
1442
  ["encrypt", "decrypt"]
1410
1443
  );
1411
1444
  }
@@ -1460,7 +1493,10 @@ async function keySchedule(sharedSecret, info) {
1460
1493
  __name(keySchedule, "keySchedule");
1461
1494
  async function openRawBytes(keypair, wrapped, opts) {
1462
1495
  if (wrapped.byteLength < ENC_LENGTH + AEAD_TAG_LENGTH) {
1463
- throw new Error("malformed envelope: too short");
1496
+ throw new HpkeError(
1497
+ "ERR_MALFORMED_ENVELOPE",
1498
+ "malformed envelope: too short"
1499
+ );
1464
1500
  }
1465
1501
  const info = normalizeInfo(opts?.info);
1466
1502
  const enc = wrapped.slice(0, ENC_LENGTH);
@@ -1472,7 +1508,7 @@ async function openRawBytes(keypair, wrapped, opts) {
1472
1508
  __name(openRawBytes, "openRawBytes");
1473
1509
  async function openBytes(keypair, wrapped, opts) {
1474
1510
  const keyBytes = await openRawBytes(keypair, wrapped, opts);
1475
- return importAesKey(keyBytes);
1511
+ return importAesKey(keyBytes, opts?.extractable ?? false);
1476
1512
  }
1477
1513
  __name(openBytes, "openBytes");
1478
1514
  async function encryptKey(recipient, aesKey, opts) {
@@ -1496,8 +1532,11 @@ async function encryptKey(recipient, aesKey, opts) {
1496
1532
  const { key, baseNonce } = await keySchedule(sharedSecret, info);
1497
1533
  const ciphertext = await aeadSeal(key, baseNonce, keyBytes);
1498
1534
  const wrapped = concat2(enc, ciphertext);
1499
- const aesGcmKey = await importAesKey(keyBytes);
1500
- return { enc: wrapped, key: aesGcmKey };
1535
+ const aesGcmKey = await importAesKey(
1536
+ keyBytes,
1537
+ opts?.extractable ?? false
1538
+ );
1539
+ return { wrapped, key: aesGcmKey };
1501
1540
  }
1502
1541
  __name(encryptKey, "encryptKey");
1503
1542
 
@@ -1527,24 +1566,38 @@ async function aeadOpen(key, nonce, ciphertext) {
1527
1566
  false,
1528
1567
  ["decrypt"]
1529
1568
  );
1530
- const pt = await subtle2.decrypt(
1531
- { name: "AES-GCM", iv: nonce },
1532
- k,
1533
- ciphertext
1534
- );
1569
+ let pt;
1570
+ try {
1571
+ pt = await subtle2.decrypt(
1572
+ { name: "AES-GCM", iv: nonce },
1573
+ k,
1574
+ ciphertext
1575
+ );
1576
+ } catch (err) {
1577
+ throw new HpkeError(
1578
+ "ERR_DECRYPT_FAILED",
1579
+ "failed to decrypt the wrapped key: wrong keypair, wrong info, or a tampered envelope",
1580
+ { cause: err }
1581
+ );
1582
+ }
1535
1583
  return new Uint8Array(pt);
1536
1584
  }
1537
1585
  __name(aeadOpen, "aeadOpen");
1538
1586
  async function encryptBytes(recipient, message, aesKey, opts) {
1539
1587
  const plaintext = typeof message === "string" ? new TextEncoder().encode(message) : message;
1540
- const { enc, key } = await encryptKey(recipient, aesKey, opts);
1588
+ const { wrapped, key } = await encryptKey(recipient, aesKey, opts);
1541
1589
  const iv = globalThis.crypto.getRandomValues(new Uint8Array(NN));
1542
1590
  const ct = new Uint8Array(await subtle2.encrypt(
1543
1591
  { name: "AES-GCM", iv },
1544
1592
  key,
1545
1593
  plaintext
1546
1594
  ));
1547
- return concat2(i2osp(enc.length, WRAPPED_LEN_PREFIX), enc, iv, ct);
1595
+ return concat2(
1596
+ i2osp(wrapped.length, WRAPPED_LEN_PREFIX),
1597
+ wrapped,
1598
+ iv,
1599
+ ct
1600
+ );
1548
1601
  }
1549
1602
  __name(encryptBytes, "encryptBytes");
1550
1603
  async function encryptToString(recipient, message, aesKey, opts) {
@@ -1610,7 +1663,8 @@ async function exportRawPublic(key) {
1610
1663
  __name(exportRawPublic, "exportRawPublic");
1611
1664
  async function importRawPublic(raw) {
1612
1665
  if (raw.length !== ENC_LENGTH) {
1613
- throw new Error(
1666
+ throw new HpkeError(
1667
+ "ERR_INVALID_RECIPIENT_KEY",
1614
1668
  `invalid public key length: ${raw.length} bytes (expected ${ENC_LENGTH})`
1615
1669
  );
1616
1670
  }
@@ -1629,9 +1683,16 @@ async function resolveRecipientPublicKey(recipient) {
1629
1683
  }
1630
1684
  if (recipient instanceof CryptoKey) {
1631
1685
  if (recipient.type !== "public") {
1632
- throw new Error("recipient CryptoKey must be a public key");
1686
+ throw new HpkeError(
1687
+ "ERR_INVALID_RECIPIENT_KEY",
1688
+ "recipient CryptoKey must be a public key"
1689
+ );
1633
1690
  }
1634
- assertX25519Algorithm(recipient, "recipient CryptoKey");
1691
+ assertX25519Algorithm(
1692
+ recipient,
1693
+ "recipient CryptoKey",
1694
+ "ERR_INVALID_RECIPIENT_KEY"
1695
+ );
1635
1696
  return recipient;
1636
1697
  }
1637
1698
  if (typeof recipient.publicKey === "string") {
@@ -1641,17 +1702,28 @@ async function resolveRecipientPublicKey(recipient) {
1641
1702
  const pair = recipient;
1642
1703
  if (pair.publicKey instanceof CryptoKey) {
1643
1704
  if (pair.publicKey.type !== "public") {
1644
- throw new Error("recipient keypair publicKey must be a public key");
1705
+ throw new HpkeError(
1706
+ "ERR_INVALID_RECIPIENT_KEY",
1707
+ "recipient keypair publicKey must be a public key"
1708
+ );
1645
1709
  }
1646
- assertX25519Algorithm(pair.publicKey, "recipient keypair publicKey");
1710
+ assertX25519Algorithm(
1711
+ pair.publicKey,
1712
+ "recipient keypair publicKey",
1713
+ "ERR_INVALID_RECIPIENT_KEY"
1714
+ );
1647
1715
  return pair.publicKey;
1648
1716
  }
1649
- throw new Error("unrecognized recipient key form");
1717
+ throw new HpkeError(
1718
+ "ERR_INVALID_RECIPIENT_KEY",
1719
+ "unrecognized recipient key form"
1720
+ );
1650
1721
  }
1651
1722
  __name(resolveRecipientPublicKey, "resolveRecipientPublicKey");
1652
- function assertX25519Algorithm(key, context) {
1723
+ function assertX25519Algorithm(key, context, code2) {
1653
1724
  if (key.algorithm.name !== "X25519") {
1654
- throw new Error(
1725
+ throw new HpkeError(
1726
+ code2,
1655
1727
  `${context} must be an X25519 key (got ${key.algorithm.name})`
1656
1728
  );
1657
1729
  }
@@ -1665,7 +1737,8 @@ async function dh(priv, pub) {
1665
1737
  );
1666
1738
  const bytes = new Uint8Array(bits);
1667
1739
  if (bytes.every((b) => b === 0)) {
1668
- throw new Error(
1740
+ throw new HpkeError(
1741
+ "ERR_SMALL_ORDER_KEY",
1669
1742
  "X25519 shared secret is all-zero (small-order public key?)"
1670
1743
  );
1671
1744
  }
@@ -1704,9 +1777,16 @@ async function encap(pkR) {
1704
1777
  __name(encap, "encap");
1705
1778
  async function decap(enc, keypair) {
1706
1779
  if (keypair.privateKey.type !== "private") {
1707
- throw new Error("keypair.privateKey must be a private key");
1780
+ throw new HpkeError(
1781
+ "ERR_INVALID_KEYPAIR",
1782
+ "keypair.privateKey must be a private key"
1783
+ );
1708
1784
  }
1709
- assertX25519Algorithm(keypair.privateKey, "keypair.privateKey");
1785
+ assertX25519Algorithm(
1786
+ keypair.privateKey,
1787
+ "keypair.privateKey",
1788
+ "ERR_INVALID_KEYPAIR"
1789
+ );
1710
1790
  const pkE = await subtle2.importKey(
1711
1791
  "raw",
1712
1792
  enc,
@@ -1765,4 +1845,6 @@ var HPKE_SUITE_ID = concat2(
1765
1845
  i2osp(KDF_ID, 2),
1766
1846
  i2osp(AEAD_ID, 2)
1767
1847
  );
1848
+ var KEY_ID_LABEL = new TextEncoder().encode("simple-hpke/keyId/v1");
1849
+ var KEY_ID_NONCE_LENGTH = 12;
1768
1850
  //# sourceMappingURL=constants.cjs.map