102 lines
3.2 KiB
TypeScript
102 lines
3.2 KiB
TypeScript
import { decrypt, encrypt, generateKey } from "./index.ts";
|
|
|
|
export interface EncryptionKey {
|
|
id: string;
|
|
secret: string;
|
|
active?: boolean;
|
|
createdAt?: number;
|
|
}
|
|
|
|
export interface EncryptionKeyring {
|
|
active(): EncryptionKey;
|
|
get(id: string): EncryptionKey | undefined;
|
|
keys(): EncryptionKey[];
|
|
rotate(key?: EncryptionKey): Promise<EncryptionKey>;
|
|
remove(id: string): boolean;
|
|
}
|
|
|
|
const KEY_ID = /^[A-Za-z0-9._-]{1,64}$/;
|
|
|
|
function validateKey(key: EncryptionKey): void {
|
|
if (!KEY_ID.test(key.id)) {
|
|
throw new TypeError(`Invalid encryption key id: ${key.id}`);
|
|
}
|
|
if (!key.secret.trim()) {
|
|
throw new TypeError(`Encryption key '${key.id}' has an empty secret`);
|
|
}
|
|
}
|
|
|
|
export function createKeyring(initial: EncryptionKey[]): EncryptionKeyring {
|
|
const values = new Map<string, EncryptionKey>();
|
|
let activeCount = 0;
|
|
for (const key of initial) {
|
|
validateKey(key);
|
|
if (values.has(key.id)) {
|
|
throw new Error(`WRN-ENCRYPTION-KEYRING-DUPLICATE: ${key.id}`);
|
|
}
|
|
if (key.active) activeCount++;
|
|
values.set(key.id, { ...key });
|
|
}
|
|
if (!values.size) throw new Error("WRN-ENCRYPTION-KEYRING-EMPTY");
|
|
if (activeCount > 1) throw new Error("WRN-ENCRYPTION-KEYRING-MULTIPLE-ACTIVE");
|
|
|
|
const activeInternal = (): EncryptionKey => {
|
|
const key = [...values.values()].find((entry) => entry.active) ?? [...values.values()].at(-1);
|
|
if (!key) throw new Error("WRN-ENCRYPTION-ACTIVE-KEY-MISSING");
|
|
return key;
|
|
};
|
|
|
|
return {
|
|
active: () => ({ ...activeInternal() }),
|
|
get(id) {
|
|
const key = values.get(id);
|
|
return key ? { ...key } : undefined;
|
|
},
|
|
keys: () => [...values.values()].map((key) => ({ ...key })),
|
|
async rotate(key) {
|
|
const next = key ?? {
|
|
id: `key-${Date.now().toString(36)}`,
|
|
secret: await generateKey(),
|
|
createdAt: Date.now(),
|
|
};
|
|
validateKey(next);
|
|
for (const current of values.values()) current.active = false;
|
|
const stored = { ...next, active: true };
|
|
values.set(stored.id, stored);
|
|
return { ...stored };
|
|
},
|
|
remove(id) {
|
|
if (!values.has(id)) return false;
|
|
if (values.size <= 1) {
|
|
throw new Error("WRN-ENCRYPTION-KEYRING-LAST-KEY");
|
|
}
|
|
if (activeInternal().id === id) {
|
|
throw new Error("WRN-ENCRYPTION-KEYRING-ACTIVE-REMOVE");
|
|
}
|
|
return values.delete(id);
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Versioned payload: `wrn1.<key-id>.<aes-gcm-payload>`. */
|
|
export async function seal(plaintext: string, keyring: EncryptionKeyring): Promise<string> {
|
|
const key = keyring.active();
|
|
return `wrn1.${key.id}.${await encrypt(plaintext, key.secret)}`;
|
|
}
|
|
|
|
export async function open(sealed: string, keyring: EncryptionKeyring): Promise<string> {
|
|
const match = /^wrn1\.([A-Za-z0-9._-]{1,64})\.(.+)$/.exec(sealed);
|
|
if (!match) throw new Error("WRN-ENCRYPTION-PAYLOAD-VERSION");
|
|
const key = keyring.get(match[1]!);
|
|
if (!key) throw new Error(`WRN-ENCRYPTION-KEY-NOT-FOUND: ${match[1]}`);
|
|
return decrypt(match[2]!, key.secret);
|
|
}
|
|
|
|
export function sealedKeyId(sealed: string): string | null {
|
|
return /^wrn1\.([A-Za-z0-9._-]{1,64})\./.exec(sealed)?.[1] ?? null;
|
|
}
|
|
|
|
export function needsRotation(sealed: string, keyring: EncryptionKeyring): boolean {
|
|
return sealedKeyId(sealed) !== keyring.active().id;
|
|
}
|