feat: release authentication laboratories 0.3.0

This commit is contained in:
2026-08-20 00:21:31 +02:00
parent 53cc91f2a5
commit 6afa59d18f
44 changed files with 4332 additions and 66 deletions
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { utf8ToBytes } from "../../src/crypto/encoding";
import {
compareCollectionSnapshots,
createCollectionSnapshot,
parseCollectionSnapshot,
} from "../../src/otp/collection";
import type { OtpProfile } from "../../src/otp/profile";
function profile(account: string, secret: string): OtpProfile {
return {
kind: "totp",
issuer: "Example",
account,
secret: utf8ToBytes(secret),
algorithm: "SHA-1",
digits: 6,
period: 30,
counter: 0n,
extensions: new Map(),
};
}
describe("OTP collection snapshots", () => {
it("exports fingerprints without secrets", async () => {
const snapshot = await createCollectionSnapshot([
profile("alice", "top-secret-value"),
]);
const serialized = JSON.stringify(snapshot);
expect(serialized).not.toContain("top-secret-value");
expect(parseCollectionSnapshot(serialized)).toEqual(snapshot);
});
it("detects renames, parameter changes and secret reuse", async () => {
const before = await createCollectionSnapshot([
profile("alice", "secret-a"),
profile("bob", "secret-b"),
]);
const bob = profile("bob", "secret-b");
bob.digits = 8;
const after = await createCollectionSnapshot([
profile("alice-renamed", "secret-a"),
bob,
profile("carol", "secret-b"),
]);
const result = compareCollectionSnapshots(before, after);
expect(result.changes.map(({ kind }) => kind)).toEqual(
expect.arrayContaining([
"renamed",
"parameters-changed",
"added",
"secret-reused",
]),
);
expect(result.rotationPlan[0]?.kind).toBe("secret-reused");
});
});
+24
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
exportGoogleMigration,
exportCsv,
importCsv,
importGoogleMigrationBatch,
@@ -115,4 +116,27 @@ describe("OTP migrations", () => {
importGoogleMigrationBatch([googlePart(0), googlePart(1, 2, 99)]),
).toThrow(/different batches/iu);
});
it("exports and reassembles Google migration batches", () => {
const portable = { ...profile, period: 30 };
const exported = exportGoogleMigration(
[portable, { ...portable, account: "bob" }],
1,
);
expect(exported.uris).toHaveLength(2);
const imported = importGoogleMigrationBatch(exported.uris);
expect(imported.profiles.map(({ account }) => account)).toEqual([
profile.account,
"bob",
]);
expect(imported.profiles[0]).toMatchObject({
algorithm: "SHA-256",
digits: 8,
period: 30,
});
});
it("refuses lossy Google exports", () => {
expect(() => exportGoogleMigration([profile])).toThrow(/period or epoch/u);
});
});
+28
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { utf8ToBytes } from "../../src/crypto/encoding";
import {
hotp,
resynchronizeHotp,
totp,
totpCounter,
verifyHotp,
@@ -41,6 +42,33 @@ describe("HOTP", () => {
});
expect(match).toMatchObject({ counter: 4n, delta: 3 });
});
it("resynchronizes with one or two bounded consecutive codes", async () => {
const secret = utf8ToBytes("12345678901234567890");
await expect(
resynchronizeHotp({
firstCode: "338314",
secondCode: "254676",
secret,
counter: 1n,
lookAhead: 10,
}),
).resolves.toEqual({
matchedCounter: 4n,
nextCounter: 6n,
distance: 3,
confidence: "consecutive-codes",
});
await expect(
resynchronizeHotp({
firstCode: "338314",
secondCode: "000000",
secret,
counter: 1n,
lookAhead: 10,
}),
).resolves.toBeNull();
});
});
describe("TOTP", () => {
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { utf8ToBytes } from "../../src/crypto/encoding";
import { exportEncryptedPskc, importEncryptedPskc } from "../../src/otp/pskc";
import type { OtpProfile } from "../../src/otp/profile";
const profiles: OtpProfile[] = [
{
kind: "totp",
account: "alice@example.test",
issuer: "Example & Sons",
secret: utf8ToBytes("12345678901234567890"),
algorithm: "SHA-256",
digits: 8,
period: 45,
epoch: 10,
counter: 0n,
extensions: new Map(),
},
{
kind: "hotp",
account: "hardware token",
issuer: "Example",
secret: utf8ToBytes("abcdefghijklmnopqrst"),
algorithm: "SHA-1",
digits: 6,
period: 30,
counter: 1042n,
extensions: new Map(),
},
];
describe("encrypted PSKC", () => {
it("round-trips authenticated password-encrypted collections", async () => {
const exported = await exportEncryptedPskc(
profiles,
"correct horse battery staple",
{ iterations: 100_000 },
);
expect(exported).toContain("PBKDF2-params");
expect(exported).toContain(
'Algorithm="http://www.rsasecurity.com/rsalabs/pkcs/schemas/pkcs-5v2-0#pbkdf2"',
);
expect(exported).toContain("<pskc:MACKey><xenc:EncryptionMethod");
expect(exported).toContain('<pskc:EncryptedValue Id="ED-1">');
expect(exported).not.toContain("12345678901234567890");
const result = await importEncryptedPskc(
exported,
"correct horse battery staple",
);
expect(result.profiles).toHaveLength(2);
expect(result.profiles[0]).toMatchObject({
kind: "totp",
account: "alice@example.test",
issuer: "Example & Sons",
algorithm: "SHA-256",
digits: 8,
period: 45,
epoch: 10,
});
expect(result.profiles[1]).toMatchObject({
kind: "hotp",
counter: 1042n,
});
expect([...result.profiles[0]!.secret]).toEqual([...profiles[0]!.secret]);
});
it("rejects wrong passwords and unauthenticated changes", async () => {
const exported = await exportEncryptedPskc(
profiles.slice(0, 1),
"correct horse battery staple",
{ iterations: 100_000 },
);
await expect(
importEncryptedPskc(exported, "incorrect password"),
).rejects.toThrow(/incorrect|damaged/u);
const tampered = exported.replace(
/<pskc:ValueMAC>([^<])/u,
(_match, first: string) => `<pskc:ValueMAC>${first === "A" ? "B" : "A"}`,
);
await expect(
importEncryptedPskc(tampered, "correct horse battery staple"),
).rejects.toThrow(/ValueMAC/u);
});
it("rejects XML entity declarations", async () => {
await expect(
importEncryptedPskc(
'<!DOCTYPE x [<!ENTITY e SYSTEM "file:///etc/passwd">]><x/>',
"password",
),
).rejects.toThrow(/entity/u);
});
});
+96
View File
@@ -1,13 +1,46 @@
import { describe, expect, it } from "vitest";
import {
importAegisBackup,
importEncryptedAegisBackup,
importAndOtpBackup,
importFreeOtpBackup,
importTwoFasBackup,
} from "../../src/otp/vendor-backups";
import { scrypt } from "@noble/hashes/scrypt.js";
import {
bytesToArrayBuffer,
bytesToHex,
utf8ToBytes,
} from "../../src/crypto/encoding";
const secret = "JBSWY3DPEHPK3PXP";
async function encryptGcm(
keyBytes: Uint8Array,
plaintext: Uint8Array,
nonce: Uint8Array,
): Promise<{ ciphertext: Uint8Array; tag: Uint8Array }> {
const key = await crypto.subtle.importKey(
"raw",
bytesToArrayBuffer(keyBytes),
"AES-GCM",
false,
["encrypt"],
);
const output = new Uint8Array(
await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: bytesToArrayBuffer(nonce) },
key,
bytesToArrayBuffer(plaintext),
),
);
return { ciphertext: output.slice(0, -16), tag: output.slice(-16) };
}
function base64(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes));
}
describe("vendor OTP backup importers", () => {
it("imports Aegis plaintext entries and skips non-portable token types", () => {
const result = importAegisBackup(
@@ -90,4 +123,67 @@ describe("vendor OTP backup importers", () => {
importAegisBackup(JSON.stringify({ db: "ciphertext", header: {} })),
).toThrow(/encrypted/iu);
});
it("decrypts authenticated Aegis password vaults locally", async () => {
const password = "correct horse battery staple";
const salt = Uint8Array.from({ length: 16 }, (_, index) => index + 1);
const masterKey = Uint8Array.from(
{ length: 32 },
(_, index) => 255 - index,
);
const wrappingKey = scrypt(password, salt, {
N: 16,
r: 8,
p: 1,
dkLen: 32,
maxmem: 1024 * 1024,
});
const slotNonce = Uint8Array.from({ length: 12 }, (_, index) => index + 20);
const dbNonce = Uint8Array.from({ length: 12 }, (_, index) => index + 40);
const wrapped = await encryptGcm(wrappingKey, masterKey, slotNonce);
const database = utf8ToBytes(
JSON.stringify({
entries: [
{
type: "totp",
name: "alice",
issuer: "Example",
info: { secret, algo: "SHA1", digits: 6, period: 30 },
},
],
}),
);
const encrypted = await encryptGcm(masterKey, database, dbNonce);
const vault = JSON.stringify({
version: 1,
header: {
slots: [
{
type: 1,
n: 16,
r: 8,
p: 1,
salt: bytesToHex(salt),
key: bytesToHex(wrapped.ciphertext),
key_params: {
nonce: bytesToHex(slotNonce),
tag: bytesToHex(wrapped.tag),
},
},
],
params: { nonce: bytesToHex(dbNonce), tag: bytesToHex(encrypted.tag) },
},
db: base64(encrypted.ciphertext),
});
await expect(
importEncryptedAegisBackup(vault, password),
).resolves.toMatchObject({
profiles: [
expect.objectContaining({ account: "alice", issuer: "Example" }),
],
});
await expect(importEncryptedAegisBackup(vault, "wrong")).rejects.toThrow(
/incorrect/u,
);
});
});