Matrix: preserve owner-signed verification state

This commit is contained in:
Gustavo Madeira Santana
2026-03-09 01:46:53 -04:00
parent e4900d6f7f
commit 4e364cb8e9
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 () => {
matrixJsClient.getCrypto = vi.fn(() => ({ on: vi.fn() }));
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {

View File

@@ -309,9 +309,17 @@ export class MatrixClient {
if (!crypto) {
return;
}
const initial = await this.cryptoBootstrapper.bootstrap(crypto);
const initial = await this.cryptoBootstrapper.bootstrap(crypto, {
allowAutomaticCrossSigningReset: 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 {
const repaired = await this.cryptoBootstrapper.bootstrap(crypto, {
forceResetCrossSigning: true,
@@ -757,7 +765,9 @@ export class MatrixClient {
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();
if (!status.verified) {
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 () => {
const deps = createBootstrapperDeps();
const crypto = createCryptoApi({

View File

@@ -26,6 +26,7 @@ export type MatrixCryptoBootstrapperDeps<TRawEvent extends MatrixRawEvent> = {
export type MatrixCryptoBootstrapOptions = {
forceResetCrossSigning?: boolean;
allowAutomaticCrossSigningReset?: boolean;
strict?: boolean;
};
@@ -51,6 +52,7 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
await this.bootstrapSecretStorage(crypto, strict);
const crossSigning = await this.bootstrapCrossSigning(crypto, {
forceResetCrossSigning: options.forceResetCrossSigning === true,
allowAutomaticCrossSigningReset: options.allowAutomaticCrossSigningReset !== false,
strict,
});
await this.bootstrapSecretStorage(crypto, strict);
@@ -91,7 +93,11 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
private async bootstrapCrossSigning(
crypto: MatrixCryptoBootstrapApi,
options: { forceResetCrossSigning: boolean; strict: boolean },
options: {
forceResetCrossSigning: boolean;
allowAutomaticCrossSigningReset: boolean;
strict: boolean;
},
): Promise<{ ready: boolean; published: boolean }> {
const userId = await this.deps.getUserId();
const authUploadDeviceSigningKeys = this.createSigningKeysUiAuthCallback({
@@ -156,6 +162,14 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
authUploadDeviceSigningKeys,
});
} 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(
"MatrixClientLite",
"Initial cross-signing bootstrap failed, trying reset:",
@@ -182,6 +196,10 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
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.
try {
await crypto.bootstrapCrossSigning({