190 lines
5.0 KiB
TypeScript
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,
|
|
);
|
|
});
|
|
});
|