release: WRNexusJS 0.4.0

This commit is contained in:
2026-07-27 12:42:18 +05:30
parent 8b728a3e5d
commit 30e5721e84
250 changed files with 10065 additions and 3923 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/encryption",
"version": "0.3.6",
"version": "0.4.0",
"private": true,
"type": "module",
"main": "src/index.ts",
+2
View File
@@ -138,3 +138,5 @@ export async function deriveKey(password: string, salt: string): Promise<string>
);
return toB64(new Uint8Array(bits));
}
export { createKeyring, seal, open, sealedKeyId, needsRotation } from "./keyring.ts";
export type { EncryptionKey, EncryptionKeyring } from "./keyring.ts";
+101
View File
@@ -0,0 +1,101 @@
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;
}
@@ -7,6 +7,7 @@ import {
sha256,
hmacSign,
hmacVerify,
createKeyring,
} from "../src/index.ts";
test("sha256 is stable and hex-encoded", async () => {
@@ -56,3 +57,20 @@ test("deriveKey is deterministic for the same password+salt", async () => {
// usable as an encryption key
expect(await decrypt(await encrypt("x", k1), k1)).toBe("x");
});
test("keyrings reject duplicate keys and return defensive copies", () => {
expect(() =>
createKeyring([
{ id: "one", secret: "secret-one", active: true },
{ id: "one", secret: "secret-two" },
]),
).toThrow("DUPLICATE");
const keyring = createKeyring([
{ id: "one", secret: "secret-one", active: true },
{ id: "two", secret: "secret-two" },
]);
const active = keyring.active();
active.secret = "changed";
expect(keyring.active().secret).toBe("secret-one");
});