Files
auth-tools/tests/otp/vendor-backups.test.ts

190 lines
5.0 KiB
TypeScript

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(
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);
});
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,
);
});
});