UNPKG

5.9 kBPlain TextView Raw
1// deno-lint-ignore-file no-explicit-any
2import type {
3 CookieInit, CookieList, CookieListItem, CookieStore, CookieStoreDeleteOptions, CookieStoreGetOptions,
4} from 'cookie-store-interface';
5export * from 'cookie-store-interface';
6
7import { bufferSourceToUint8Array, concatBufferSources, splitBufferSource } from "typed-array-utils";
8import { Base64Decoder, Base64Encoder } from "base64-encoding";
9import { AggregateError } from "./aggregate-error.js";
10
11const EXT = '.enc';
12const IV_LENGTH = 16; // bytes
13
14const secretToUint8Array = (secret: string | BufferSource) => typeof secret === 'string'
15 ? new TextEncoder().encode(secret)
16 : bufferSourceToUint8Array(secret);
17
18export interface EncryptedCookieStoreOptions {
19 /**
20 * One or more crypto keys that were previously used to encrypt cookies.
21 * `EncryptedCookieStore` will try to decrypt cookies using these, but they are not used for encrypting new cookies.
22 */
23 keyring?: readonly CryptoKey[],
24}
25
26export 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 * # Encrypted Cookie Store
38 * A partial implementation of the [Cookie Store API](https://wicg.github.io/cookie-store)
39 * that transparently encrypts and decrypts cookies via AES-GCM.
40 *
41 * This is likely only useful in server-side implementations,
42 * but written in a platform-agnostic way.
43 */
44export class EncryptedCookieStore implements CookieStore {
45 /** A helper function to derive a crypto key from a passphrase */
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 // FIXME: empty values!
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 // FIXME: empty string!
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}