fix: trust fresh Matrix owner identity

Trust freshly reset Matrix cross-signing identities only after this device created them, so owner-trusted verification completes locally.

Also accept staged recovery keys when they actually establish verified identity trust and usable backup material, even if secret-storage validity does not echo the key id.
This commit is contained in:
Gustavo Madeira Santana
2026-04-23 11:00:05 -04:00
parent 35c9a0fdc9
commit 66e52bb241
4 changed files with 120 additions and 4 deletions

View File

@@ -1639,6 +1639,58 @@ describe("MatrixClient crypto bootstrapping", () => {
expect(checkKeyBackupAndEnable).toHaveBeenCalledTimes(1);
});
it("accepts a staged recovery key when it establishes identity trust and backup usability", async () => {
const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)));
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(consumeMatrixSecretStorageKey),
requestOwnUserVerification: vi.fn(async () => null),
getSecretStorageStatus: vi.fn(async () => ({
ready: true,
defaultKeyId: "SSSSKEY",
secretStorageKeyValidityMap: {},
})),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
})),
checkKeyBackupAndEnable: vi.fn(async () => {}),
getActiveSessionBackupVersion: vi.fn(async () => "11"),
getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])),
getKeyBackupInfo: vi.fn(async () => ({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {},
version: "11",
})),
isKeyBackupTrusted: vi.fn(async () => ({
trusted: true,
matchesDecryptionKey: true,
})),
}));
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-used-key-"));
const recoveryKeyPath = path.join(recoveryDir, "recovery-key.json");
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
recoveryKeyPath,
});
const result = await client.verifyWithRecoveryKey(encoded as string);
expect(result.success).toBe(true);
expect(result.recoveryKeyAccepted).toBe(true);
expect(result.backupUsable).toBe(true);
expect(result.deviceOwnerVerified).toBe(true);
expect(result.recoveryKeyStored).toBe(true);
expect(fs.existsSync(recoveryKeyPath)).toBe(true);
});
it("fails recovery-key verification when the device lacks full cross-signing identity trust", async () => {
const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)));

View File

@@ -1237,11 +1237,12 @@ export class MatrixClient {
typeof crypto.getSecretStorageStatus === "function"
? await crypto.getSecretStorageStatus().catch(() => null)
: null;
const stagedRecoveryKeyValidated = Boolean(
const stagedRecoveryKeyConfirmedBySecretStorage =
Boolean(stagedKeyId) &&
secretStorageStatus?.secretStorageKeyValidityMap?.[stagedKeyId ?? ""] === true;
const stagedRecoveryKeyValidated =
stagedRecoveryKeyUsed &&
stagedKeyId &&
secretStorageStatus?.secretStorageKeyValidityMap?.[stagedKeyId] === true,
);
(stagedRecoveryKeyConfirmedBySecretStorage || (status.verified && backupUsable));
const recoveryKeyAccepted = stagedRecoveryKeyValidated && (status.verified || backupUsable);
if (!status.verified) {
if (backupUsable && stagedRecoveryKeyValidated) {

View File

@@ -461,6 +461,47 @@ describe("MatrixCryptoBootstrapper", () => {
});
});
it("trusts the fresh own identity after a forced cross-signing reset", async () => {
const verifyOwnIdentity = vi.fn(async () => ({}));
const freeOwnIdentity = vi.fn();
const { crypto, bootstrapper } = createForcedResetHarness(vi.fn(async () => {}));
crypto.getOwnIdentity = vi.fn(async () => ({
free: freeOwnIdentity,
isVerified: () => false,
verify: verifyOwnIdentity,
}));
await bootstrapper.bootstrap(crypto, {
strict: true,
forceResetCrossSigning: true,
});
expect(verifyOwnIdentity).toHaveBeenCalledTimes(1);
expect(freeOwnIdentity).toHaveBeenCalledTimes(1);
});
it("does not trust an existing unpublished identity without a reset", async () => {
const verifyOwnIdentity = vi.fn(async () => ({}));
const { crypto, bootstrapper } = createBootstrapperHarness({
bootstrapCrossSigning: vi.fn(async () => {}),
getDeviceVerificationStatus: vi.fn(async () => createVerifiedDeviceStatus()),
getOwnIdentity: vi.fn(async () => ({
isVerified: () => false,
verify: verifyOwnIdentity,
})),
isCrossSigningReady: vi.fn(async () => false),
userHasCrossSigningKeys: vi.fn(async () => false),
});
const result = await bootstrapper.bootstrap(crypto, {
allowAutomaticCrossSigningReset: false,
strict: false,
});
expect(result.crossSigningPublished).toBe(false);
expect(verifyOwnIdentity).not.toHaveBeenCalled();
});
it("fails in strict mode when cross-signing keys are still unpublished", async () => {
const deps = createBootstrapperDeps();
const crypto = createCryptoApi({

View File

@@ -189,6 +189,7 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
};
try {
await resetCrossSigning();
await this.trustFreshOwnIdentity(crypto);
} catch (err) {
const shouldRepairSecretStorage =
options.allowSecretStorageRecreateWithoutRecoveryKey &&
@@ -204,6 +205,7 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
forceNewSecretStorage: true,
});
await resetCrossSigning();
await this.trustFreshOwnIdentity(crypto);
} catch (repairErr) {
LogService.warn("MatrixClientLite", "Forced cross-signing reset failed:", repairErr);
if (options.strict) {
@@ -289,6 +291,7 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
setupNewCrossSigning: true,
authUploadDeviceSigningKeys,
});
await this.trustFreshOwnIdentity(crypto);
} catch (err) {
LogService.warn("MatrixClientLite", "Fallback cross-signing bootstrap failed:", err);
if (options.strict) {
@@ -300,6 +303,25 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
return await finalize();
}
private async trustFreshOwnIdentity(crypto: MatrixCryptoBootstrapApi): Promise<void> {
const ownIdentity =
typeof crypto.getOwnIdentity === "function"
? await crypto.getOwnIdentity().catch(() => undefined)
: undefined;
if (!ownIdentity) {
return;
}
try {
if (typeof ownIdentity.isVerified === "function" && ownIdentity.isVerified()) {
return;
}
await ownIdentity.verify?.();
} finally {
ownIdentity.free?.();
}
}
private async bootstrapSecretStorage(
crypto: MatrixCryptoBootstrapApi,
options: {