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 +207 -27
- package/dist/constants.cjs +188 -47
- package/dist/constants.cjs.map +4 -4
- package/dist/constants.d.ts +2 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +188 -47
- package/dist/constants.js.map +4 -4
- package/dist/constants.min.cjs +1 -1
- package/dist/constants.min.cjs.map +4 -4
- package/dist/constants.min.js +1 -1
- package/dist/constants.min.js.map +4 -4
- package/dist/errors.cjs +38 -0
- package/dist/errors.cjs.map +7 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +19 -0
- package/dist/errors.js.map +7 -0
- package/dist/errors.min.cjs +2 -0
- package/dist/errors.min.cjs.map +7 -0
- package/dist/errors.min.js +2 -0
- package/dist/errors.min.js.map +7 -0
- package/dist/index.cjs +231 -50
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +34 -21
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +231 -50
- package/dist/index.js.map +4 -4
- package/dist/index.min.cjs +1 -1
- package/dist/index.min.cjs.map +4 -4
- package/dist/index.min.js +1 -1
- package/dist/index.min.js.map +4 -4
- package/dist/meta.json +81 -20
- package/dist/util.cjs +228 -48
- package/dist/util.cjs.map +4 -4
- package/dist/util.d.ts +56 -3
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +228 -48
- package/dist/util.js.map +4 -4
- package/dist/util.min.cjs +1 -1
- package/dist/util.min.cjs.map +4 -4
- package/dist/util.min.js +1 -1
- package/dist/util.min.js.map +4 -4
- package/package.json +6 -5
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
|
-
// `
|
|
81
|
-
const {
|
|
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, //
|
|
97
|
+
true, // extractable -- its raw bytes are what get sealed
|
|
89
98
|
['encrypt', 'decrypt']
|
|
90
99
|
)
|
|
91
100
|
|
|
92
|
-
// Wrap the existing key.
|
|
93
|
-
|
|
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`
|
|
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
|
|
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 {
|
|
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 `
|
|
133
|
-
// 256-bit AES key, so `
|
|
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
|
|
136
|
-
// (or otherwise transmit it) so the recipient can split
|
|
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
|
|
139
|
-
const message = new Uint8Array(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
message.set(
|
|
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
|
|
146
|
-
const iv2 = message.subarray(
|
|
147
|
-
const ciphertext2 = message.subarray(
|
|
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,
|
|
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
|
|
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?:
|
|
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 `
|
|
325
|
-
a`
|
|
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 {
|
|
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
|