@udibo/oauth2 0.33.0 → 0.34.0
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/dist/adapters/hono/bff/_cookie-keys.d.ts +17 -0
- package/dist/adapters/hono/bff/_cookie-keys.js +25 -0
- package/dist/adapters/hono/bff/auth-request-store.d.ts +18 -7
- package/dist/adapters/hono/bff/auth-request-store.js +25 -12
- package/dist/adapters/hono/bff/session-store.d.ts +14 -6
- package/dist/adapters/hono/bff/session-store.js +13 -11
- package/dist/utils/crypto.d.ts +8 -26
- package/dist/utils/crypto.js +8 -33
- package/package.json +1 -1
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AES-256-GCM keys the BFF's encrypted-cookie stores seal under.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
/** HKDF `info` label for `EncryptedCookieSessionStore`'s key. */
|
|
7
|
+
export declare const SESSION_COOKIE_KEY_INFO = "udibo:oauth2:bff:session-cookie:v1";
|
|
8
|
+
/** HKDF `info` label for `EncryptedCookieAuthRequestStorage`'s key. */
|
|
9
|
+
export declare const AUTH_REQUEST_COOKIE_KEY_INFO = "udibo:oauth2:bff:auth-request-cookie:v1";
|
|
10
|
+
/**
|
|
11
|
+
* Derives a cookie store's key from its configured secret with HKDF-SHA-256
|
|
12
|
+
* under the store's own `info` label, so one secret given to two stores yields
|
|
13
|
+
* independent keys.
|
|
14
|
+
*
|
|
15
|
+
* @throws {Error} When the secret is shorter than 16 bytes.
|
|
16
|
+
*/
|
|
17
|
+
export declare function deriveCookieKey(secret: string | Uint8Array, info: string): Promise<CryptoKey>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AES-256-GCM keys the BFF's encrypted-cookie stores seal under.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
import { deriveSealKey } from "../../../utils/crypto.js";
|
|
7
|
+
/** HKDF `info` label for `EncryptedCookieSessionStore`'s key. */
|
|
8
|
+
export const SESSION_COOKIE_KEY_INFO = "udibo:oauth2:bff:session-cookie:v1";
|
|
9
|
+
/** HKDF `info` label for `EncryptedCookieAuthRequestStorage`'s key. */
|
|
10
|
+
export const AUTH_REQUEST_COOKIE_KEY_INFO = "udibo:oauth2:bff:auth-request-cookie:v1";
|
|
11
|
+
const MIN_SECRET_BYTES = 16;
|
|
12
|
+
/**
|
|
13
|
+
* Derives a cookie store's key from its configured secret with HKDF-SHA-256
|
|
14
|
+
* under the store's own `info` label, so one secret given to two stores yields
|
|
15
|
+
* independent keys.
|
|
16
|
+
*
|
|
17
|
+
* @throws {Error} When the secret is shorter than 16 bytes.
|
|
18
|
+
*/
|
|
19
|
+
export async function deriveCookieKey(secret, info) {
|
|
20
|
+
const bytes = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
|
|
21
|
+
if (bytes.byteLength < MIN_SECRET_BYTES) {
|
|
22
|
+
throw new Error("secret must be at least 16 bytes; 32+ bytes of entropy is recommended.");
|
|
23
|
+
}
|
|
24
|
+
return await deriveSealKey(bytes, info);
|
|
25
|
+
}
|
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
* caller chooses.
|
|
12
12
|
*
|
|
13
13
|
* {@link EncryptedCookieAuthRequestStorage} carries the record in an encrypted
|
|
14
|
-
* cookie instead (AES-GCM under a server-held secret,
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* cookie instead (AES-GCM under a key derived from a server-held secret), so no
|
|
15
|
+
* server-side storage is involved and any instance can complete the flow. Apps
|
|
16
|
+
* that already have a database can implement {@link AuthRequestStorage} against
|
|
17
|
+
* it instead.
|
|
18
18
|
*
|
|
19
19
|
* **Which storage you pick is a reliability decision, not a security one.**
|
|
20
20
|
* Server-side storage on its own says a `state` is pending somewhere — never
|
|
@@ -84,6 +84,13 @@ export interface EncryptedCookieAuthRequestStorageOptions {
|
|
|
84
84
|
/**
|
|
85
85
|
* Secret the AES-256 key is derived from; at least 32 bytes. Reuse the
|
|
86
86
|
* secret across instances so any of them can unseal a cookie another sealed.
|
|
87
|
+
*
|
|
88
|
+
* Give each store instance and each app a secret of its own. Nothing in a
|
|
89
|
+
* sealed cookie binds it to the app or store instance that sealed it, so two
|
|
90
|
+
* apps that share a secret can open each other's pending sign-ins. Sharing
|
|
91
|
+
* one with {@link EncryptedCookieSessionStore} does not let either store open
|
|
92
|
+
* the other's cookies: each derives its key with HKDF-SHA-256 under its own
|
|
93
|
+
* label.
|
|
87
94
|
*/
|
|
88
95
|
secret: string;
|
|
89
96
|
/** Cookie attribute overrides. */
|
|
@@ -138,8 +145,12 @@ export interface AuthRequestStorageFactory {
|
|
|
138
145
|
* ```ts
|
|
139
146
|
* const bff = new HonoBff({
|
|
140
147
|
* client,
|
|
141
|
-
* sessionStore: new EncryptedCookieSessionStore({
|
|
142
|
-
*
|
|
148
|
+
* sessionStore: new EncryptedCookieSessionStore({
|
|
149
|
+
* secret: process.env.SESSION_SECRET!,
|
|
150
|
+
* }),
|
|
151
|
+
* authRequestStorage: new EncryptedCookieAuthRequestStorage({
|
|
152
|
+
* secret: process.env.AUTH_REQUEST_SECRET!,
|
|
153
|
+
* }),
|
|
143
154
|
* });
|
|
144
155
|
* ```
|
|
145
156
|
*/
|
|
@@ -171,7 +182,7 @@ export declare class EncryptedCookieAuthRequestStorage implements AuthRequestSto
|
|
|
171
182
|
* import { encryptedCookieAuthRequestStorage } from "@udibo/oauth2/hono/bff";
|
|
172
183
|
*
|
|
173
184
|
* const authRequestStorage = encryptedCookieAuthRequestStorage({
|
|
174
|
-
* secret: process.env.
|
|
185
|
+
* secret: process.env.AUTH_REQUEST_SECRET!,
|
|
175
186
|
* });
|
|
176
187
|
* ```
|
|
177
188
|
*/
|
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
* caller chooses.
|
|
12
12
|
*
|
|
13
13
|
* {@link EncryptedCookieAuthRequestStorage} carries the record in an encrypted
|
|
14
|
-
* cookie instead (AES-GCM under a server-held secret,
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* cookie instead (AES-GCM under a key derived from a server-held secret), so no
|
|
15
|
+
* server-side storage is involved and any instance can complete the flow. Apps
|
|
16
|
+
* that already have a database can implement {@link AuthRequestStorage} against
|
|
17
|
+
* it instead.
|
|
18
18
|
*
|
|
19
19
|
* **Which storage you pick is a reliability decision, not a security one.**
|
|
20
20
|
* Server-side storage on its own says a `state` is pending somewhere — never
|
|
@@ -29,13 +29,17 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
31
31
|
import { MemoryAuthRequestStorage, } from "../../../client/mod.js";
|
|
32
|
-
import {
|
|
32
|
+
import { sealJson, unsealJson } from "../../../utils/crypto.js";
|
|
33
|
+
import { AUTH_REQUEST_COOKIE_KEY_INFO, deriveCookieKey, } from "./_cookie-keys.js";
|
|
33
34
|
import { resolveCookieName, resolvePrefixConstrainedAttributes, } from "./cookie-prefix.js";
|
|
34
35
|
const BASE_NAME = "oauth2_auth_request";
|
|
35
36
|
const DEFAULT_TTL_MS = 10 * 60 * 1000;
|
|
36
37
|
const DEFAULT_MAX_PENDING = 3;
|
|
37
38
|
// RFC 6265 §6.1 only guarantees 4096 bytes per cookie, attributes included.
|
|
38
39
|
const MAX_COOKIE_NAME_VALUE_BYTES = 3900;
|
|
40
|
+
function isObject(value) {
|
|
41
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
42
|
+
}
|
|
39
43
|
function cookieAttributes(options, maxAgeSeconds) {
|
|
40
44
|
const { secure, path, domain } = resolvePrefixConstrainedAttributes(options);
|
|
41
45
|
return {
|
|
@@ -65,8 +69,12 @@ function cookieAttributes(options, maxAgeSeconds) {
|
|
|
65
69
|
* ```ts
|
|
66
70
|
* const bff = new HonoBff({
|
|
67
71
|
* client,
|
|
68
|
-
* sessionStore: new EncryptedCookieSessionStore({
|
|
69
|
-
*
|
|
72
|
+
* sessionStore: new EncryptedCookieSessionStore({
|
|
73
|
+
* secret: process.env.SESSION_SECRET!,
|
|
74
|
+
* }),
|
|
75
|
+
* authRequestStorage: new EncryptedCookieAuthRequestStorage({
|
|
76
|
+
* secret: process.env.AUTH_REQUEST_SECRET!,
|
|
77
|
+
* }),
|
|
70
78
|
* });
|
|
71
79
|
* ```
|
|
72
80
|
*/
|
|
@@ -107,17 +115,19 @@ export class EncryptedCookieAuthRequestStorage {
|
|
|
107
115
|
}
|
|
108
116
|
}
|
|
109
117
|
#key() {
|
|
110
|
-
return (this.#keyPromise ??=
|
|
118
|
+
return (this.#keyPromise ??= deriveCookieKey(this.#secret, AUTH_REQUEST_COOKIE_KEY_INFO));
|
|
111
119
|
}
|
|
112
120
|
async #read(c) {
|
|
113
121
|
const cookieValue = getCookie(c, this.#name);
|
|
114
122
|
if (!cookieValue)
|
|
115
123
|
return {};
|
|
116
124
|
const pending = await unsealJson(await this.#key(), cookieValue);
|
|
117
|
-
if (!pending)
|
|
125
|
+
if (!isObject(pending))
|
|
118
126
|
return {};
|
|
119
127
|
const cutoff = Date.now() - this.#ttlMs;
|
|
120
|
-
return Object.fromEntries(Object.entries(pending).filter((
|
|
128
|
+
return Object.fromEntries(Object.entries(pending).filter((entry) => isObject(entry[1]) &&
|
|
129
|
+
typeof entry[1].createdAt === "number" &&
|
|
130
|
+
entry[1].createdAt > cutoff));
|
|
121
131
|
}
|
|
122
132
|
async #write(c, pending, keep) {
|
|
123
133
|
const entries = Object.entries(pending)
|
|
@@ -143,7 +153,10 @@ export class EncryptedCookieAuthRequestStorage {
|
|
|
143
153
|
/** Returns the storage bound to `c`, for the duration of that request. */
|
|
144
154
|
forRequest(c) {
|
|
145
155
|
return {
|
|
146
|
-
get: async (state) =>
|
|
156
|
+
get: async (state) => {
|
|
157
|
+
const pending = await this.#read(c);
|
|
158
|
+
return Object.hasOwn(pending, state) ? pending[state] : null;
|
|
159
|
+
},
|
|
147
160
|
set: async (state, value) => {
|
|
148
161
|
const pending = await this.#read(c);
|
|
149
162
|
pending[state] = value;
|
|
@@ -168,7 +181,7 @@ export class EncryptedCookieAuthRequestStorage {
|
|
|
168
181
|
* import { encryptedCookieAuthRequestStorage } from "@udibo/oauth2/hono/bff";
|
|
169
182
|
*
|
|
170
183
|
* const authRequestStorage = encryptedCookieAuthRequestStorage({
|
|
171
|
-
* secret: process.env.
|
|
184
|
+
* secret: process.env.AUTH_REQUEST_SECRET!,
|
|
172
185
|
* });
|
|
173
186
|
* ```
|
|
174
187
|
*/
|
|
@@ -283,6 +283,13 @@ export interface EncryptedCookieSessionStoreOptions {
|
|
|
283
283
|
* Server-held secret(s) used to derive the AES-256 key(s). Each must have at
|
|
284
284
|
* least 32 bytes of entropy; pass a base64 / hex string or raw bytes.
|
|
285
285
|
*
|
|
286
|
+
* Give each store instance and each app a secret of its own. Nothing in a
|
|
287
|
+
* sealed cookie binds it to the app or store instance that sealed it, so two
|
|
288
|
+
* apps that share a secret can open each other's session cookies. Sharing one
|
|
289
|
+
* with `EncryptedCookieAuthRequestStorage` does not let either store open the
|
|
290
|
+
* other's cookies: each derives its key with HKDF-SHA-256 under its own
|
|
291
|
+
* label.
|
|
292
|
+
*
|
|
286
293
|
* Pass an **ordered list** to rotate the secret without signing everyone out:
|
|
287
294
|
* the **first** entry is current — every cookie is sealed under it — and
|
|
288
295
|
* `read` tries each entry in turn, so a cookie sealed under a still-listed
|
|
@@ -308,10 +315,6 @@ export interface EncryptedCookieSessionStoreOptions {
|
|
|
308
315
|
* `update` re-seals with a fresh stamp, so this is an **inactivity** window
|
|
309
316
|
* for a session the BFF keeps refreshing. Must be a positive number of
|
|
310
317
|
* milliseconds.
|
|
311
|
-
*
|
|
312
|
-
* The store stamps each cookie with a seal time at `create`/`update`; a
|
|
313
|
-
* cookie without that stamp is aged from its `updatedAt` instead, so it is
|
|
314
|
-
* bounded too.
|
|
315
318
|
*/
|
|
316
319
|
maxAgeMs?: number;
|
|
317
320
|
}
|
|
@@ -322,6 +325,11 @@ export interface EncryptedCookieSessionStoreOptions {
|
|
|
322
325
|
* Security notes:
|
|
323
326
|
* - Each secret must have at least 256 bits of entropy. A weak secret defeats
|
|
324
327
|
* the confidentiality of the cookie.
|
|
328
|
+
* - The key is derived from the secret with HKDF-SHA-256 under a label of this
|
|
329
|
+
* store's own, so a cookie sealed by another store under the same secret
|
|
330
|
+
* does not open here.
|
|
331
|
+
* - `read` accepts only what `create` sealed: a cookie that is malformed,
|
|
332
|
+
* foreign, or the wrong shape reads as `null`.
|
|
325
333
|
* - AES-GCM provides authenticated encryption; tampered cookies fail `read`
|
|
326
334
|
* (returns `null`).
|
|
327
335
|
* - `destroy` is a no-op on the server — the cookie must be cleared in the
|
|
@@ -366,8 +374,8 @@ export declare class EncryptedCookieSessionStore implements SessionStore, Bounde
|
|
|
366
374
|
create(data: SessionData): Promise<string>;
|
|
367
375
|
/**
|
|
368
376
|
* Decrypts the cookie value, trying each configured secret in turn. Returns
|
|
369
|
-
* `null` if it is tampered, malformed, sealed with an unknown secret
|
|
370
|
-
* sealed longer ago than
|
|
377
|
+
* `null` if it is tampered, malformed, sealed with an unknown secret or by
|
|
378
|
+
* another store, not in the shape `create` seals, or sealed longer ago than
|
|
371
379
|
* {@linkcode EncryptedCookieSessionStoreOptions.maxAgeMs}.
|
|
372
380
|
*/
|
|
373
381
|
read(cookieValue: string): Promise<SessionData | null>;
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
* @module
|
|
25
25
|
*/
|
|
26
26
|
import { InvalidGrantError } from "../../../errors.js";
|
|
27
|
-
import {
|
|
27
|
+
import { sealJson, unsealJson } from "../../../utils/crypto.js";
|
|
28
|
+
import { deriveCookieKey, SESSION_COOKIE_KEY_INFO } from "./_cookie-keys.js";
|
|
28
29
|
/** Whether a store implements the {@link BackchannelLogoutStore} capability. */
|
|
29
30
|
export function supportsBackchannelLogout(store) {
|
|
30
31
|
return (typeof store.destroyByLogout ===
|
|
@@ -233,6 +234,11 @@ function isSealedSession(value) {
|
|
|
233
234
|
* Security notes:
|
|
234
235
|
* - Each secret must have at least 256 bits of entropy. A weak secret defeats
|
|
235
236
|
* the confidentiality of the cookie.
|
|
237
|
+
* - The key is derived from the secret with HKDF-SHA-256 under a label of this
|
|
238
|
+
* store's own, so a cookie sealed by another store under the same secret
|
|
239
|
+
* does not open here.
|
|
240
|
+
* - `read` accepts only what `create` sealed: a cookie that is malformed,
|
|
241
|
+
* foreign, or the wrong shape reads as `null`.
|
|
236
242
|
* - AES-GCM provides authenticated encryption; tampered cookies fail `read`
|
|
237
243
|
* (returns `null`).
|
|
238
244
|
* - `destroy` is a no-op on the server — the cookie must be cleared in the
|
|
@@ -294,7 +300,7 @@ export class EncryptedCookieSessionStore {
|
|
|
294
300
|
return this.#maxAgeMs;
|
|
295
301
|
}
|
|
296
302
|
#key(index) {
|
|
297
|
-
return (this.#keyPromises[index] ??=
|
|
303
|
+
return (this.#keyPromises[index] ??= deriveCookieKey(this.#secrets[index], SESSION_COOKIE_KEY_INFO));
|
|
298
304
|
}
|
|
299
305
|
/** Encrypts the session into the cookie value under the current secret; there is no server-side record. */
|
|
300
306
|
async create(data) {
|
|
@@ -303,8 +309,8 @@ export class EncryptedCookieSessionStore {
|
|
|
303
309
|
}
|
|
304
310
|
/**
|
|
305
311
|
* Decrypts the cookie value, trying each configured secret in turn. Returns
|
|
306
|
-
* `null` if it is tampered, malformed, sealed with an unknown secret
|
|
307
|
-
* sealed longer ago than
|
|
312
|
+
* `null` if it is tampered, malformed, sealed with an unknown secret or by
|
|
313
|
+
* another store, not in the shape `create` seals, or sealed longer ago than
|
|
308
314
|
* {@linkcode EncryptedCookieSessionStoreOptions.maxAgeMs}.
|
|
309
315
|
*/
|
|
310
316
|
async read(cookieValue) {
|
|
@@ -312,13 +318,9 @@ export class EncryptedCookieSessionStore {
|
|
|
312
318
|
const unsealed = await unsealJson(await this.#key(index), cookieValue);
|
|
313
319
|
if (unsealed === null)
|
|
314
320
|
continue;
|
|
315
|
-
if (isSealedSession(unsealed))
|
|
316
|
-
return
|
|
317
|
-
|
|
318
|
-
const legacy = unsealed;
|
|
319
|
-
return this.#isExpired(legacy.updatedAt ?? legacy.createdAt)
|
|
320
|
-
? null
|
|
321
|
-
: legacy;
|
|
321
|
+
if (!isSealedSession(unsealed))
|
|
322
|
+
return null;
|
|
323
|
+
return this.#isExpired(unsealed.issuedAt) ? null : unsealed.data;
|
|
322
324
|
}
|
|
323
325
|
return null;
|
|
324
326
|
}
|
package/dist/utils/crypto.d.ts
CHANGED
|
@@ -2,20 +2,14 @@
|
|
|
2
2
|
* Authenticated-encryption (AES-256-GCM) and key-derivation helpers for sealing
|
|
3
3
|
* session and token data at rest or in a cookie.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* a high-entropy *per-session* secret yields an encryption key that is
|
|
14
|
-
* independent of any other value derived from the same secret. This is what
|
|
15
|
-
* makes "the cookie value is both the lookup credential **and** the decryption
|
|
16
|
-
* key" safe: a database can store `sha256(secret)` as a lookup hash while the
|
|
17
|
-
* sealing key is `HKDF(secret, info)`, and knowing the stored hash never
|
|
18
|
-
* reveals the key. Always pass a distinct `info` per purpose.
|
|
5
|
+
* {@link deriveSealKey} uses **HKDF** with a domain-separating `info` label so a
|
|
6
|
+
* high-entropy secret yields an encryption key that is independent of any other
|
|
7
|
+
* value derived from the same secret. This is what makes "the cookie value is
|
|
8
|
+
* both the lookup credential **and** the decryption key" safe: a database can
|
|
9
|
+
* store `sha256(secret)` as a lookup hash while the sealing key is
|
|
10
|
+
* `HKDF(secret, info)`, and knowing the stored hash never reveals the key. It is
|
|
11
|
+
* also what lets one secret seal two kinds of value without either opening as
|
|
12
|
+
* the other. Always pass a distinct `info` per purpose.
|
|
19
13
|
*
|
|
20
14
|
* {@link seal}/{@link unseal} use AES-256-GCM (an AEAD), so tampered ciphertext
|
|
21
15
|
* fails to unseal (returns `null`) rather than decrypting to garbage.
|
|
@@ -61,18 +55,6 @@ export declare function base64urlDecode(value: string): Uint8Array;
|
|
|
61
55
|
* ```
|
|
62
56
|
*/
|
|
63
57
|
export declare function randomToken(bytes?: number): string;
|
|
64
|
-
/**
|
|
65
|
-
* Derive an AES-256-GCM key from a single server-held secret via SHA-256.
|
|
66
|
-
*
|
|
67
|
-
* Suitable when the secret is a long-lived configuration value that is never
|
|
68
|
-
* stored alongside the ciphertext. Do **not** use this when a hash of the same
|
|
69
|
-
* secret is persisted (e.g. as a lookup key) — use {@link deriveSealKey} so the
|
|
70
|
-
* sealing key is independent of that stored hash.
|
|
71
|
-
*
|
|
72
|
-
* @throws {Error} if the secret is shorter than 16 bytes. Length is all that is
|
|
73
|
-
* checked; supply 32+ bytes of real entropy.
|
|
74
|
-
*/
|
|
75
|
-
export declare function deriveAesKey(secret: string | Uint8Array): Promise<CryptoKey>;
|
|
76
58
|
/**
|
|
77
59
|
* Derive an AES-256-GCM sealing key from a high-entropy secret via HKDF-SHA-256
|
|
78
60
|
* with a domain-separating `info` label.
|
package/dist/utils/crypto.js
CHANGED
|
@@ -2,20 +2,14 @@
|
|
|
2
2
|
* Authenticated-encryption (AES-256-GCM) and key-derivation helpers for sealing
|
|
3
3
|
* session and token data at rest or in a cookie.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* a high-entropy *per-session* secret yields an encryption key that is
|
|
14
|
-
* independent of any other value derived from the same secret. This is what
|
|
15
|
-
* makes "the cookie value is both the lookup credential **and** the decryption
|
|
16
|
-
* key" safe: a database can store `sha256(secret)` as a lookup hash while the
|
|
17
|
-
* sealing key is `HKDF(secret, info)`, and knowing the stored hash never
|
|
18
|
-
* reveals the key. Always pass a distinct `info` per purpose.
|
|
5
|
+
* {@link deriveSealKey} uses **HKDF** with a domain-separating `info` label so a
|
|
6
|
+
* high-entropy secret yields an encryption key that is independent of any other
|
|
7
|
+
* value derived from the same secret. This is what makes "the cookie value is
|
|
8
|
+
* both the lookup credential **and** the decryption key" safe: a database can
|
|
9
|
+
* store `sha256(secret)` as a lookup hash while the sealing key is
|
|
10
|
+
* `HKDF(secret, info)`, and knowing the stored hash never reveals the key. It is
|
|
11
|
+
* also what lets one secret seal two kinds of value without either opening as
|
|
12
|
+
* the other. Always pass a distinct `info` per purpose.
|
|
19
13
|
*
|
|
20
14
|
* {@link seal}/{@link unseal} use AES-256-GCM (an AEAD), so tampered ciphertext
|
|
21
15
|
* fails to unseal (returns `null`) rather than decrypting to garbage.
|
|
@@ -79,25 +73,6 @@ export function base64urlDecode(value) {
|
|
|
79
73
|
export function randomToken(bytes = 32) {
|
|
80
74
|
return base64urlEncode(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
81
75
|
}
|
|
82
|
-
/**
|
|
83
|
-
* Derive an AES-256-GCM key from a single server-held secret via SHA-256.
|
|
84
|
-
*
|
|
85
|
-
* Suitable when the secret is a long-lived configuration value that is never
|
|
86
|
-
* stored alongside the ciphertext. Do **not** use this when a hash of the same
|
|
87
|
-
* secret is persisted (e.g. as a lookup key) — use {@link deriveSealKey} so the
|
|
88
|
-
* sealing key is independent of that stored hash.
|
|
89
|
-
*
|
|
90
|
-
* @throws {Error} if the secret is shorter than 16 bytes. Length is all that is
|
|
91
|
-
* checked; supply 32+ bytes of real entropy.
|
|
92
|
-
*/
|
|
93
|
-
export async function deriveAesKey(secret) {
|
|
94
|
-
const raw = toBytes(secret);
|
|
95
|
-
if (raw.byteLength < 16) {
|
|
96
|
-
throw new Error("secret must be at least 16 bytes; 32+ bytes of entropy is recommended.");
|
|
97
|
-
}
|
|
98
|
-
const digest = await crypto.subtle.digest("SHA-256", toArrayBuffer(raw));
|
|
99
|
-
return await crypto.subtle.importKey("raw", digest, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
|
|
100
|
-
}
|
|
101
76
|
/**
|
|
102
77
|
* Derive an AES-256-GCM sealing key from a high-entropy secret via HKDF-SHA-256
|
|
103
78
|
* with a domain-separating `info` label.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@udibo/oauth2",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"description": "A flexible OAuth2 toolkit: framework-agnostic authorization & resource servers, a typed client, Hono adapters (including a backend-for-frontend), and React bindings.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"authorization-server",
|