@worker-tools/signed-cookie-store
Version:
A partial implementation of the Cookie Store API that transparently signs and verifies cookies via the Web Cryptography API.
200 lines (170 loc) • 6.55 kB
text/typescript
import type {
CookieInit, CookieList, CookieListItem, CookieStore, CookieStoreDeleteOptions, CookieStoreGetOptions,
} from 'cookie-store-interface';
export * from 'cookie-store-interface';
import { bufferSourceToUint8Array } from "typed-array-utils";
import { Base64Decoder, Base64Encoder } from "base64-encoding";
const EXT = '.sig';
const secretToUint8Array = (secret: string | BufferSource) => typeof secret === 'string'
? new TextEncoder().encode(secret)
: bufferSourceToUint8Array(secret);
export interface SignedCookieStoreOptions {
/**
* One or more crypto keys that were previously used to sign cookies.
* `SignedCookieStore` will try to verify the signature using these, but they are not used for signing.
*/
keyring?: readonly CryptoKey[],
}
export interface DeriveOptions {
secret: string | BufferSource | JsonWebKey
salt?: BufferSource
iterations?: number
format?: KeyFormat,
hash?: HashAlgorithmIdentifier;
hmacHash?: HashAlgorithmIdentifier;
length?: number,
}
/**
* # Signed Cookie Store
* A partial implementation of the [Cookie Store API](https://wicg.github.io/cookie-store)
* that transparently signs and verifies cookies via the Web Cryptography API.
*
* This is likely only useful in server-side implementations,
* but written in a platform-agnostic way.
*/
export class SignedCookieStore implements CookieStore {
/**
* A helper function to derive a crypto key from a passphrase.
*/
static async deriveCryptoKey(opts: DeriveOptions): Promise<CryptoKey> {
if (!opts.secret) throw Error('Secret missing');
const passphraseKey = await (opts.format === 'jwk'
? crypto.subtle.importKey('jwk', opts.secret as JsonWebKey, 'PBKDF2', false, ['deriveKey'])
: crypto.subtle.importKey(
opts.format ?? 'raw',
secretToUint8Array(opts.secret as string | BufferSource),
'PBKDF2',
false,
['deriveKey', 'deriveBits']
)
);
const key = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
iterations: opts.iterations ?? 999,
hash: opts.hash ?? 'SHA-256',
salt: opts.salt
? bufferSourceToUint8Array(opts.salt)
: new Base64Decoder().decode('o0kcRbdpRH+H/WQzPI028A==')
},
passphraseKey,
{
name: 'HMAC',
hash: opts.hmacHash ?? 'SHA-256',
length: opts.length ?? 128
},
false,
['sign', 'verify'],
);
return key
}
#store: CookieStore;
#keyring: readonly CryptoKey[];
#key: CryptoKey;
constructor(store: CookieStore, key: CryptoKey, opts: SignedCookieStoreOptions = {}) {
this.#store = store;
this.#key = key;
this.#keyring = [key, ...opts.keyring ?? []];
}
#verify = async (cookie: CookieListItem, sigCookie: CookieListItem) => {
for (const key of this.#keyring) {
const signature = new Base64Decoder().decode(sigCookie.value);
const message = new TextEncoder().encode([cookie.name, cookie.value].join('='));
const ok = await crypto.subtle.verify('HMAC', key, signature, message);
if (ok) return true;
}
return false;
}
#sign = async (name: string, value: string): Promise<string> => {
const message = new TextEncoder().encode([name, value].join('='));
const signature = await crypto.subtle.sign('HMAC', this.#key, message);
return new Base64Encoder({ url: true }).encode(signature);
}
/**
* @throws if the signature doesn't match.
* @returns null when the signature cookie is missing.
*/
get(name?: string): Promise<CookieListItem | null>;
get(options?: CookieStoreGetOptions): Promise<CookieListItem | null>;
async get(name?: string | CookieStoreGetOptions): Promise<CookieListItem | null> {
if (typeof name !== 'string') throw Error('Overload not implemented.');
const [cookie, sigCookie] = await Promise.all([
this.#store.get(name),
this.#store.get(`${name}${EXT}`),
]);
if (!cookie || !sigCookie) return null;
const ok = await this.#verify(cookie, sigCookie);
if (!ok) throw Error('No key in the keyring can verify signature!')
return cookie;
}
/**
* @throws if any signature doesn't match.
* @returns A list of cookies, exclusive of all cookies without signatures
*/
getAll(name?: string): Promise<CookieList>;
getAll(options?: CookieStoreGetOptions): Promise<CookieList>;
async getAll(name?: string | CookieStoreGetOptions): Promise<CookieList> {
if (name != null) throw Error('Overload not implemented.');
const all = await this.#store.getAll();
const sigCookies = all.filter(x => x.name.endsWith(EXT))
const list: CookieList = [];
for (const sigCookie of sigCookies) {
const name = sigCookie.name;
const baseCookieName = name.substring(0, name.length - EXT.length);
const cookie = await this.get(baseCookieName);
if (cookie) list.push(cookie);
}
return list;
}
set(name: string, value: string): Promise<void>;
set(options: CookieInit): Promise<void>;
async set(options: string | CookieInit, value?: string) {
const [name, val] = typeof options === 'string'
? [options, value ?? '']
: [options.name, options.value ?? ''];
if (name.endsWith(EXT)) throw new Error('Illegal name');
const signature = await this.#sign(name, val);
const sigCookieName = `${name}${EXT}`;
if (typeof options === 'string') {
await Promise.all([
this.#store.set(options, val),
this.#store.set(sigCookieName, signature),
]);
} else {
// deno-lint-ignore no-unused-vars
const { name, value, ...init } = options;
await Promise.all([
this.#store.set(options),
this.#store.set({ ...init, name: sigCookieName, value: signature }),
]);
}
}
delete(name: string): Promise<void>;
delete(options: CookieStoreDeleteOptions): Promise<void>;
async delete(name: string | CookieStoreDeleteOptions): Promise<void> {
if (typeof name !== 'string') throw Error('Overload not implemented.');
await Promise.all([
this.#store.delete(name),
this.#store.delete(`${name}${EXT}`),
]);
}
addEventListener(...args: Parameters<CookieStore['addEventListener']>): void {
return this.#store.addEventListener(...args);
}
dispatchEvent(event: Event): boolean {
return this.#store.dispatchEvent(event);
}
removeEventListener(...args: Parameters<CookieStore['removeEventListener']>): void {
return this.#store.removeEventListener(...args);
}
}