feat: release authentication diagnostics 0.2.0

This commit is contained in:
2026-08-19 14:13:48 +02:00
parent 603559c540
commit 53cc91f2a5
41 changed files with 3472 additions and 154 deletions
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { utf8ToBytes } from "../../src/crypto/encoding";
import {
credentialHealthReport,
findTotpDrift,
generateTotpTimeline,
} from "../../src/otp/diagnostics";
import type { OtpProfile } from "../../src/otp/profile";
const profile: OtpProfile = {
kind: "totp",
secret: utf8ToBytes("12345678901234567890"),
issuer: "RFC",
account: "vector",
algorithm: "SHA-1",
digits: 8,
period: 30,
epoch: 0,
counter: 0n,
extensions: new Map(),
};
describe("OTP diagnostics", () => {
it("builds an ordered RFC timeline and finds clock drift", async () => {
const timeline = await generateTotpTimeline(profile, 59, 1, 2);
expect(timeline.map((item) => item.delta)).toEqual([-1, 0, 1, 2]);
expect(timeline[1]).toMatchObject({ code: "94287082", counter: 1n });
const drift = await findTotpDrift(timeline[2]!.code, profile, 59, 5);
expect(drift).toMatchObject({ delta: 1, driftSeconds: 30 });
});
it("honors a custom T0 and bounds diagnostic work", async () => {
const shifted = { ...profile, epoch: 30 };
expect((await generateTotpTimeline(shifted, 59, 0, 0))[0]!.counter).toBe(
0n,
);
await expect(
findTotpDrift("12345678", profile, 59, 10_001),
).rejects.toThrow(/10000/u);
});
it("detects duplicate secrets without exposing their fingerprint", async () => {
const report = await credentialHealthReport([
profile,
{ ...profile, issuer: "Other", account: "second" },
]);
expect(report.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: "reused-secret", severity: "danger" }),
]),
);
expect(JSON.stringify(report)).not.toContain("31323334");
});
});
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { utf8ToBytes } from "../../src/crypto/encoding";
import {
decryptOtpBackup,
encryptOtpBackup,
} from "../../src/otp/encrypted-backup";
import type { OtpProfile } from "../../src/otp/profile";
const profile: OtpProfile = {
kind: "totp",
secret: utf8ToBytes("12345678901234567890"),
issuer: "Example",
account: "alice",
algorithm: "SHA-256",
digits: 8,
period: 45,
epoch: 12,
counter: 0n,
extensions: new Map([["image", "none"]]),
};
describe("encrypted OTP backups", () => {
it("round-trips profiles through authenticated encryption", async () => {
const encrypted = await encryptOtpBackup(
[profile],
"correct horse battery staple",
);
expect(encrypted).not.toContain("JBSWY");
const restored = await decryptOtpBackup(
encrypted,
"correct horse battery staple",
);
expect(restored[0]).toMatchObject({
issuer: "Example",
account: "alice",
epoch: 12,
counter: 0n,
});
expect([...restored[0]!.secret]).toEqual([...profile.secret]);
});
it("rejects a wrong password and tampering", async () => {
const encrypted = await encryptOtpBackup(
[profile],
"correct horse battery staple",
);
await expect(
decryptOtpBackup(encrypted, "different secure password"),
).rejects.toThrow(/authentication failed/iu);
const parsed = JSON.parse(encrypted) as { ciphertext: string };
parsed.ciphertext = `${parsed.ciphertext.slice(0, -2)}AA`;
await expect(
decryptOtpBackup(JSON.stringify(parsed), "correct horse battery staple"),
).rejects.toThrow(/authentication failed/iu);
});
});
+53 -1
View File
@@ -2,10 +2,11 @@ import { describe, expect, it } from "vitest";
import {
exportCsv,
importCsv,
importGoogleMigrationBatch,
importOtpAuthList,
importPlainPskc,
} from "../../src/otp/migration";
import { utf8ToBytes } from "../../src/crypto/encoding";
import { bytesToBase64Url, utf8ToBytes } from "../../src/crypto/encoding";
import type { OtpProfile } from "../../src/otp/profile";
const profile: OtpProfile = {
@@ -20,6 +21,43 @@ const profile: OtpProfile = {
extensions: new Map(),
};
function varint(value: number): number[] {
const output: number[] = [];
let remaining = value;
do {
let byte = remaining & 0x7f;
remaining >>>= 7;
if (remaining) byte |= 0x80;
output.push(byte);
} while (remaining);
return output;
}
function field(number: number, value: number | Uint8Array): number[] {
return typeof value === "number"
? [...varint(number << 3), ...varint(value)]
: [...varint((number << 3) | 2), ...varint(value.length), ...value];
}
function googlePart(index: number, size = 2, id = 73): string {
const credential = Uint8Array.from([
...field(1, utf8ToBytes("12345678901234567890")),
...field(2, utf8ToBytes(`user-${index}`)),
...field(3, utf8ToBytes("Example")),
...field(4, 1),
...field(5, 1),
...field(6, 2),
]);
const payload = Uint8Array.from([
...field(1, credential),
...field(2, 1),
...field(3, size),
...field(4, index),
...field(5, id),
]);
return `otpauth-migration://offline?data=${bytesToBase64Url(payload)}`;
}
describe("OTP migrations", () => {
it("round-trips the documented CSV including quoting", () => {
const result = importCsv(exportCsv([profile]));
@@ -63,4 +101,18 @@ describe("OTP migrations", () => {
),
).toThrow(/encrypted/iu);
});
it("assembles Google multi-QR batches in index order", () => {
const result = importGoogleMigrationBatch([googlePart(1), googlePart(0)]);
expect(result.profiles.map((item) => item.account)).toEqual([
"user-0",
"user-1",
]);
expect(() => importGoogleMigrationBatch([googlePart(0)])).toThrow(
/missing part 2/iu,
);
expect(() =>
importGoogleMigrationBatch([googlePart(0), googlePart(1, 2, 99)]),
).toThrow(/different batches/iu);
});
});
+11
View File
@@ -58,4 +58,15 @@ describe("otpauth profiles", () => {
true,
);
});
it("round-trips a diagnostic RFC 6238 T0 with a portability warning", () => {
const result = parseOtpAuth(
"otpauth://totp/Example?secret=JBSWY3DPEHPK3PXP&t0=1234",
);
expect(result.profile.epoch).toBe(1234);
expect(
result.warnings.some(({ code }) => code === "nonstandard-epoch"),
).toBe(true);
expect(serializeOtpAuth(result.profile)).toContain("t0=1234");
});
});
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import {
importAegisBackup,
importAndOtpBackup,
importFreeOtpBackup,
importTwoFasBackup,
} from "../../src/otp/vendor-backups";
const secret = "JBSWY3DPEHPK3PXP";
describe("vendor OTP backup importers", () => {
it("imports Aegis plaintext entries and skips non-portable token types", () => {
const result = importAegisBackup(
JSON.stringify({
db: {
entries: [
{
type: "totp",
name: "alice",
issuer: "Example",
info: { secret, algo: "SHA1", digits: 6, period: 30 },
},
{ type: "steam", name: "game", issuer: "Steam", info: { secret } },
],
},
}),
);
expect(result.profiles[0]).toMatchObject({
issuer: "Example",
account: "alice",
});
expect(result.warnings[0]).toMatch(/steam/iu);
});
it("imports 2FAS, andOTP and FreeOTP field layouts", () => {
expect(
importTwoFasBackup(
JSON.stringify({
services: [
{
name: "Example",
secret,
otp: {
account: "alice",
issuer: "Issuer",
tokenType: "TOTP",
algorithm: "SHA1",
digits: 6,
period: 30,
},
},
],
}),
).profiles[0],
).toMatchObject({ account: "alice", issuer: "Issuer" });
expect(
importAndOtpBackup(
JSON.stringify([
{
secret,
issuer: "Example",
label: "bob",
type: "TOTP",
algorithm: "SHA1",
digits: 6,
period: 30,
},
]),
).profiles[0]!.account,
).toBe("bob");
expect(
importFreeOtpBackup(
JSON.stringify([
{
secret: [49, 50, 51, 52],
issuerExt: "Example",
label: "carol",
type: "totp",
algo: "SHA1",
digits: 6,
period: 30,
},
]),
).profiles[0]!.account,
).toBe("carol");
});
it("refuses encrypted Aegis content instead of guessing", () => {
expect(() =>
importAegisBackup(JSON.stringify({ db: "ciphertext", header: {} })),
).toThrow(/encrypted/iu);
});
});