feat: release authentication diagnostics 0.2.0
This commit is contained in:
@@ -86,4 +86,29 @@ describe("authentication workbench", () => {
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the new OTP diagnostics and attestation workspaces", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
await user.click(screen.getByRole("button", { name: "Drift & timeline" }));
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "OTP timeline" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Clock-drift finder" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /WebAuthn \/ Passkeys/iu }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Attestation verifier" }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Attestation evidence" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "FIDO Metadata BLOB" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { decodeQrImage } from "../../src/qr/decoder";
|
||||
|
||||
describe("QR image decoding", () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("uses native QR detection when available and closes the bitmap", async () => {
|
||||
const close = vi.fn();
|
||||
vi.stubGlobal(
|
||||
"createImageBitmap",
|
||||
vi.fn().mockResolvedValue({ width: 128, height: 128, close }),
|
||||
);
|
||||
vi.stubGlobal(
|
||||
"BarcodeDetector",
|
||||
class {
|
||||
async detect() {
|
||||
return [
|
||||
{ rawValue: "otpauth://totp/Example?secret=JBSWY3DPEHPK3PXP" },
|
||||
];
|
||||
}
|
||||
},
|
||||
);
|
||||
await expect(
|
||||
decodeQrImage(new File([new Uint8Array(10)], "qr.png")),
|
||||
).resolves.toMatch(/^otpauth:/u);
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects oversized dimensions before pixel extraction", async () => {
|
||||
const close = vi.fn();
|
||||
vi.stubGlobal(
|
||||
"createImageBitmap",
|
||||
vi.fn().mockResolvedValue({ width: 4097, height: 1, close }),
|
||||
);
|
||||
await expect(
|
||||
decodeQrImage(new File([new Uint8Array(10)], "huge.png")),
|
||||
).rejects.toThrow(/dimensions/iu);
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
bytesToArrayBuffer,
|
||||
bytesToBase64Url,
|
||||
utf8ToBytes,
|
||||
} from "../../src/crypto/encoding";
|
||||
import { verifyAttestation } from "../../src/webauthn/attestation";
|
||||
|
||||
function bytesValue(value: Uint8Array): number[] {
|
||||
if (value.length < 24) return [0x40 | value.length, ...value];
|
||||
if (value.length < 256) return [0x58, value.length, ...value];
|
||||
return [0x59, value.length >>> 8, value.length & 0xff, ...value];
|
||||
}
|
||||
|
||||
function textValue(value: string): number[] {
|
||||
const encoded = utf8ToBytes(value);
|
||||
return [0x60 | encoded.length, ...encoded];
|
||||
}
|
||||
|
||||
function noneAttestation(
|
||||
authenticatorData: Uint8Array,
|
||||
format = "none",
|
||||
): Uint8Array {
|
||||
return Uint8Array.from([
|
||||
0xa3,
|
||||
...textValue("fmt"),
|
||||
...textValue(format),
|
||||
...textValue("authData"),
|
||||
...bytesValue(authenticatorData),
|
||||
...textValue("attStmt"),
|
||||
0xa0,
|
||||
]);
|
||||
}
|
||||
|
||||
async function registrationData(): Promise<{
|
||||
attestationObject: string;
|
||||
clientDataJSON: string;
|
||||
challenge: string;
|
||||
}> {
|
||||
const challenge = bytesToBase64Url(Uint8Array.of(1, 2, 3, 4));
|
||||
const client = utf8ToBytes(
|
||||
JSON.stringify({
|
||||
type: "webauthn.create",
|
||||
challenge,
|
||||
origin: "https://example.test",
|
||||
crossOrigin: false,
|
||||
}),
|
||||
);
|
||||
const rpHash = new Uint8Array(
|
||||
await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
bytesToArrayBuffer(utf8ToBytes("example.test")),
|
||||
),
|
||||
);
|
||||
const cose = Uint8Array.from([
|
||||
0xa5,
|
||||
0x01,
|
||||
0x02,
|
||||
0x03,
|
||||
0x26,
|
||||
0x20,
|
||||
0x01,
|
||||
0x21,
|
||||
0x58,
|
||||
0x20,
|
||||
...new Uint8Array(32).fill(1),
|
||||
0x22,
|
||||
0x58,
|
||||
0x20,
|
||||
...new Uint8Array(32).fill(2),
|
||||
]);
|
||||
const authData = Uint8Array.from([
|
||||
...rpHash,
|
||||
0x41,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
...new Uint8Array(16),
|
||||
0,
|
||||
1,
|
||||
7,
|
||||
...cose,
|
||||
]);
|
||||
return {
|
||||
attestationObject: bytesToBase64Url(noneAttestation(authData)),
|
||||
clientDataJSON: bytesToBase64Url(client),
|
||||
challenge,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WebAuthn attestation verification", () => {
|
||||
it("verifies a registration with none attestation without claiming trust", async () => {
|
||||
const data = await registrationData();
|
||||
const result = await verifyAttestation({
|
||||
...data,
|
||||
expectedChallenge: data.challenge,
|
||||
expectedOrigin: "https://example.test",
|
||||
expectedRpId: "example.test",
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
verified: true,
|
||||
format: "none",
|
||||
attestationType: "none",
|
||||
trustEstablished: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects mismatched ceremony state", async () => {
|
||||
const data = await registrationData();
|
||||
const result = await verifyAttestation({
|
||||
...data,
|
||||
expectedChallenge: "different",
|
||||
expectedOrigin: "https://evil.test",
|
||||
expectedRpId: "evil.test",
|
||||
});
|
||||
expect(result.verified).toBe(false);
|
||||
expect(
|
||||
result.checks
|
||||
.filter((check) => check.status === "fail")
|
||||
.map((check) => check.name),
|
||||
).toEqual(expect.arrayContaining(["Challenge", "Origin", "RP ID hash"]));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user