simple-hpke 0.2.0 → 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)
@@ -38,6 +39,10 @@ the plaintext is.
38
39
  - [`decrypt`](#decrypt)
39
40
  - [`decrypt.asString`](#decryptasstring)
40
41
  - [`decrypt.fromString`](#decryptfromstring)
42
+ - [Extractable keys](#extractable-keys)
43
+ - [`keyId`](#keyid)
44
+ * [`verifyKeyId`](#verifykeyid)
45
+ - [Errors](#errors)
41
46
  - [Modules](#modules)
42
47
  * [ESM](#esm)
43
48
  * [Common JS](#common-js)
@@ -77,27 +82,45 @@ const keypair = await crypto.subtle.generateKey(
77
82
 
78
83
  // Create a new AES key and encrypt it to your public key.
79
84
  // `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
85
+ // `wrapped` is the AES key encrypted to the given public key
86
+ const { wrapped, key } = await create(keypair.publicKey) // or bytes or string
87
+
88
+ // `key` is non-extractable by default. It still encrypts and decrypts;
89
+ // it just cannot be exported. Pass `{ extractable: true }` if you need
90
+ // the raw bytes. See [Extractable keys](#extractable-keys).
82
91
 
83
92
  //
84
93
  // Or wrap an existing AES key. The supplied AES key must be extractable.
85
94
  //
86
95
  const aesKey = await crypto.subtle.generateKey(
87
96
  { name: 'AES-GCM', length: 256 },
88
- true, // not extractable
97
+ true, // extractable -- its raw bytes are what get sealed
89
98
  ['encrypt', 'decrypt']
90
99
  )
91
100
 
92
- // Wrap the existing key.
93
- const { enc: 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
+ )
94
107
 
95
108
  // or pass in just a public key
96
109
 
97
110
  // Later, recover the same key with your private key.
98
111
  const recoveredKey = await open(keypair, wrappedKey)
99
112
 
100
- // `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)
101
124
  ```
102
125
 
103
126
 
@@ -107,7 +130,7 @@ const recoveredKey = await open(keypair, wrappedKey)
107
130
  ### Hybrid Encryption
108
131
 
109
132
  Encrypt a message with AES-GCM, then encrypt the AES key to a given
110
- public key. The encrypted key is concattenated with the cipher text,
133
+ public key. The encrypted key is concatenated with the cipher text,
111
134
  along with the IV. The recipient uses their private key to open the AES key and
112
135
  decrypt the message.
113
136
 
@@ -121,7 +144,7 @@ const recipient = await crypto.subtle.generateKey(
121
144
  )
122
145
 
123
146
  // Create a fresh AES-GCM key, and encrypt a message with it.
124
- const { enc, key } = await create(recipient)
147
+ const { wrapped, key } = await create(recipient)
125
148
  const iv = crypto.getRandomValues(new Uint8Array(12))
126
149
  const ciphertextBuffer = await crypto.subtle.encrypt(
127
150
  { name: 'AES-GCM', iv },
@@ -129,25 +152,28 @@ const ciphertextBuffer = await crypto.subtle.encrypt(
129
152
  new TextEncoder().encode('attack at dawn')
130
153
  )
131
154
 
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.
155
+ // Send `wrapped`, `iv`, and `ciphertext` together. `create(...)` defaults
156
+ // to a 256-bit AES key, so `wrapped.length` is 80 bytes in this example.
134
157
  //
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.
158
+ // If your protocol allows different wrapped-key sizes, prefix
159
+ // `wrapped.length` (or otherwise transmit it) so the recipient can split
160
+ // the payload safely.
137
161
  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)
162
+ const wrappedLength = wrapped.length
163
+ const message = new Uint8Array(
164
+ wrappedLength + iv.length + ciphertext.length
165
+ )
166
+ message.set(wrapped, 0)
167
+ message.set(iv, wrappedLength)
168
+ message.set(ciphertext, wrappedLength + iv.length)
143
169
 
144
170
  // 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)
171
+ const wrapped2 = message.subarray(0, wrappedLength)
172
+ const iv2 = message.subarray(wrappedLength, wrappedLength + 12)
173
+ const ciphertext2 = message.subarray(wrappedLength + 12)
148
174
 
149
175
  // Recover the key, then decrypt the message.
150
- const recovered = await open(recipient, enc2)
176
+ const recovered = await open(recipient, wrapped2)
151
177
  const plaintext = await crypto.subtle.decrypt(
152
178
  { name: 'AES-GCM', iv: iv2 },
153
179
  recovered,
@@ -199,7 +225,7 @@ const encryptedMessage = await encrypt(recipient.publicKey, 'hello encryption')
199
225
 
200
226
  // encrypt to a public key as Uint8Array
201
227
  const publicBytes = new Uint8Array(
202
- await subtle.exportKey('raw', recipient.publicKey)
228
+ await crypto.subtle.exportKey('raw', recipient.publicKey)
203
229
  )
204
230
  const envelope = await encrypt(publicBytes, 'hello again')
205
231
 
@@ -246,14 +272,16 @@ const plaintext = await decrypt.asString(recipient, envelope)
246
272
 
247
273
  ##### `encrypt`
248
274
 
249
- 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`.
250
278
 
251
279
  ```ts
252
280
  type RecipientKey =
253
281
  | CryptoKey
254
282
  | CryptoKeyPair
255
283
  | Uint8Array
256
- | { publicKey:string; encoding?:Uint8ArrayEncodings }
284
+ | { publicKey:string; encoding?:SupportedEncodings }
257
285
 
258
286
  async function encrypt (
259
287
  recipient:RecipientKey,
@@ -266,6 +294,11 @@ async function encrypt (
266
294
  ):Promise<Uint8Array>
267
295
  ```
268
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
+
269
302
  ##### `encrypt.asString`
270
303
 
271
304
  `encrypt.asString` is `encrypt` with the envelope encoded to a string, useful
@@ -321,8 +354,9 @@ decrypt.asString = async function decryptToString (
321
354
 
322
355
  ##### `decrypt.fromString`
323
356
 
324
- Take a `string` as input. Return either a string, or if `opts.buffer` is true,
325
- 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`.
326
360
 
327
361
  ```ts
328
362
  decrypt.fromString = async function decryptFromString (
@@ -334,6 +368,143 @@ decrypt.fromString = async function decryptFromString (
334
368
 
335
369
  ---------------------------------------------------------------
336
370
 
371
+ ## Extractable keys
372
+
373
+ `create`, `encryptKey`, and `open` return a **non-extractable** AES-GCM
374
+ `CryptoKey` by default. It encrypts and decrypts normally; it just cannot
375
+ be exported, so its raw bytes cannot leak out of the runtime.
376
+
377
+ Opt in when you genuinely need the bytes:
378
+
379
+ ```ts
380
+ const { wrapped, key } = await create(recipient, { extractable: true })
381
+ const bytes = new Uint8Array(await crypto.subtle.exportKey('raw', key))
382
+
383
+ // same option on the way back out
384
+ const recovered = await open(keypair, wrapped, { extractable: true })
385
+ ```
386
+
387
+ This is separate from the `aesKey` you may pass **in** to `encryptKey` /
388
+ `encrypt`. That one must always be extractable, because its raw bytes are
389
+ what get sealed.
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
+
408
+ If you only need to tell two keys apart, you do not need the bytes --
409
+ use [`keyId`](#keyid).
410
+
411
+ ## `keyId`
412
+
413
+ A stable fingerprint for an AES-GCM key, so you can ask "is this the key I
414
+ have?" without holding its bytes.
415
+
416
+ ```ts
417
+ async function keyId (key:CryptoKey):Promise<string>
418
+ ```
419
+
420
+ ```ts
421
+ import { create, open, keyId } from 'simple-hpke'
422
+
423
+ const { wrapped, key } = await create(recipient)
424
+ const id = await keyId(key) // 43-char base64url string
425
+
426
+ const recovered = await open(keypair, wrapped)
427
+ await keyId(recovered) === id // true
428
+ ```
429
+
430
+ It works on a **non-extractable** key -- including one rehydrated from
431
+ IndexedDB -- which is the whole point: the obvious implementation
432
+ (`exportKey('raw')` then SHA-256) would force every key you want to
433
+ identify to be extractable.
434
+
435
+ The derivation is fixed, and is treated as wire format:
436
+
437
+ ```
438
+ LABEL = utf8("simple-hpke/keyId/v1")
439
+ N = SHA-256(LABEL)[0..12] // fixed nonce
440
+ C = AES-GCM(key, N, LABEL) // ciphertext || 16-byte tag
441
+ keyId = base64url(SHA-256(LABEL || C))
442
+ ```
443
+
444
+ `key` must be an AES-GCM key with the `encrypt` usage; anything else
445
+ throws [`ERR_INVALID_AES_KEY`](#errors).
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
+
473
+ ## Errors
474
+
475
+ Every error this package throws is an `HpkeError`: an ordinary `Error`
476
+ with a stable `code`. Branch on the code, not on the message text -- the
477
+ codes are part of the API, the prose is not. Where a WebCrypto failure is
478
+ being wrapped, the original is kept on `cause`.
479
+
480
+ ```ts
481
+ import { decrypt, HpkeError } from 'simple-hpke'
482
+
483
+ try {
484
+ await decrypt(keypair, envelope)
485
+ } catch (err) {
486
+ if (err instanceof HpkeError && err.code === 'ERR_DECRYPT_FAILED') {
487
+ // wrong keypair, wrong `info`, or a tampered envelope
488
+ }
489
+ throw err
490
+ }
491
+ ```
492
+
493
+ | Code | Raised when |
494
+ | --- | --- |
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. |
498
+ | `ERR_MALFORMED_ENVELOPE` | An `open` / `open.raw` envelope is too short to hold `enc` plus an AEAD tag. |
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`. |
500
+ | `ERR_INVALID_KEYSIZE` | `opts.size` is neither `128` nor `256`. |
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. |
502
+ | `ERR_DECRYPT_FAILED` | AES-GCM authentication failed: wrong keypair, wrong `info`, or a tampered ciphertext. |
503
+
504
+ `HpkeErrorCode` is exported as a type if you want to switch exhaustively.
505
+
506
+ ---------------------------------------------------------------
507
+
337
508
  ## Modules
338
509
 
339
510
  This exposes ESM and common JS via
@@ -341,7 +512,16 @@ This exposes ESM and common JS via
341
512
 
342
513
  ### ESM
343
514
  ```js
344
- import { create, encryptKey, open, encrypt, decrypt } from 'simple-hpke'
515
+ import {
516
+ create,
517
+ encryptKey,
518
+ open,
519
+ encrypt,
520
+ decrypt,
521
+ keyId,
522
+ verifyKeyId,
523
+ HpkeError
524
+ } from 'simple-hpke'
345
525
  ```
346
526
 
347
527
  ### Common JS