Files
WRNexusJS/packages/encryption/src/keyring.ts
T
2026-07-27 12:42:18 +05:30

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;
}