143 lines
4.5 KiB
TypeScript
143 lines
4.5 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
exportGoogleMigration,
|
|
exportCsv,
|
|
importCsv,
|
|
importGoogleMigrationBatch,
|
|
importOtpAuthList,
|
|
importPlainPskc,
|
|
} from "../../src/otp/migration";
|
|
import { bytesToBase64Url, utf8ToBytes } from "../../src/crypto/encoding";
|
|
import type { OtpProfile } from "../../src/otp/profile";
|
|
|
|
const profile: OtpProfile = {
|
|
kind: "totp",
|
|
account: 'alice,"admin"',
|
|
issuer: "Example",
|
|
secret: utf8ToBytes("12345678901234567890"),
|
|
algorithm: "SHA-256",
|
|
digits: 8,
|
|
period: 45,
|
|
counter: 0n,
|
|
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]));
|
|
expect(result.profiles[0]).toMatchObject({
|
|
account: profile.account,
|
|
issuer: "Example",
|
|
algorithm: "SHA-256",
|
|
digits: 8,
|
|
period: 45,
|
|
});
|
|
expect([...result.profiles[0]!.secret]).toEqual([...profile.secret]);
|
|
});
|
|
|
|
it("neutralizes spreadsheet formulas without changing a round trip", () => {
|
|
const dangerous = { ...profile, account: '=HYPERLINK("https://bad")' };
|
|
const csv = exportCsv([dangerous]);
|
|
expect(csv).toContain("'=HYPERLINK");
|
|
expect(importCsv(csv).profiles[0]!.account).toBe(dangerous.account);
|
|
});
|
|
|
|
it("reports the failing line in URI lists", () => {
|
|
expect(() =>
|
|
importOtpAuthList("otpauth://totp/Good?secret=JBSWY3DPEHPK3PXP\nnope"),
|
|
).toThrow(/Line 2/iu);
|
|
});
|
|
|
|
it("imports plain-secret PSKC and refuses encrypted keys", () => {
|
|
const xml = `<KeyContainer xmlns="urn:ietf:params:xml:ns:keyprov:pskc"><KeyPackage><Key Id="alice" Algorithm="urn:ietf:params:xml:ns:keyprov:pskc:totp"><Issuer>Example</Issuer><Data><Secret><PlainValue>MTIzNDU2Nzg5MDEyMzQ1Njc4OTA=</PlainValue></Secret><TimeInterval><PlainValue>30</PlainValue></TimeInterval></Data><Policy><KeyUsage>OTP</KeyUsage></Policy><ResponseFormat Length="6" Encoding="DECIMAL"/></Key></KeyPackage></KeyContainer>`;
|
|
expect(importPlainPskc(xml).profiles[0]).toMatchObject({
|
|
kind: "totp",
|
|
account: "alice",
|
|
issuer: "Example",
|
|
digits: 6,
|
|
});
|
|
expect(() =>
|
|
importPlainPskc(
|
|
xml.replace(
|
|
"<PlainValue>MTIzNDU2Nzg5MDEyMzQ1Njc4OTA=</PlainValue>",
|
|
"<EncryptedValue/>",
|
|
),
|
|
),
|
|
).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);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|