refactor: dedupe test and script helpers

This commit is contained in:
Peter Steinberger
2026-03-24 15:47:44 +00:00
parent 66e954858b
commit 781295c14b
56 changed files with 2277 additions and 3522 deletions

View File

@@ -29,6 +29,97 @@ function createCryptoApi(overrides?: Partial<MatrixCryptoBootstrapApi>): MatrixC
};
}
function createVerifiedDeviceStatus(overrides?: {
localVerified?: boolean;
crossSigningVerified?: boolean;
signedByOwner?: boolean;
}) {
return {
isVerified: () => true,
localVerified: overrides?.localVerified ?? true,
crossSigningVerified: overrides?.crossSigningVerified ?? true,
signedByOwner: overrides?.signedByOwner ?? true,
};
}
function createBootstrapperHarness(
cryptoOverrides?: Partial<MatrixCryptoBootstrapApi>,
depsOverrides?: Partial<ReturnType<typeof createBootstrapperDeps>>,
) {
const deps = {
...createBootstrapperDeps(),
...depsOverrides,
};
const crypto = createCryptoApi(cryptoOverrides);
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
return { deps, crypto, bootstrapper };
}
async function runExplicitSecretStorageRepairScenario(firstError: string) {
const bootstrapCrossSigning = vi
.fn<() => Promise<void>>()
.mockRejectedValueOnce(new Error(firstError))
.mockResolvedValueOnce(undefined);
const { deps, crypto, bootstrapper } = createBootstrapperHarness({
bootstrapCrossSigning,
isCrossSigningReady: vi.fn(async () => true),
userHasCrossSigningKeys: vi.fn(async () => true),
getDeviceVerificationStatus: vi.fn(async () => createVerifiedDeviceStatus()),
});
await bootstrapper.bootstrap(crypto, {
strict: true,
allowSecretStorageRecreateWithoutRecoveryKey: true,
allowAutomaticCrossSigningReset: false,
});
return { deps, crypto, bootstrapCrossSigning };
}
function expectSecretStorageRepairRetry(
deps: ReturnType<typeof createBootstrapperDeps>,
crypto: MatrixCryptoBootstrapApi,
bootstrapCrossSigning: ReturnType<typeof vi.fn>,
) {
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith(crypto, {
allowSecretStorageRecreateWithoutRecoveryKey: true,
forceNewSecretStorage: true,
});
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(2);
}
async function bootstrapWithVerificationRequestListener(overrides?: {
deps?: Partial<ReturnType<typeof createBootstrapperDeps>>;
crypto?: Partial<MatrixCryptoBootstrapApi>;
}) {
const listeners = new Map<string, (...args: unknown[]) => void>();
const { deps, bootstrapper, crypto } = createBootstrapperHarness(
{
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
listeners.set(eventName, listener);
}),
...overrides?.crypto,
},
overrides?.deps,
);
await bootstrapper.bootstrap(crypto);
const listener = Array.from(listeners.entries()).find(([eventName]) =>
eventName.toLowerCase().includes("verificationrequest"),
)?.[1];
expect(listener).toBeTypeOf("function");
return {
deps,
listener,
};
}
describe("MatrixCryptoBootstrapper", () => {
beforeEach(() => {
vi.restoreAllMocks();
@@ -159,40 +250,11 @@ describe("MatrixCryptoBootstrapper", () => {
});
it("recreates secret storage and retries cross-signing when explicit bootstrap hits a stale server key", async () => {
const deps = createBootstrapperDeps();
const bootstrapCrossSigning = vi
.fn<() => Promise<void>>()
.mockRejectedValueOnce(new Error("getSecretStorageKey callback returned falsey"))
.mockResolvedValueOnce(undefined);
const crypto = createCryptoApi({
bootstrapCrossSigning,
isCrossSigningReady: vi.fn(async () => true),
userHasCrossSigningKeys: vi.fn(async () => true),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
})),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
const { deps, crypto, bootstrapCrossSigning } = await runExplicitSecretStorageRepairScenario(
"getSecretStorageKey callback returned falsey",
);
await bootstrapper.bootstrap(crypto, {
strict: true,
allowSecretStorageRecreateWithoutRecoveryKey: true,
allowAutomaticCrossSigningReset: false,
});
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith(
crypto,
{
allowSecretStorageRecreateWithoutRecoveryKey: true,
forceNewSecretStorage: true,
},
);
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(2);
expectSecretStorageRepairRetry(deps, crypto, bootstrapCrossSigning);
expect(bootstrapCrossSigning).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
@@ -208,40 +270,11 @@ describe("MatrixCryptoBootstrapper", () => {
});
it("recreates secret storage and retries cross-signing when explicit bootstrap hits bad MAC", async () => {
const deps = createBootstrapperDeps();
const bootstrapCrossSigning = vi
.fn<() => Promise<void>>()
.mockRejectedValueOnce(new Error("Error decrypting secret m.cross_signing.master: bad MAC"))
.mockResolvedValueOnce(undefined);
const crypto = createCryptoApi({
bootstrapCrossSigning,
isCrossSigningReady: vi.fn(async () => true),
userHasCrossSigningKeys: vi.fn(async () => true),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
})),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
const { deps, crypto, bootstrapCrossSigning } = await runExplicitSecretStorageRepairScenario(
"Error decrypting secret m.cross_signing.master: bad MAC",
);
await bootstrapper.bootstrap(crypto, {
strict: true,
allowSecretStorageRecreateWithoutRecoveryKey: true,
allowAutomaticCrossSigningReset: false,
});
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith(
crypto,
{
allowSecretStorageRecreateWithoutRecoveryKey: true,
forceNewSecretStorage: true,
},
);
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(2);
expectSecretStorageRepairRetry(deps, crypto, bootstrapCrossSigning);
});
it("fails in strict mode when cross-signing keys are still unpublished", async () => {
@@ -264,9 +297,8 @@ describe("MatrixCryptoBootstrapper", () => {
});
it("uses password UIA fallback when null and dummy auth fail", async () => {
const deps = createBootstrapperDeps();
const bootstrapCrossSigning = vi.fn(async () => {});
const crypto = createCryptoApi({
const { bootstrapper, crypto } = createBootstrapperHarness({
bootstrapCrossSigning,
isCrossSigningReady: vi.fn(async () => true),
userHasCrossSigningKeys: vi.fn(async () => true),
@@ -274,9 +306,6 @@ describe("MatrixCryptoBootstrapper", () => {
isVerified: () => true,
})),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
@@ -321,12 +350,11 @@ describe("MatrixCryptoBootstrapper", () => {
});
it("resets cross-signing when first bootstrap attempt throws", async () => {
const deps = createBootstrapperDeps();
const bootstrapCrossSigning = vi
.fn<() => Promise<void>>()
.mockRejectedValueOnce(new Error("first attempt failed"))
.mockResolvedValueOnce(undefined);
const crypto = createCryptoApi({
const { bootstrapper, crypto } = createBootstrapperHarness({
bootstrapCrossSigning,
isCrossSigningReady: vi.fn(async () => true),
userHasCrossSigningKeys: vi.fn(async () => true),
@@ -334,9 +362,6 @@ describe("MatrixCryptoBootstrapper", () => {
isVerified: () => true,
})),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
@@ -418,32 +443,13 @@ describe("MatrixCryptoBootstrapper", () => {
});
it("tracks incoming verification requests from other users", async () => {
const deps = createBootstrapperDeps();
const listeners = new Map<string, (...args: unknown[]) => void>();
const crypto = createCryptoApi({
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
listeners.set(eventName, listener);
}),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
const { deps, listener } = await bootstrapWithVerificationRequestListener();
const verificationRequest = {
otherUserId: "@alice:example.org",
isSelfVerification: false,
initiatedByMe: false,
accept: vi.fn(async () => {}),
};
const listener = Array.from(listeners.entries()).find(([eventName]) =>
eventName.toLowerCase().includes("verificationrequest"),
)?.[1];
expect(listener).toBeTypeOf("function");
await listener?.(verificationRequest);
expect(deps.verificationManager.trackVerificationRequest).toHaveBeenCalledWith(
@@ -453,24 +459,20 @@ describe("MatrixCryptoBootstrapper", () => {
});
it("does not touch request state when tracking summary throws", async () => {
const deps = createBootstrapperDeps();
deps.verificationManager.trackVerificationRequest = vi.fn(() => {
throw new Error("summary failure");
const { listener } = await bootstrapWithVerificationRequestListener({
deps: {
verificationManager: {
trackVerificationRequest: vi.fn(() => {
throw new Error("summary failure");
}),
},
},
crypto: {
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
},
});
const listeners = new Map<string, (...args: unknown[]) => void>();
const crypto = createCryptoApi({
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
listeners.set(eventName, listener);
}),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
const verificationRequest = {
otherUserId: "@alice:example.org",
@@ -478,10 +480,6 @@ describe("MatrixCryptoBootstrapper", () => {
initiatedByMe: false,
accept: vi.fn(async () => {}),
};
const listener = Array.from(listeners.entries()).find(([eventName]) =>
eventName.toLowerCase().includes("verificationrequest"),
)?.[1];
expect(listener).toBeTypeOf("function");
await listener?.(verificationRequest);
expect(verificationRequest.accept).not.toHaveBeenCalled();

View File

@@ -3,35 +3,69 @@ import { createMatrixCryptoFacade } from "./crypto-facade.js";
import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js";
import type { MatrixVerificationManager } from "./verification-manager.js";
type MatrixCryptoFacadeDeps = Parameters<typeof createMatrixCryptoFacade>[0];
function createVerificationManagerMock(
overrides: Partial<MatrixVerificationManager> = {},
): MatrixVerificationManager {
return {
requestOwnUserVerification: vi.fn(async () => null),
listVerifications: vi.fn(async () => []),
ensureVerificationDmTracked: vi.fn(async () => null),
requestVerification: vi.fn(),
acceptVerification: vi.fn(),
cancelVerification: vi.fn(),
startVerification: vi.fn(),
generateVerificationQr: vi.fn(),
scanVerificationQr: vi.fn(),
confirmVerificationSas: vi.fn(),
mismatchVerificationSas: vi.fn(),
confirmVerificationReciprocateQr: vi.fn(),
getVerificationSas: vi.fn(),
...overrides,
} as unknown as MatrixVerificationManager;
}
function createRecoveryKeyStoreMock(
summary: ReturnType<MatrixRecoveryKeyStore["getRecoveryKeySummary"]> = null,
): MatrixRecoveryKeyStore {
return {
getRecoveryKeySummary: vi.fn(() => summary),
} as unknown as MatrixRecoveryKeyStore;
}
function createFacadeHarness(params?: {
client?: Partial<MatrixCryptoFacadeDeps["client"]>;
verificationManager?: Partial<MatrixVerificationManager>;
recoveryKeySummary?: ReturnType<MatrixRecoveryKeyStore["getRecoveryKeySummary"]>;
getRoomStateEvent?: MatrixCryptoFacadeDeps["getRoomStateEvent"];
downloadContent?: MatrixCryptoFacadeDeps["downloadContent"];
}) {
const getRoomStateEvent: MatrixCryptoFacadeDeps["getRoomStateEvent"] =
params?.getRoomStateEvent ?? (async () => ({}));
const downloadContent: MatrixCryptoFacadeDeps["downloadContent"] =
params?.downloadContent ?? (async () => Buffer.alloc(0));
const facade = createMatrixCryptoFacade({
client: {
getRoom: params?.client?.getRoom ?? (() => null),
getCrypto: params?.client?.getCrypto ?? (() => undefined),
},
verificationManager: createVerificationManagerMock(params?.verificationManager),
recoveryKeyStore: createRecoveryKeyStoreMock(params?.recoveryKeySummary ?? null),
getRoomStateEvent,
downloadContent,
});
return { facade, getRoomStateEvent, downloadContent };
}
describe("createMatrixCryptoFacade", () => {
it("detects encrypted rooms from cached room state", async () => {
const facade = createMatrixCryptoFacade({
const { facade } = createFacadeHarness({
client: {
getRoom: () => ({
hasEncryptionStateEvent: () => true,
}),
getCrypto: () => undefined,
},
verificationManager: {
requestOwnUserVerification: vi.fn(),
listVerifications: vi.fn(async () => []),
ensureVerificationDmTracked: vi.fn(async () => null),
requestVerification: vi.fn(),
acceptVerification: vi.fn(),
cancelVerification: vi.fn(),
startVerification: vi.fn(),
generateVerificationQr: vi.fn(),
scanVerificationQr: vi.fn(),
confirmVerificationSas: vi.fn(),
mismatchVerificationSas: vi.fn(),
confirmVerificationReciprocateQr: vi.fn(),
getVerificationSas: vi.fn(),
} as unknown as MatrixVerificationManager,
recoveryKeyStore: {
getRecoveryKeySummary: vi.fn(() => null),
} as unknown as MatrixRecoveryKeyStore,
getRoomStateEvent: vi.fn(async () => ({ algorithm: "m.megolm.v1.aes-sha2" })),
downloadContent: vi.fn(async () => Buffer.alloc(0)),
});
await expect(facade.isRoomEncrypted("!room:example.org")).resolves.toBe(true);
@@ -41,33 +75,13 @@ describe("createMatrixCryptoFacade", () => {
const getRoomStateEvent = vi.fn(async () => ({
algorithm: "m.megolm.v1.aes-sha2",
}));
const facade = createMatrixCryptoFacade({
const { facade } = createFacadeHarness({
client: {
getRoom: () => ({
hasEncryptionStateEvent: () => false,
}),
getCrypto: () => undefined,
},
verificationManager: {
requestOwnUserVerification: vi.fn(),
listVerifications: vi.fn(async () => []),
ensureVerificationDmTracked: vi.fn(async () => null),
requestVerification: vi.fn(),
acceptVerification: vi.fn(),
cancelVerification: vi.fn(),
startVerification: vi.fn(),
generateVerificationQr: vi.fn(),
scanVerificationQr: vi.fn(),
confirmVerificationSas: vi.fn(),
mismatchVerificationSas: vi.fn(),
confirmVerificationReciprocateQr: vi.fn(),
getVerificationSas: vi.fn(),
} as unknown as MatrixVerificationManager,
recoveryKeyStore: {
getRecoveryKeySummary: vi.fn(() => null),
} as unknown as MatrixRecoveryKeyStore,
getRoomStateEvent,
downloadContent: vi.fn(async () => Buffer.alloc(0)),
});
await expect(facade.isRoomEncrypted("!room:example.org")).resolves.toBe(true);
@@ -92,31 +106,15 @@ describe("createMatrixCryptoFacade", () => {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
const facade = createMatrixCryptoFacade({
const { facade } = createFacadeHarness({
client: {
getRoom: () => null,
getCrypto: () => crypto,
},
verificationManager: {
requestOwnUserVerification: vi.fn(async () => null),
listVerifications: vi.fn(async () => []),
ensureVerificationDmTracked: vi.fn(async () => null),
requestVerification,
acceptVerification: vi.fn(),
cancelVerification: vi.fn(),
startVerification: vi.fn(),
generateVerificationQr: vi.fn(),
scanVerificationQr: vi.fn(),
confirmVerificationSas: vi.fn(),
mismatchVerificationSas: vi.fn(),
confirmVerificationReciprocateQr: vi.fn(),
getVerificationSas: vi.fn(),
} as unknown as MatrixVerificationManager,
recoveryKeyStore: {
getRecoveryKeySummary: vi.fn(() => ({ keyId: "KEY" })),
} as unknown as MatrixRecoveryKeyStore,
getRoomStateEvent: vi.fn(async () => ({})),
downloadContent: vi.fn(async () => Buffer.alloc(0)),
},
recoveryKeySummary: { keyId: "KEY" },
});
const result = await facade.requestVerification({
@@ -174,32 +172,14 @@ describe("createMatrixCryptoFacade", () => {
requestOwnUserVerification: vi.fn(async () => null),
findVerificationRequestDMInProgress: vi.fn(() => request),
};
const facade = createMatrixCryptoFacade({
const { facade } = createFacadeHarness({
client: {
getRoom: () => null,
getCrypto: () => crypto,
},
verificationManager: {
trackVerificationRequest,
requestOwnUserVerification: vi.fn(async () => null),
listVerifications: vi.fn(async () => []),
ensureVerificationDmTracked: vi.fn(async () => null),
requestVerification: vi.fn(),
acceptVerification: vi.fn(),
cancelVerification: vi.fn(),
startVerification: vi.fn(),
generateVerificationQr: vi.fn(),
scanVerificationQr: vi.fn(),
confirmVerificationSas: vi.fn(),
mismatchVerificationSas: vi.fn(),
confirmVerificationReciprocateQr: vi.fn(),
getVerificationSas: vi.fn(),
} as unknown as MatrixVerificationManager,
recoveryKeyStore: {
getRecoveryKeySummary: vi.fn(() => null),
} as unknown as MatrixRecoveryKeyStore,
getRoomStateEvent: vi.fn(async () => ({})),
downloadContent: vi.fn(async () => Buffer.alloc(0)),
},
});
const summary = await facade.ensureVerificationDmTracked({

View File

@@ -4,13 +4,85 @@ import path from "node:path";
import { encodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MatrixRecoveryKeyStore } from "./recovery-key-store.js";
import type { MatrixCryptoBootstrapApi } from "./types.js";
import type { MatrixCryptoBootstrapApi, MatrixSecretStorageStatus } from "./types.js";
function createTempRecoveryKeyPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-recovery-key-store-"));
return path.join(dir, "recovery-key.json");
}
function createGeneratedRecoveryKey(params: {
keyId: string;
name: string;
bytes: number[];
encodedPrivateKey: string;
}) {
return {
keyId: params.keyId,
keyInfo: { name: params.name },
privateKey: new Uint8Array(params.bytes),
encodedPrivateKey: params.encodedPrivateKey,
};
}
function createBootstrapSecretStorageMock(errorMessage?: string) {
return vi.fn(
async (opts?: {
setupNewSecretStorage?: boolean;
createSecretStorageKey?: () => Promise<unknown>;
}) => {
if (opts?.setupNewSecretStorage || !errorMessage) {
await opts?.createSecretStorageKey?.();
return;
}
throw new Error(errorMessage);
},
);
}
function createRecoveryKeyCrypto(params: {
bootstrapSecretStorage: ReturnType<typeof vi.fn>;
createRecoveryKeyFromPassphrase: ReturnType<typeof vi.fn>;
status: MatrixSecretStorageStatus;
}): MatrixCryptoBootstrapApi {
return {
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: params.bootstrapSecretStorage,
createRecoveryKeyFromPassphrase: params.createRecoveryKeyFromPassphrase,
getSecretStorageStatus: vi.fn(async () => params.status),
requestOwnUserVerification: vi.fn(async () => null),
} as unknown as MatrixCryptoBootstrapApi;
}
async function runSecretStorageBootstrapScenario(params: {
generated: ReturnType<typeof createGeneratedRecoveryKey>;
status: MatrixSecretStorageStatus;
allowSecretStorageRecreateWithoutRecoveryKey?: boolean;
firstBootstrapError?: string;
}) {
const recoveryKeyPath = createTempRecoveryKeyPath();
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
const createRecoveryKeyFromPassphrase = vi.fn(async () => params.generated);
const bootstrapSecretStorage = createBootstrapSecretStorageMock(params.firstBootstrapError);
const crypto = createRecoveryKeyCrypto({
bootstrapSecretStorage,
createRecoveryKeyFromPassphrase,
status: params.status,
});
await store.bootstrapSecretStorageWithRecoveryKey(crypto, {
allowSecretStorageRecreateWithoutRecoveryKey:
params.allowSecretStorageRecreateWithoutRecoveryKey ?? false,
});
return {
store,
createRecoveryKeyFromPassphrase,
bootstrapSecretStorage,
};
}
describe("MatrixRecoveryKeyStore", () => {
beforeEach(() => {
vi.restoreAllMocks();
@@ -65,30 +137,16 @@ describe("MatrixRecoveryKeyStore", () => {
});
it("creates and persists a recovery key when secret storage is missing", async () => {
const recoveryKeyPath = createTempRecoveryKeyPath();
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
const generated = {
keyId: "GENERATED",
keyInfo: { name: "generated" },
privateKey: new Uint8Array([5, 6, 7, 8]),
encodedPrivateKey: "encoded-generated-key", // pragma: allowlist secret
};
const createRecoveryKeyFromPassphrase = vi.fn(async () => generated);
const bootstrapSecretStorage = vi.fn(
async (opts?: { createSecretStorageKey?: () => Promise<unknown> }) => {
await opts?.createSecretStorageKey?.();
},
);
const crypto = {
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage,
createRecoveryKeyFromPassphrase,
getSecretStorageStatus: vi.fn(async () => ({ ready: false, defaultKeyId: null })),
requestOwnUserVerification: vi.fn(async () => null),
} as unknown as MatrixCryptoBootstrapApi;
await store.bootstrapSecretStorageWithRecoveryKey(crypto);
const { store, createRecoveryKeyFromPassphrase, bootstrapSecretStorage } =
await runSecretStorageBootstrapScenario({
generated: createGeneratedRecoveryKey({
keyId: "GENERATED",
name: "generated",
bytes: [5, 6, 7, 8],
encodedPrivateKey: "encoded-generated-key", // pragma: allowlist secret
}),
status: { ready: false, defaultKeyId: null },
});
expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1);
expect(bootstrapSecretStorage).toHaveBeenCalledWith(
@@ -138,30 +196,16 @@ describe("MatrixRecoveryKeyStore", () => {
});
it("recreates secret storage when default key exists but is not usable locally", async () => {
const recoveryKeyPath = createTempRecoveryKeyPath();
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
const generated = {
keyId: "RECOVERED",
keyInfo: { name: "recovered" },
privateKey: new Uint8Array([1, 1, 2, 3]),
encodedPrivateKey: "encoded-recovered-key", // pragma: allowlist secret
};
const createRecoveryKeyFromPassphrase = vi.fn(async () => generated);
const bootstrapSecretStorage = vi.fn(
async (opts?: { createSecretStorageKey?: () => Promise<unknown> }) => {
await opts?.createSecretStorageKey?.();
},
);
const crypto = {
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage,
createRecoveryKeyFromPassphrase,
getSecretStorageStatus: vi.fn(async () => ({ ready: false, defaultKeyId: "LEGACY" })),
requestOwnUserVerification: vi.fn(async () => null),
} as unknown as MatrixCryptoBootstrapApi;
await store.bootstrapSecretStorageWithRecoveryKey(crypto);
const { store, createRecoveryKeyFromPassphrase, bootstrapSecretStorage } =
await runSecretStorageBootstrapScenario({
generated: createGeneratedRecoveryKey({
keyId: "RECOVERED",
name: "recovered",
bytes: [1, 1, 2, 3],
encodedPrivateKey: "encoded-recovered-key", // pragma: allowlist secret
}),
status: { ready: false, defaultKeyId: "LEGACY" },
});
expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1);
expect(bootstrapSecretStorage).toHaveBeenCalledWith(
@@ -176,43 +220,22 @@ describe("MatrixRecoveryKeyStore", () => {
});
it("recreates secret storage during explicit bootstrap when the server key exists but no local recovery key is available", async () => {
const recoveryKeyPath = createTempRecoveryKeyPath();
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
const generated = {
keyId: "REPAIRED",
keyInfo: { name: "repaired" },
privateKey: new Uint8Array([7, 7, 8, 9]),
encodedPrivateKey: "encoded-repaired-key", // pragma: allowlist secret
};
const createRecoveryKeyFromPassphrase = vi.fn(async () => generated);
const bootstrapSecretStorage = vi.fn(
async (opts?: {
setupNewSecretStorage?: boolean;
createSecretStorageKey?: () => Promise<unknown>;
}) => {
if (opts?.setupNewSecretStorage) {
await opts.createSecretStorageKey?.();
return;
}
throw new Error("getSecretStorageKey callback returned falsey");
},
);
const crypto = {
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage,
createRecoveryKeyFromPassphrase,
getSecretStorageStatus: vi.fn(async () => ({
ready: true,
defaultKeyId: "LEGACY",
secretStorageKeyValidityMap: { LEGACY: true },
})),
requestOwnUserVerification: vi.fn(async () => null),
} as unknown as MatrixCryptoBootstrapApi;
await store.bootstrapSecretStorageWithRecoveryKey(crypto, {
allowSecretStorageRecreateWithoutRecoveryKey: true,
});
const { store, createRecoveryKeyFromPassphrase, bootstrapSecretStorage } =
await runSecretStorageBootstrapScenario({
generated: createGeneratedRecoveryKey({
keyId: "REPAIRED",
name: "repaired",
bytes: [7, 7, 8, 9],
encodedPrivateKey: "encoded-repaired-key", // pragma: allowlist secret
}),
status: {
ready: true,
defaultKeyId: "LEGACY",
secretStorageKeyValidityMap: { LEGACY: true },
},
allowSecretStorageRecreateWithoutRecoveryKey: true,
firstBootstrapError: "getSecretStorageKey callback returned falsey",
});
expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1);
expect(bootstrapSecretStorage).toHaveBeenCalledTimes(2);
@@ -228,43 +251,22 @@ describe("MatrixRecoveryKeyStore", () => {
});
it("recreates secret storage during explicit bootstrap when decrypting a stored secret fails with bad MAC", async () => {
const recoveryKeyPath = createTempRecoveryKeyPath();
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
const generated = {
keyId: "REPAIRED",
keyInfo: { name: "repaired" },
privateKey: new Uint8Array([7, 7, 8, 9]),
encodedPrivateKey: "encoded-repaired-key", // pragma: allowlist secret
};
const createRecoveryKeyFromPassphrase = vi.fn(async () => generated);
const bootstrapSecretStorage = vi.fn(
async (opts?: {
setupNewSecretStorage?: boolean;
createSecretStorageKey?: () => Promise<unknown>;
}) => {
if (opts?.setupNewSecretStorage) {
await opts.createSecretStorageKey?.();
return;
}
throw new Error("Error decrypting secret m.cross_signing.master: bad MAC");
},
);
const crypto = {
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage,
createRecoveryKeyFromPassphrase,
getSecretStorageStatus: vi.fn(async () => ({
ready: true,
defaultKeyId: "LEGACY",
secretStorageKeyValidityMap: { LEGACY: true },
})),
requestOwnUserVerification: vi.fn(async () => null),
} as unknown as MatrixCryptoBootstrapApi;
await store.bootstrapSecretStorageWithRecoveryKey(crypto, {
allowSecretStorageRecreateWithoutRecoveryKey: true,
});
const { createRecoveryKeyFromPassphrase, bootstrapSecretStorage } =
await runSecretStorageBootstrapScenario({
generated: createGeneratedRecoveryKey({
keyId: "REPAIRED",
name: "repaired",
bytes: [7, 7, 8, 9],
encodedPrivateKey: "encoded-repaired-key", // pragma: allowlist secret
}),
status: {
ready: true,
defaultKeyId: "LEGACY",
secretStorageKeyValidityMap: { LEGACY: true },
},
allowSecretStorageRecreateWithoutRecoveryKey: true,
firstBootstrapError: "Error decrypting secret m.cross_signing.master: bad MAC",
});
expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1);
expect(bootstrapSecretStorage).toHaveBeenCalledTimes(2);

View File

@@ -86,6 +86,65 @@ class MockVerificationRequest extends EventEmitter implements MatrixVerification
generateQRCode = vi.fn(async () => new Uint8ClampedArray([1, 2, 3]));
}
function createSasVerifierFixture(params: {
decimal: [number, number, number];
emoji: [string, string][];
verifyImpl?: () => Promise<void>;
}) {
const confirm = vi.fn(async () => {});
const mismatch = vi.fn();
const cancel = vi.fn();
const verify = vi.fn(params.verifyImpl ?? (async () => {}));
return {
confirm,
mismatch,
verify,
verifier: new MockVerifier(
{
sas: {
decimal: params.decimal,
emoji: params.emoji,
},
confirm,
mismatch,
cancel,
},
null,
verify,
),
};
}
function createReadyRequestWithoutVerifier(params: {
transactionId: string;
isSelfVerification: boolean;
verifier: MatrixVerifierLike;
}) {
const request = new MockVerificationRequest({
transactionId: params.transactionId,
initiatedByMe: false,
isSelfVerification: params.isSelfVerification,
verifier: undefined,
});
request.startVerification = vi.fn(async (_method: string) => {
request.phase = VerificationPhase.Started;
request.verifier = params.verifier;
return params.verifier;
});
return request;
}
function expectTrackedSas(
manager: MatrixVerificationManager,
trackedId: string,
decimal: [number, number, number],
) {
const summary = manager.listVerifications().find((item) => item.id === trackedId);
expect(summary?.hasSas).toBe(true);
expect(summary?.sas?.decimal).toEqual(decimal);
expect(manager.getVerificationSas(trackedId).decimal).toEqual(decimal);
}
describe("MatrixVerificationManager", () => {
it("handles rust verification requests whose methods getter throws", () => {
const manager = new MatrixVerificationManager();
@@ -173,24 +232,14 @@ describe("MatrixVerificationManager", () => {
});
it("auto-starts an incoming verifier exposed via request change events", async () => {
const verify = vi.fn(async () => {});
const verifier = new MockVerifier(
{
sas: {
decimal: [6158, 1986, 3513],
emoji: [
["gift", "Gift"],
["globe", "Globe"],
["horse", "Horse"],
],
},
confirm: vi.fn(async () => {}),
mismatch: vi.fn(),
cancel: vi.fn(),
},
null,
verify,
);
const { verifier, verify } = createSasVerifierFixture({
decimal: [6158, 1986, 3513],
emoji: [
["gift", "Gift"],
["globe", "Globe"],
["horse", "Horse"],
],
});
const request = new MockVerificationRequest({
transactionId: "txn-incoming-change",
verifier: undefined,
@@ -204,31 +253,18 @@ describe("MatrixVerificationManager", () => {
await vi.waitFor(() => {
expect(verify).toHaveBeenCalledTimes(1);
});
const summary = manager.listVerifications().find((item) => item.id === tracked.id);
expect(summary?.hasSas).toBe(true);
expect(summary?.sas?.decimal).toEqual([6158, 1986, 3513]);
expect(manager.getVerificationSas(tracked.id).decimal).toEqual([6158, 1986, 3513]);
expectTrackedSas(manager, tracked.id, [6158, 1986, 3513]);
});
it("emits summary updates when SAS becomes available", async () => {
const verify = vi.fn(async () => {});
const verifier = new MockVerifier(
{
sas: {
decimal: [6158, 1986, 3513],
emoji: [
["gift", "Gift"],
["globe", "Globe"],
["horse", "Horse"],
],
},
confirm: vi.fn(async () => {}),
mismatch: vi.fn(),
cancel: vi.fn(),
},
null,
verify,
);
const { verifier } = createSasVerifierFixture({
decimal: [6158, 1986, 3513],
emoji: [
["gift", "Gift"],
["globe", "Globe"],
["horse", "Horse"],
],
});
const request = new MockVerificationRequest({
transactionId: "txn-summary-listener",
roomId: "!dm:example.org",
@@ -257,34 +293,18 @@ describe("MatrixVerificationManager", () => {
});
it("does not auto-start non-self inbound SAS when request becomes ready without a verifier", async () => {
const verify = vi.fn(async () => {});
const verifier = new MockVerifier(
{
sas: {
decimal: [1234, 5678, 9012],
emoji: [
["gift", "Gift"],
["rocket", "Rocket"],
["butterfly", "Butterfly"],
],
},
confirm: vi.fn(async () => {}),
mismatch: vi.fn(),
cancel: vi.fn(),
},
null,
verify,
);
const request = new MockVerificationRequest({
transactionId: "txn-no-auto-start-dm-sas",
initiatedByMe: false,
isSelfVerification: false,
verifier: undefined,
const { verifier, verify } = createSasVerifierFixture({
decimal: [1234, 5678, 9012],
emoji: [
["gift", "Gift"],
["rocket", "Rocket"],
["butterfly", "Butterfly"],
],
});
request.startVerification = vi.fn(async (_method: string) => {
request.phase = VerificationPhase.Started;
request.verifier = verifier;
return verifier;
const request = createReadyRequestWithoutVerifier({
transactionId: "txn-no-auto-start-dm-sas",
isSelfVerification: false,
verifier,
});
const manager = new MatrixVerificationManager();
const tracked = manager.trackVerificationRequest(request);
@@ -303,34 +323,18 @@ describe("MatrixVerificationManager", () => {
});
it("auto-starts self verification SAS when request becomes ready without a verifier", async () => {
const verify = vi.fn(async () => {});
const verifier = new MockVerifier(
{
sas: {
decimal: [1234, 5678, 9012],
emoji: [
["gift", "Gift"],
["rocket", "Rocket"],
["butterfly", "Butterfly"],
],
},
confirm: vi.fn(async () => {}),
mismatch: vi.fn(),
cancel: vi.fn(),
},
null,
verify,
);
const request = new MockVerificationRequest({
transactionId: "txn-auto-start-self-sas",
initiatedByMe: false,
isSelfVerification: true,
verifier: undefined,
const { verifier, verify } = createSasVerifierFixture({
decimal: [1234, 5678, 9012],
emoji: [
["gift", "Gift"],
["rocket", "Rocket"],
["butterfly", "Butterfly"],
],
});
request.startVerification = vi.fn(async (_method: string) => {
request.phase = VerificationPhase.Started;
request.verifier = verifier;
return verifier;
const request = createReadyRequestWithoutVerifier({
transactionId: "txn-auto-start-self-sas",
isSelfVerification: true,
verifier,
});
const manager = new MatrixVerificationManager();
const tracked = manager.trackVerificationRequest(request);
@@ -344,10 +348,7 @@ describe("MatrixVerificationManager", () => {
await vi.waitFor(() => {
expect(verify).toHaveBeenCalledTimes(1);
});
const summary = manager.listVerifications().find((item) => item.id === tracked.id);
expect(summary?.hasSas).toBe(true);
expect(summary?.sas?.decimal).toEqual([1234, 5678, 9012]);
expect(manager.getVerificationSas(tracked.id).decimal).toEqual([1234, 5678, 9012]);
expectTrackedSas(manager, tracked.id, [1234, 5678, 9012]);
});
it("auto-accepts incoming verification requests only once per transaction", async () => {