simple-hpke 0.3.1 → 0.3.3

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
@@ -31,6 +31,7 @@ the plaintext is.
31
31
  - [Install](#install)
32
32
  - [Examples](#examples)
33
33
  * [Key Wrapping](#key-wrapping)
34
+ + [`open.raw`](#openraw)
34
35
  * [Hybrid Encryption](#hybrid-encryption)
35
36
  + [Encrypt / Decrypt](#encrypt--decrypt)
36
37
  - [`encrypt`](#encrypt)
@@ -40,6 +41,7 @@ the plaintext is.
40
41
  - [`decrypt.fromString`](#decryptfromstring)
41
42
  - [Extractable keys](#extractable-keys)
42
43
  - [`keyId`](#keyid)
44
+ * [`verifyKeyId`](#verifykeyid)
43
45
  - [Errors](#errors)
44
46
  - [Modules](#modules)
45
47
  * [ESM](#esm)
@@ -96,15 +98,29 @@ const aesKey = await crypto.subtle.generateKey(
96
98
  ['encrypt', 'decrypt']
97
99
  )
98
100
 
99
- // Wrap the existing key.
100
- const { wrapped: wrappedKey } = await encryptKey(keypair, aesKey)
101
+ // Wrap the existing key. The returned key is your own `aesKey` object,
102
+ // untouched (`sameKey === aesKey`): same `extractable`, same usages.
103
+ const { wrapped: wrappedKey, key: sameKey } = await encryptKey(
104
+ keypair,
105
+ aesKey
106
+ )
101
107
 
102
108
  // or pass in just a public key
103
109
 
104
110
  // Later, recover the same key with your private key.
105
111
  const recoveredKey = await open(keypair, wrappedKey)
106
112
 
107
- // `recoveredKey` is equal to `aesKey`
113
+ // `recoveredKey` holds the same key material as `aesKey`
114
+ ```
115
+
116
+ #### `open.raw`
117
+
118
+ Recover the wrapped key as raw bytes instead of a `CryptoKey`. Returns a
119
+ `Uint8Array` of 16 or 32 bytes, matching the key that was wrapped. It
120
+ takes the same `info` option as `open`, and no `extractable` option.
121
+
122
+ ```ts
123
+ const keyBytes = await open.raw(keypair, wrappedKey)
108
124
  ```
109
125
 
110
126
 
@@ -114,7 +130,7 @@ const recoveredKey = await open(keypair, wrappedKey)
114
130
  ### Hybrid Encryption
115
131
 
116
132
  Encrypt a message with AES-GCM, then encrypt the AES key to a given
117
- public key. The encrypted key is concattenated with the cipher text,
133
+ public key. The encrypted key is concatenated with the cipher text,
118
134
  along with the IV. The recipient uses their private key to open the AES key and
119
135
  decrypt the message.
120
136
 
@@ -209,7 +225,7 @@ const encryptedMessage = await encrypt(recipient.publicKey, 'hello encryption')
209
225
 
210
226
  // encrypt to a public key as Uint8Array
211
227
  const publicBytes = new Uint8Array(
212
- await subtle.exportKey('raw', recipient.publicKey)
228
+ await crypto.subtle.exportKey('raw', recipient.publicKey)
213
229
  )
214
230
  const envelope = await encrypt(publicBytes, 'hello again')
215
231
 
@@ -256,14 +272,16 @@ const plaintext = await decrypt.asString(recipient, envelope)
256
272
 
257
273
  ##### `encrypt`
258
274
 
259
- The recipient can be a crypto key, a Uint8Array, or a string public key.
275
+ The recipient can be a public crypto key, a keypair (only its public key
276
+ is used), a Uint8Array, or a string public key. `SupportedEncodings` is
277
+ the encoding type from `uint8arrays`.
260
278
 
261
279
  ```ts
262
280
  type RecipientKey =
263
281
  | CryptoKey
264
282
  | CryptoKeyPair
265
283
  | Uint8Array
266
- | { publicKey:string; encoding?:Uint8ArrayEncodings }
284
+ | { publicKey:string; encoding?:SupportedEncodings }
267
285
 
268
286
  async function encrypt (
269
287
  recipient:RecipientKey,
@@ -276,6 +294,11 @@ async function encrypt (
276
294
  ):Promise<Uint8Array>
277
295
  ```
278
296
 
297
+ A supplied `aesKey` `CryptoKey` must be extractable and must have the
298
+ `encrypt` usage, because the message is encrypted under that key directly.
299
+ A key without it rejects with `ERR_INVALID_AES_KEY` before any work is
300
+ done. The key is used as-is, never re-imported.
301
+
279
302
  ##### `encrypt.asString`
280
303
 
281
304
  `encrypt.asString` is `encrypt` with the envelope encoded to a string, useful
@@ -331,8 +354,9 @@ decrypt.asString = async function decryptToString (
331
354
 
332
355
  ##### `decrypt.fromString`
333
356
 
334
- Take a `string` as input. Return either a string, or if `opts.buffer` is true,
335
- a`Uint8Array`.
357
+ Take a `base64url` string as input (as returned by `encrypt.asString` with
358
+ the default encoding). Return either a string, or if `opts.buffer` is true,
359
+ a `Uint8Array`.
336
360
 
337
361
  ```ts
338
362
  decrypt.fromString = async function decryptFromString (
@@ -364,6 +388,23 @@ This is separate from the `aesKey` you may pass **in** to `encryptKey` /
364
388
  `encrypt`. That one must always be extractable, because its raw bytes are
365
389
  what get sealed.
366
390
 
391
+ A supplied `CryptoKey` is returned **as-is**: `encryptKey` gives back the
392
+ same object you passed in, with its own `extractable` flag and usages.
393
+ `opts.extractable` does not apply to it; it only governs a key the library
394
+ imports (a generated key, or raw `Uint8Array` bytes). Because the library
395
+ does not re-import your key, it does not add usages either. `encryptKey`
396
+ will wrap a decrypt-only key, but `encrypt` needs the `encrypt` usage.
397
+
398
+ ```ts
399
+ const myKey = await crypto.subtle.generateKey(
400
+ { name: 'AES-GCM', length: 256 },
401
+ true,
402
+ ['encrypt', 'decrypt']
403
+ )
404
+ const { key } = await encryptKey(recipient, myKey, { extractable: false })
405
+ key === myKey // true, and still extractable
406
+ ```
407
+
367
408
  If you only need to tell two keys apart, you do not need the bytes --
368
409
  use [`keyId`](#keyid).
369
410
 
@@ -403,6 +444,32 @@ keyId = base64url(SHA-256(LABEL || C))
403
444
  `key` must be an AES-GCM key with the `encrypt` usage; anything else
404
445
  throws [`ERR_INVALID_AES_KEY`](#errors).
405
446
 
447
+ ### `verifyKeyId`
448
+
449
+ Check a key against a `keyId` you stored earlier.
450
+
451
+ ```ts
452
+ async function verifyKeyId (
453
+ key:CryptoKey,
454
+ expected:string
455
+ ):Promise<boolean>
456
+ ```
457
+
458
+ ```ts
459
+ import { open, verifyKeyId } from 'simple-hpke'
460
+
461
+ const recovered = await open(keypair, wrapped)
462
+ if (!(await verifyKeyId(recovered, storedId))) {
463
+ // not the key you expected
464
+ }
465
+ ```
466
+
467
+ It resolves `true` or `false`, comparing in constant time. A key that
468
+ `keyId` cannot fingerprint (not AES-GCM, or no `encrypt` usage) rejects
469
+ with [`ERR_INVALID_AES_KEY`](#errors) rather than resolving `false`, so
470
+ "cannot check this key" is never confused with "wrong key". Do not catch
471
+ that error and return `false`.
472
+
406
473
  ## Errors
407
474
 
408
475
  Every error this package throws is an `HpkeError`: an ordinary `Error`
@@ -425,13 +492,13 @@ try {
425
492
 
426
493
  | Code | Raised when |
427
494
  | --- | --- |
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. |
495
+ | `ERR_INVALID_RECIPIENT_KEY` | The recipient is not a usable X25519 public key: wrong length, a private key, the wrong algorithm, a non-extractable public key, a string that does not decode, or an unrecognized form. |
496
+ | `ERR_INVALID_KEYPAIR` | `keypair.privateKey` is not an X25519 private key or lacks the `deriveBits` usage, or `keypair.publicKey` is not an extractable X25519 public key. |
497
+ | `ERR_SMALL_ORDER_KEY` | A small-order X25519 public key (for example all zeros), as the recipient or as the `enc` in a tampered envelope. Raised on conforming runtimes, which reject it inside `deriveBits`, and on nonconforming ones, where the shared secret comes out all-zero. |
431
498
  | `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. |
499
+ | `ERR_MALFORMED_MESSAGE` | A `decrypt` envelope is too short to hold its own declared segments, or a `decrypt.fromString` input is not valid `base64url`. |
433
500
  | `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. |
501
+ | `ERR_INVALID_AES_KEY` | A supplied `aesKey` is the wrong length, non-extractable, or not an AES-GCM key, `encrypt` got an `aesKey` without the `encrypt` usage, or `keyId` / `verifyKeyId` got a key it cannot use. |
435
502
  | `ERR_DECRYPT_FAILED` | AES-GCM authentication failed: wrong keypair, wrong `info`, or a tampered ciphertext. |
436
503
 
437
504
  `HpkeErrorCode` is exported as a type if you want to switch exhaustively.
@@ -452,6 +519,7 @@ import {
452
519
  encrypt,
453
520
  decrypt,
454
521
  keyId,
522
+ verifyKeyId,
455
523
  HpkeError
456
524
  } from 'simple-hpke'
457
525
  ```
@@ -1390,7 +1390,16 @@ async function decryptToString(keypair, message, opts) {
1390
1390
  }
1391
1391
  __name(decryptToString, "decryptToString");
1392
1392
  async function decryptFromString(keypair, message, opts) {
1393
- const bytes = fromString2(message, "base64url");
1393
+ let bytes;
1394
+ try {
1395
+ bytes = fromString2(message, "base64url");
1396
+ } catch (err) {
1397
+ throw new HpkeError(
1398
+ "ERR_MALFORMED_MESSAGE",
1399
+ "malformed message: not base64url",
1400
+ { cause: err }
1401
+ );
1402
+ }
1394
1403
  const plaintext = await decryptBytes(
1395
1404
  keypair,
1396
1405
  bytes,
@@ -1518,6 +1527,12 @@ async function encryptKey(recipient, aesKey, opts) {
1518
1527
  validateRawKeyBytes(aesKey);
1519
1528
  keyBytes = aesKey;
1520
1529
  } else if (aesKey) {
1530
+ if (aesKey.algorithm.name !== "AES-GCM") {
1531
+ throw new HpkeError(
1532
+ "ERR_INVALID_AES_KEY",
1533
+ "aesKey must be an AES-GCM key, got " + aesKey.algorithm.name
1534
+ );
1535
+ }
1521
1536
  keyBytes = await exportAesKeyBytes(aesKey);
1522
1537
  validateRawKeyBytes(keyBytes);
1523
1538
  } else {
@@ -1532,6 +1547,7 @@ async function encryptKey(recipient, aesKey, opts) {
1532
1547
  const { key, baseNonce } = await keySchedule(sharedSecret, info);
1533
1548
  const ciphertext = await aeadSeal(key, baseNonce, keyBytes);
1534
1549
  const wrapped = concat2(enc, ciphertext);
1550
+ if (aesKey instanceof CryptoKey) return { wrapped, key: aesKey };
1535
1551
  const aesGcmKey = await importAesKey(
1536
1552
  keyBytes,
1537
1553
  opts?.extractable ?? false
@@ -1584,6 +1600,12 @@ async function aeadOpen(key, nonce, ciphertext) {
1584
1600
  }
1585
1601
  __name(aeadOpen, "aeadOpen");
1586
1602
  async function encryptBytes(recipient, message, aesKey, opts) {
1603
+ if (aesKey instanceof CryptoKey && !aesKey.usages.includes("encrypt")) {
1604
+ throw new HpkeError(
1605
+ "ERR_INVALID_AES_KEY",
1606
+ 'aesKey is missing the "encrypt" usage, which encrypt needs to encrypt the message'
1607
+ );
1608
+ }
1587
1609
  const plaintext = typeof message === "string" ? new TextEncoder().encode(message) : message;
1588
1610
  const { wrapped, key } = await encryptKey(recipient, aesKey, opts);
1589
1611
  const iv = globalThis.crypto.getRandomValues(new Uint8Array(NN));
@@ -1682,37 +1704,28 @@ async function resolveRecipientPublicKey(recipient) {
1682
1704
  return importRawPublic(recipient);
1683
1705
  }
1684
1706
  if (recipient instanceof CryptoKey) {
1685
- if (recipient.type !== "public") {
1686
- throw new HpkeError(
1687
- "ERR_INVALID_RECIPIENT_KEY",
1688
- "recipient CryptoKey must be a public key"
1689
- );
1690
- }
1691
- assertX25519Algorithm(
1692
- recipient,
1693
- "recipient CryptoKey",
1694
- "ERR_INVALID_RECIPIENT_KEY"
1695
- );
1696
- return recipient;
1707
+ return assertRecipientPublicKey(recipient, "recipient CryptoKey");
1697
1708
  }
1698
1709
  if (typeof recipient.publicKey === "string") {
1699
1710
  const { publicKey, encoding } = recipient;
1700
- return importRawPublic(fromString2(publicKey, encoding ?? "base64url"));
1701
- }
1702
- const pair = recipient;
1703
- if (pair.publicKey instanceof CryptoKey) {
1704
- if (pair.publicKey.type !== "public") {
1711
+ let decoded;
1712
+ try {
1713
+ decoded = fromString2(publicKey, encoding ?? "base64url");
1714
+ } catch (err) {
1705
1715
  throw new HpkeError(
1706
1716
  "ERR_INVALID_RECIPIENT_KEY",
1707
- "recipient keypair publicKey must be a public key"
1717
+ `recipient publicKey string does not decode as ${encoding ?? "base64url"}`,
1718
+ { cause: err }
1708
1719
  );
1709
1720
  }
1710
- assertX25519Algorithm(
1721
+ return importRawPublic(decoded);
1722
+ }
1723
+ const pair = recipient;
1724
+ if (pair.publicKey instanceof CryptoKey) {
1725
+ return assertRecipientPublicKey(
1711
1726
  pair.publicKey,
1712
- "recipient keypair publicKey",
1713
- "ERR_INVALID_RECIPIENT_KEY"
1727
+ "recipient keypair publicKey"
1714
1728
  );
1715
- return pair.publicKey;
1716
1729
  }
1717
1730
  throw new HpkeError(
1718
1731
  "ERR_INVALID_RECIPIENT_KEY",
@@ -1720,6 +1733,23 @@ async function resolveRecipientPublicKey(recipient) {
1720
1733
  );
1721
1734
  }
1722
1735
  __name(resolveRecipientPublicKey, "resolveRecipientPublicKey");
1736
+ function assertRecipientPublicKey(key, context) {
1737
+ if (key.type !== "public") {
1738
+ throw new HpkeError(
1739
+ "ERR_INVALID_RECIPIENT_KEY",
1740
+ `${context} must be a public key`
1741
+ );
1742
+ }
1743
+ assertX25519Algorithm(key, context, "ERR_INVALID_RECIPIENT_KEY");
1744
+ if (!key.extractable) {
1745
+ throw new HpkeError(
1746
+ "ERR_INVALID_RECIPIENT_KEY",
1747
+ `${context} must be extractable (the KEM exports it)`
1748
+ );
1749
+ }
1750
+ return key;
1751
+ }
1752
+ __name(assertRecipientPublicKey, "assertRecipientPublicKey");
1723
1753
  function assertX25519Algorithm(key, context, code2) {
1724
1754
  if (key.algorithm.name !== "X25519") {
1725
1755
  throw new HpkeError(
@@ -1730,11 +1760,23 @@ function assertX25519Algorithm(key, context, code2) {
1730
1760
  }
1731
1761
  __name(assertX25519Algorithm, "assertX25519Algorithm");
1732
1762
  async function dh(priv, pub) {
1733
- const bits = await subtle2.deriveBits(
1734
- { name: "X25519", public: pub },
1735
- priv,
1736
- 256
1737
- );
1763
+ let bits;
1764
+ try {
1765
+ bits = await subtle2.deriveBits(
1766
+ { name: "X25519", public: pub },
1767
+ priv,
1768
+ 256
1769
+ );
1770
+ } catch (err) {
1771
+ if (err?.name === "OperationError") {
1772
+ throw new HpkeError(
1773
+ "ERR_SMALL_ORDER_KEY",
1774
+ "X25519 deriveBits rejected a small-order public key",
1775
+ { cause: err }
1776
+ );
1777
+ }
1778
+ throw err;
1779
+ }
1738
1780
  const bytes = new Uint8Array(bits);
1739
1781
  if (bytes.every((b) => b === 0)) {
1740
1782
  throw new HpkeError(
@@ -1787,6 +1829,23 @@ async function decap(enc, keypair) {
1787
1829
  "keypair.privateKey",
1788
1830
  "ERR_INVALID_KEYPAIR"
1789
1831
  );
1832
+ if (!keypair.privateKey.usages.includes("deriveBits")) {
1833
+ throw new HpkeError(
1834
+ "ERR_INVALID_KEYPAIR",
1835
+ "keypair.privateKey must have the deriveBits usage"
1836
+ );
1837
+ }
1838
+ if (keypair.publicKey.type !== "public" || !keypair.publicKey.extractable) {
1839
+ throw new HpkeError(
1840
+ "ERR_INVALID_KEYPAIR",
1841
+ "keypair.publicKey must be an extractable public key"
1842
+ );
1843
+ }
1844
+ assertX25519Algorithm(
1845
+ keypair.publicKey,
1846
+ "keypair.publicKey",
1847
+ "ERR_INVALID_KEYPAIR"
1848
+ );
1790
1849
  const pkE = await subtle2.importKey(
1791
1850
  "raw",
1792
1851
  enc,