Matrix: preserve owner-signed verification state

This commit is contained in:
Gustavo Madeira Santana
2026-03-09 01:46:53 -04:00
parent 21f1e903a4
commit fdf7953226
4 changed files with 109 additions and 4 deletions

View File

@@ -842,6 +842,53 @@ describe("MatrixClient crypto bootstrapping", () => {
}); });
}); });
it("does not force-reset bootstrap when the device is already signed by its owner", async () => {
matrixJsClient.getCrypto = vi.fn(() => ({ on: vi.fn() }));
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
password: "secret-password",
});
const bootstrapSpy = vi.fn().mockResolvedValue({
crossSigningReady: false,
crossSigningPublished: false,
ownDeviceVerified: true,
});
(
client as unknown as {
cryptoBootstrapper: { bootstrap: typeof bootstrapSpy };
}
).cryptoBootstrapper.bootstrap = bootstrapSpy;
vi.spyOn(client, "getOwnDeviceVerificationStatus").mockResolvedValue({
encryptionEnabled: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
verified: true,
localVerified: true,
crossSigningVerified: false,
signedByOwner: true,
recoveryKeyStored: false,
recoveryKeyCreatedAt: null,
recoveryKeyId: null,
backupVersion: null,
backup: {
serverVersion: null,
activeVersion: null,
trusted: null,
matchesDecryptionKey: null,
decryptionKeyCached: false,
keyLoadAttempted: false,
keyLoadError: null,
},
});
await client.start();
expect(bootstrapSpy).toHaveBeenCalledTimes(1);
expect(bootstrapSpy.mock.calls[0]?.[1]).toEqual({
allowAutomaticCrossSigningReset: false,
});
});
it("does not force-reset bootstrap when password is unavailable", async () => { it("does not force-reset bootstrap when password is unavailable", async () => {
matrixJsClient.getCrypto = vi.fn(() => ({ on: vi.fn() })); matrixJsClient.getCrypto = vi.fn(() => ({ on: vi.fn() }));
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {

View File

@@ -309,9 +309,17 @@ export class MatrixClient {
if (!crypto) { if (!crypto) {
return; return;
} }
const initial = await this.cryptoBootstrapper.bootstrap(crypto); const initial = await this.cryptoBootstrapper.bootstrap(crypto, {
allowAutomaticCrossSigningReset: false,
});
if (!initial.crossSigningPublished || initial.ownDeviceVerified === false) { if (!initial.crossSigningPublished || initial.ownDeviceVerified === false) {
if (this.password?.trim()) { const status = await this.getOwnDeviceVerificationStatus();
if (status.signedByOwner) {
LogService.warn(
"MatrixClientLite",
"Cross-signing/bootstrap is incomplete for an already owner-signed device; skipping automatic reset and preserving the current identity. Restore the recovery key or run an explicit verification bootstrap if repair is needed.",
);
} else if (this.password?.trim()) {
try { try {
const repaired = await this.cryptoBootstrapper.bootstrap(crypto, { const repaired = await this.cryptoBootstrapper.bootstrap(crypto, {
forceResetCrossSigning: true, forceResetCrossSigning: true,
@@ -757,7 +765,9 @@ export class MatrixClient {
return await fail(err instanceof Error ? err.message : String(err)); return await fail(err instanceof Error ? err.message : String(err));
} }
await this.cryptoBootstrapper.bootstrap(crypto); await this.cryptoBootstrapper.bootstrap(crypto, {
allowAutomaticCrossSigningReset: false,
});
const status = await this.getOwnDeviceVerificationStatus(); const status = await this.getOwnDeviceVerificationStatus();
if (!status.verified) { if (!status.verified) {
return { return {

View File

@@ -99,6 +99,36 @@ describe("MatrixCryptoBootstrapper", () => {
); );
}); });
it("does not auto-reset cross-signing when automatic reset is disabled", async () => {
const deps = createBootstrapperDeps();
const bootstrapCrossSigning = vi.fn(async () => {});
const crypto = createCryptoApi({
bootstrapCrossSigning,
isCrossSigningReady: vi.fn(async () => false),
userHasCrossSigningKeys: vi.fn(async () => false),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
localVerified: true,
crossSigningVerified: false,
signedByOwner: true,
})),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto, {
allowAutomaticCrossSigningReset: false,
});
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(1);
expect(bootstrapCrossSigning).toHaveBeenCalledWith(
expect.objectContaining({
authUploadDeviceSigningKeys: expect.any(Function),
}),
);
});
it("fails in strict mode when cross-signing keys are still unpublished", async () => { it("fails in strict mode when cross-signing keys are still unpublished", async () => {
const deps = createBootstrapperDeps(); const deps = createBootstrapperDeps();
const crypto = createCryptoApi({ const crypto = createCryptoApi({

View File

@@ -26,6 +26,7 @@ export type MatrixCryptoBootstrapperDeps<TRawEvent extends MatrixRawEvent> = {
export type MatrixCryptoBootstrapOptions = { export type MatrixCryptoBootstrapOptions = {
forceResetCrossSigning?: boolean; forceResetCrossSigning?: boolean;
allowAutomaticCrossSigningReset?: boolean;
strict?: boolean; strict?: boolean;
}; };
@@ -51,6 +52,7 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
await this.bootstrapSecretStorage(crypto, strict); await this.bootstrapSecretStorage(crypto, strict);
const crossSigning = await this.bootstrapCrossSigning(crypto, { const crossSigning = await this.bootstrapCrossSigning(crypto, {
forceResetCrossSigning: options.forceResetCrossSigning === true, forceResetCrossSigning: options.forceResetCrossSigning === true,
allowAutomaticCrossSigningReset: options.allowAutomaticCrossSigningReset !== false,
strict, strict,
}); });
await this.bootstrapSecretStorage(crypto, strict); await this.bootstrapSecretStorage(crypto, strict);
@@ -91,7 +93,11 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
private async bootstrapCrossSigning( private async bootstrapCrossSigning(
crypto: MatrixCryptoBootstrapApi, crypto: MatrixCryptoBootstrapApi,
options: { forceResetCrossSigning: boolean; strict: boolean }, options: {
forceResetCrossSigning: boolean;
allowAutomaticCrossSigningReset: boolean;
strict: boolean;
},
): Promise<{ ready: boolean; published: boolean }> { ): Promise<{ ready: boolean; published: boolean }> {
const userId = await this.deps.getUserId(); const userId = await this.deps.getUserId();
const authUploadDeviceSigningKeys = this.createSigningKeysUiAuthCallback({ const authUploadDeviceSigningKeys = this.createSigningKeysUiAuthCallback({
@@ -156,6 +162,14 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
authUploadDeviceSigningKeys, authUploadDeviceSigningKeys,
}); });
} catch (err) { } catch (err) {
if (!options.allowAutomaticCrossSigningReset) {
LogService.warn(
"MatrixClientLite",
"Initial cross-signing bootstrap failed and automatic reset is disabled:",
err,
);
return { ready: false, published: false };
}
LogService.warn( LogService.warn(
"MatrixClientLite", "MatrixClientLite",
"Initial cross-signing bootstrap failed, trying reset:", "Initial cross-signing bootstrap failed, trying reset:",
@@ -182,6 +196,10 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
return { ready: true, published: true }; return { ready: true, published: true };
} }
if (!options.allowAutomaticCrossSigningReset) {
return { ready: firstPassReady, published: firstPassPublished };
}
// Fallback: recover from broken local/server state by creating a fresh identity. // Fallback: recover from broken local/server state by creating a fresh identity.
try { try {
await crypto.bootstrapCrossSigning({ await crypto.bootstrapCrossSigning({