1 |
|
2 | import type {
|
3 | CookieInit, CookieList, CookieListItem, CookieStore, CookieStoreDeleteOptions, CookieStoreGetOptions,
|
4 | } from 'cookie-store-interface';
|
5 | export * from 'cookie-store-interface';
|
6 |
|
7 | import { bufferSourceToUint8Array, concatBufferSources, splitBufferSource } from "typed-array-utils";
|
8 | import { Base64Decoder, Base64Encoder } from "base64-encoding";
|
9 | import { AggregateError } from "./aggregate-error.js";
|
10 |
|
11 | const EXT = '.enc';
|
12 | const IV_LENGTH = 16;
|
13 |
|
14 | const secretToUint8Array = (secret: string | BufferSource) => typeof secret === 'string'
|
15 | ? new TextEncoder().encode(secret)
|
16 | : bufferSourceToUint8Array(secret);
|
17 |
|
18 | export interface EncryptedCookieStoreOptions {
|
19 | |
20 |
|
21 |
|
22 |
|
23 | keyring?: readonly CryptoKey[],
|
24 | }
|
25 |
|
26 | export interface DeriveOptions {
|
27 | secret: string | BufferSource | JsonWebKey
|
28 | salt?: BufferSource
|
29 | iterations?: number
|
30 | format?: KeyFormat,
|
31 | hash?: HashAlgorithmIdentifier;
|
32 | hmacHash?: HashAlgorithmIdentifier;
|
33 | length?: number,
|
34 | }
|
35 |
|
36 |
|
37 |
|
38 |
|
39 |
|
40 |
|
41 |
|
42 |
|
43 |
|
44 | export class EncryptedCookieStore implements CookieStore {
|
45 |
|
46 | static async deriveCryptoKey(opts: DeriveOptions): Promise<CryptoKey> {
|
47 | if (!opts.secret) throw Error('Secret missing');
|
48 |
|
49 | const passphraseKey = await (opts.format === 'jwk'
|
50 | ? crypto.subtle.importKey('jwk', opts.secret as JsonWebKey, 'PBKDF2', false, ['deriveKey'])
|
51 | : crypto.subtle.importKey(
|
52 | opts.format ?? 'raw',
|
53 | secretToUint8Array(opts.secret as string | BufferSource),
|
54 | 'PBKDF2',
|
55 | false,
|
56 | ['deriveKey', 'deriveBits']
|
57 | )
|
58 | );
|
59 |
|
60 | const key = await crypto.subtle.deriveKey(
|
61 | {
|
62 | name: 'PBKDF2',
|
63 | iterations: opts.iterations ?? 999,
|
64 | hash: opts.hash ?? 'SHA-256',
|
65 | salt: opts.salt
|
66 | ? bufferSourceToUint8Array(opts.salt)
|
67 | : new Base64Decoder().decode('Gfw5ic5qS062JvoubvO+DA==')
|
68 | },
|
69 | passphraseKey,
|
70 | {
|
71 | name: 'AES-GCM',
|
72 | length: opts.length ?? 256,
|
73 | },
|
74 | false,
|
75 | ['encrypt', 'decrypt'],
|
76 | );
|
77 |
|
78 | return key;
|
79 | }
|
80 |
|
81 | #store: CookieStore;
|
82 | #keyring: readonly CryptoKey[];
|
83 | #key: CryptoKey;
|
84 |
|
85 | constructor(store: CookieStore, key: CryptoKey, opts: EncryptedCookieStoreOptions = {}) {
|
86 | this.#store = store;
|
87 | this.#key = key
|
88 | this.#keyring = [key, ...opts.keyring ?? []];
|
89 | }
|
90 |
|
91 | get(name?: string): Promise<CookieListItem | null>;
|
92 | get(options?: CookieStoreGetOptions): Promise<CookieListItem | null>;
|
93 | async get(name?: string | CookieStoreGetOptions): Promise<CookieListItem | null> {
|
94 | if (typeof name !== 'string') throw Error('Overload not implemented.');
|
95 |
|
96 | const cookie = await this.#store.get(`${name}${EXT}`);
|
97 | if (!cookie) return cookie;
|
98 |
|
99 |
|
100 | return this.#decrypt(cookie);
|
101 | }
|
102 |
|
103 | getAll(name?: string): Promise<CookieList>;
|
104 | getAll(options?: CookieStoreGetOptions): Promise<CookieList>;
|
105 | async getAll(options?: any) {
|
106 | if (options != null) throw Error('Overload not implemented.');
|
107 |
|
108 | const list: CookieList = [];
|
109 | for (const cookie of await this.#store.getAll(options)) {
|
110 | if (cookie.name.endsWith(EXT)) {
|
111 | list.push(await this.#decrypt(cookie));
|
112 | }
|
113 | }
|
114 | return list;
|
115 | }
|
116 |
|
117 | set(name: string, value: string): Promise<void>;
|
118 | set(options: CookieInit): Promise<void>;
|
119 | async set(options: string | CookieInit, value?: string) {
|
120 | const [name, val] = typeof options === 'string'
|
121 | ? [options, value ?? '']
|
122 | : [options.name, options.value ?? ''];
|
123 |
|
124 |
|
125 | const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
126 | const message = new TextEncoder().encode(val);
|
127 | const cipher = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, this.#key, message);
|
128 | const cipherB64 = new Base64Encoder({ url: true }).encode(concatBufferSources(iv, cipher));
|
129 | return this.#store.set({
|
130 | ...typeof options === 'string' ? {} : options,
|
131 | name: `${name}${EXT}`,
|
132 | value: cipherB64,
|
133 | });
|
134 | }
|
135 |
|
136 | delete(name: string): Promise<void>;
|
137 | delete(options: CookieStoreDeleteOptions): Promise<void>;
|
138 | delete(options: any) {
|
139 | if (typeof options !== 'string') throw Error('Overload not implemented.');
|
140 | return this.#store.delete(`${options}${EXT}`);
|
141 | }
|
142 |
|
143 | #decrypt = async (cookie: CookieListItem): Promise<CookieListItem> => {
|
144 | const errors: any[] = [];
|
145 | for (const key of this.#keyring) {
|
146 | try {
|
147 | const buffer = new Base64Decoder().decode(cookie.value);
|
148 | const [iv, cipher] = splitBufferSource(buffer, IV_LENGTH);
|
149 | const clearBuffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, cipher);
|
150 | const clearText = new TextDecoder().decode(clearBuffer);
|
151 | cookie.name = cookie.name.substring(0, cookie.name.length - EXT.length);
|
152 | cookie.value = clearText;
|
153 | return cookie;
|
154 | } catch (err) {
|
155 | errors.push(err);
|
156 | }
|
157 | }
|
158 | throw new AggregateError(errors, 'None of the provided keys was able to decrypt the cookie.');
|
159 | }
|
160 |
|
161 | addEventListener(...args: Parameters<CookieStore['addEventListener']>): void {
|
162 | return this.#store.addEventListener(...args);
|
163 | }
|
164 | dispatchEvent(event: Event): boolean {
|
165 | return this.#store.dispatchEvent(event);
|
166 | }
|
167 | removeEventListener(...args: Parameters<CookieStore['removeEventListener']>): void {
|
168 | return this.#store.removeEventListener(...args);
|
169 | }
|
170 | }
|