241 lines
7.4 KiB
TypeScript
241 lines
7.4 KiB
TypeScript
import { describe, expect, it } from "@suaveplan/testing/runner";
|
|
import * as fc from "fast-check";
|
|
import {
|
|
createEmptyContainer,
|
|
decryptContainer,
|
|
encryptContainer,
|
|
} from "../src/container/container.js";
|
|
import {
|
|
decryptField,
|
|
encryptField,
|
|
} from "../src/encryption/field-encryption.js";
|
|
import { VAULT_CODES } from "../src/errors/codes.js";
|
|
import { VaultError } from "../src/errors/vault-error.js";
|
|
import {
|
|
createManifest,
|
|
packManifest,
|
|
unpackManifest,
|
|
} from "../src/manifest/manifest.js";
|
|
import type {
|
|
DriveLike,
|
|
DriveSharingLike,
|
|
SyncDelegationResult,
|
|
SyncSnapshot,
|
|
VaultSyncLike,
|
|
} from "../src/types/adapters.js";
|
|
import type { VaultConfig } from "../src/types/types.js";
|
|
import { Vault } from "../src/vault/vault.js";
|
|
|
|
function vaultKey(): Uint8Array {
|
|
const key = new Uint8Array(32);
|
|
crypto.getRandomValues(key);
|
|
return key;
|
|
}
|
|
|
|
function memoryDrive(): DriveLike & { blobs: Map<string, Uint8Array> } {
|
|
const blobs = new Map<string, Uint8Array>();
|
|
return {
|
|
blobs,
|
|
async read(path) {
|
|
const v = blobs.get(path);
|
|
if (v === undefined) throw new Error(`missing ${path}`);
|
|
return v;
|
|
},
|
|
async write(path, data) {
|
|
blobs.set(path, new Uint8Array(data));
|
|
},
|
|
async delete(path) {
|
|
return blobs.delete(path);
|
|
},
|
|
async exists(path) {
|
|
return blobs.has(path);
|
|
},
|
|
};
|
|
}
|
|
|
|
function noopSharing(): DriveSharingLike {
|
|
return {
|
|
async createLink() {
|
|
return { id: "x", url: "https://x.example/x" };
|
|
},
|
|
async revokeLink() {},
|
|
async isActive() {
|
|
return true;
|
|
},
|
|
};
|
|
}
|
|
|
|
function noopSync(): VaultSyncLike {
|
|
let last: SyncSnapshot = { items: [], snapshotAt: 0 };
|
|
return {
|
|
hydrate(s) {
|
|
last = s;
|
|
},
|
|
async pull(): Promise<SyncDelegationResult> {
|
|
return { merged: [], conflicts: [], deleted: [], durationMs: 0 };
|
|
},
|
|
async push(): Promise<SyncDelegationResult> {
|
|
return { merged: [], conflicts: [], deleted: [], durationMs: 0 };
|
|
},
|
|
async sync(): Promise<SyncDelegationResult> {
|
|
return { merged: [], conflicts: [], deleted: [], durationMs: 0 };
|
|
},
|
|
currentSnapshot() {
|
|
return last;
|
|
},
|
|
};
|
|
}
|
|
|
|
function makeConfig(): VaultConfig {
|
|
return {
|
|
vaultId: "personal",
|
|
email: "u@example.com",
|
|
containerPath: "/v.bin",
|
|
attachmentsPrefix: "/att",
|
|
argon2id: {
|
|
memoryKB: 65536,
|
|
iterations: 3,
|
|
parallelism: 1,
|
|
outputLength: 32,
|
|
},
|
|
argon2idSalt: new Uint8Array(16),
|
|
drive: memoryDrive(),
|
|
driveSharing: noopSharing(),
|
|
sync: noopSync(),
|
|
};
|
|
}
|
|
|
|
describe("vault — property tests", () => {
|
|
it("encryptField + decryptField round-trip preserves arbitrary plaintexts", {
|
|
timeout: 30000,
|
|
}, () => {
|
|
const key = vaultKey();
|
|
fc.assert(
|
|
fc.property(fc.string({ maxLength: 256 }), (value) => {
|
|
const envelope = encryptField({
|
|
vaultKey: key,
|
|
itemId: "i1",
|
|
fieldName: "f",
|
|
value,
|
|
});
|
|
const recovered = decryptField({
|
|
vaultKey: key,
|
|
itemId: "i1",
|
|
fieldName: "f",
|
|
envelope,
|
|
});
|
|
return recovered === value;
|
|
}),
|
|
{ numRuns: 50 }
|
|
);
|
|
});
|
|
|
|
it("manifest pack/unpack round-trips for arbitrary parameter combinations", {
|
|
timeout: 30000,
|
|
}, () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.integer({ min: 1, max: 0xff_ff_ff_ff }),
|
|
fc.integer({ min: 1, max: 0xff_ff }),
|
|
fc.integer({ min: 1, max: 0xff }),
|
|
fc.integer({ min: 0, max: 0xff_ff }),
|
|
(memoryKB, iterations, parallelism, schemaVersion) => {
|
|
const m = createManifest({
|
|
vaultId: "v",
|
|
argon2idMemoryKB: memoryKB,
|
|
argon2idIterations: iterations,
|
|
argon2idParallelism: parallelism,
|
|
schemaVersion,
|
|
now: 1_700_000_000_000,
|
|
});
|
|
const packed = packManifest(m);
|
|
const round = unpackManifest(packed);
|
|
return (
|
|
round.argon2idMemoryKB === memoryKB &&
|
|
round.argon2idIterations === iterations &&
|
|
round.argon2idParallelism === parallelism &&
|
|
round.schemaVersion === schemaVersion
|
|
);
|
|
}
|
|
),
|
|
{ numRuns: 30 }
|
|
);
|
|
});
|
|
|
|
it("container encrypt + decrypt round-trips arbitrary item ids", {
|
|
timeout: 30000,
|
|
}, () => {
|
|
const key = vaultKey();
|
|
fc.assert(
|
|
fc.property(
|
|
fc.string({ minLength: 1, maxLength: 32 }),
|
|
fc.string({ maxLength: 64 }),
|
|
(id, title) => {
|
|
const { manifest, state } = createEmptyContainer({
|
|
vaultId: "v",
|
|
argon2idMemoryKB: 65536,
|
|
argon2idIterations: 3,
|
|
argon2idParallelism: 4,
|
|
now: 1,
|
|
});
|
|
state.items.set(id, {
|
|
id,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
version: 1,
|
|
deleted: false,
|
|
title,
|
|
category: "x",
|
|
fieldsJson: "{}",
|
|
concealedFieldsJson: "{}",
|
|
tagsJson: "[]",
|
|
favorite: false,
|
|
});
|
|
const blob = encryptContainer({
|
|
manifest,
|
|
state,
|
|
vaultKey: key,
|
|
});
|
|
const round = decryptContainer(blob, key);
|
|
return round.state.items.get(id)?.title === title;
|
|
}
|
|
),
|
|
{ numRuns: 20 }
|
|
);
|
|
});
|
|
|
|
it("lock is idempotent across repeat calls", {
|
|
timeout: 30000,
|
|
}, async () => {
|
|
const vault = Vault.open(makeConfig());
|
|
await vault.lock();
|
|
await vault.lock();
|
|
await vault.lock();
|
|
expect(vault.isLocked).toBe(true);
|
|
});
|
|
|
|
it("every CRUD method on a locked vault throws VAULT.LOCKED", {
|
|
timeout: 30000,
|
|
}, () => {
|
|
const vault = Vault.open(makeConfig());
|
|
const ops: Array<() => unknown> = [
|
|
() => vault.items,
|
|
() => vault.search,
|
|
() => vault.attachments,
|
|
() => vault.shares,
|
|
() => vault.sync,
|
|
() => vault.currentManifest(),
|
|
];
|
|
for (const fn of ops) {
|
|
let captured: unknown = null;
|
|
try {
|
|
fn();
|
|
} catch (err) {
|
|
captured = err;
|
|
}
|
|
expect(VaultError.is(captured)).toBe(true);
|
|
expect((captured as VaultError).code).toBe(VAULT_CODES.LOCKED);
|
|
}
|
|
});
|
|
});
|