From 4b7090162858f6d8bf446f46af9c4e1bd54a6d71 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Tue, 20 Jun 2023 14:04:33 +0100 Subject: [PATCH] Basic implementation of SAS verification in Rust --- package.json | 2 +- spec/integ/crypto/verification.spec.ts | 49 ++- spec/unit/rust-crypto/rust-crypto.spec.ts | 9 + src/client.ts | 2 + src/rust-crypto/rust-crypto.ts | 41 ++- src/rust-crypto/verification.ts | 429 ++++++++++++++++++++++ yarn.lock | 8 +- 7 files changed, 517 insertions(+), 23 deletions(-) create mode 100644 src/rust-crypto/verification.ts diff --git a/package.json b/package.json index f9b6d50bd79..03bfe44accd 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ ], "dependencies": { "@babel/runtime": "^7.12.5", - "@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.10", + "@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.11", "another-json": "^0.2.0", "bs58": "^5.0.0", "content-type": "^1.0.4", diff --git a/spec/integ/crypto/verification.spec.ts b/spec/integ/crypto/verification.spec.ts index 51d4faa9eb1..b43b54cc7ff 100644 --- a/spec/integ/crypto/verification.spec.ts +++ b/spec/integ/crypto/verification.spec.ts @@ -16,6 +16,7 @@ limitations under the License. import fetchMock from "fetch-mock-jest"; import { MockResponse } from "fetch-mock"; +import "fake-indexeddb/auto"; import { createClient, CryptoEvent, MatrixClient } from "../../../src"; import { @@ -49,7 +50,7 @@ jest.useFakeTimers(); let previousCrypto: Crypto | undefined; -beforeAll(() => { +beforeAll(async () => { // Stub out global.crypto previousCrypto = global["crypto"]; @@ -61,6 +62,9 @@ beforeAll(() => { }, }, }); + + // we use the libolm primitives in the test, so init the Olm library + await global.Olm.init(); }); // restore the original global.crypto @@ -136,6 +140,10 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u afterEach(async () => { await aliceClient.stopClient(); + + // Allow in-flight things to complete before we tear down the test + await jest.runAllTimersAsync(); + fetchMock.mockReset(); }); @@ -145,7 +153,9 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u e2eKeyResponder.addDeviceKeys(TEST_USER_ID, TEST_DEVICE_ID, SIGNED_TEST_DEVICE_DATA); }); - oldBackendOnly("can verify via SAS", async () => { + it("can verify another device via SAS", async () => { + await waitForDeviceList(); + // have alice initiate a verification. She should send a m.key.verification.request let [requestBody, request] = await Promise.all([ expectSendToDeviceMessage("m.key.verification.request"), @@ -207,11 +217,12 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u expect(verifier.getShowSasCallbacks()).toBeNull(); // start off the verification process: alice will send an `accept` + const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.accept"); const verificationPromise = verifier.verify(); // advance the clock, because the devicelist likes to sleep for 5ms during key downloads jest.advanceTimersByTime(10); - requestBody = await expectSendToDeviceMessage("m.key.verification.accept"); + requestBody = await sendToDevicePromise; toDeviceMessage = requestBody.messages[TEST_USER_ID][TEST_DEVICE_ID]; expect(toDeviceMessage.key_agreement_protocol).toEqual("curve25519-hkdf-sha256"); expect(toDeviceMessage.short_authentication_string).toEqual(["decimal", "emoji"]); @@ -288,15 +299,9 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u }); oldBackendOnly("can verify another via QR code with an untrusted cross-signing key", async () => { - e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA); - // QRCode fails if we don't yet have the cross-signing keys, so make sure we have them now. - // - // Completing the initial sync will make the device list download outdated device lists (of which our own - // user will be one). - syncResponder.sendOrQueueSyncResponse({}); - // DeviceList has a sleep(5) which we need to make happen - await jest.advanceTimersByTimeAsync(10); + e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA); + await waitForDeviceList(); expect(aliceClient.getStoredCrossSigningForUser(TEST_USER_ID)).toBeTruthy(); // have alice initiate a verification. She should send a m.key.verification.request @@ -384,7 +389,9 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u expect(request.phase).toEqual(VerificationPhase.Done); }); - oldBackendOnly("can cancel during the SAS phase", async () => { + it("can cancel during the SAS phase", async () => { + await waitForDeviceList(); + // have alice initiate a verification. She should send a m.key.verification.request const [, request] = await Promise.all([ expectSendToDeviceMessage("m.key.verification.request"), @@ -426,12 +433,13 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u expect(verifier.hasBeenCancelled).toBe(false); // start off the verification process: alice will send an `accept` + const sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.accept"); const verificationPromise = verifier.verify(); // advance the clock, because the devicelist likes to sleep for 5ms during key downloads jest.advanceTimersByTime(10); - await expectSendToDeviceMessage("m.key.verification.accept"); + await sendToDevicePromise; - // now we unceremoniously cancel + // now we unceremoniously cancel. We expect the verificatationPromise to reject. const requestPromise = expectSendToDeviceMessage("m.key.verification.cancel"); verifier.cancel(new Error("blah")); await requestPromise; @@ -486,6 +494,19 @@ function runTests(backend: string, initCrypto: InitCrypto, methods: string[] | u }); }); + /** make sure that the client knows about the dummy device */ + async function waitForDeviceList(): Promise { + // Completing the initial sync will make the device list download outdated device lists (of which our own + // user will be one). + syncResponder.sendOrQueueSyncResponse({}); + // DeviceList has a sleep(5) which we need to make happen + await jest.advanceTimersByTimeAsync(10); + + // The client should now know about the dummy device + const devices = await aliceClient.getCrypto()!.getUserDeviceInfo([TEST_USER_ID]); + expect(devices.get(TEST_USER_ID)!.keys()).toContain(TEST_DEVICE_ID); + } + function returnToDeviceMessageFromSync(ev: { type: string; content: object; sender?: string }): void { ev.sender ??= TEST_USER_ID; syncResponder.sendOrQueueSyncResponse({ to_device: { events: [ev] } }); diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index c2826130ba9..d502cda75f9 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -461,6 +461,15 @@ describe("RustCrypto", () => { expect(deviceMap.has(testData.TEST_DEVICE_ID)).toBe(true); rustCrypto.stop(); }); + + describe("requestDeviceVerification", () => { + it("throws an error if the device is unknown", async () => { + const rustCrypto = await makeTestRustCrypto(); + await expect(() => rustCrypto.requestDeviceVerification(TEST_USER, "unknown")).rejects.toThrow( + "Not a known device", + ); + }); + }); }); /** build a basic RustCrypto instance for testing diff --git a/src/client.ts b/src/client.ts index 175e2504241..3d1424efe90 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2235,6 +2235,8 @@ export class MatrixClient extends TypedEventEmitter { + public async requestOwnUserVerification(): Promise { + /* something like this, but currently untested + const user: RustSdkCryptoJs.OwnUserIdentity = await this.olmMachine.getIdentity( + new RustSdkCryptoJs.UserId(this.userId), + ); + const [request, outgoingRequest]: [RustSdkCryptoJs.VerificationRequest, RustSdkCryptoJs.ToDeviceRequest] = + await user.requestVerification( + this.supportedVerificationMethods?.map(verificationMethodIdentifierToMethod), + ); + await this.outgoingRequestProcessor.makeOutgoingRequest(outgoingRequest); + return new RustVerificationRequest(request, this.outgoingRequestProcessor); + */ throw new Error("not implemented"); } @@ -526,15 +545,29 @@ export class RustCrypto implements CryptoBackend { * * If a verification is already in flight, returns it. Otherwise, initiates a new one. * - * Implementation of {@link CryptoApi#requestDeviceVerification }. + * Implementation of {@link CryptoApi#requestDeviceVerification}. * * @param userId - ID of the owner of the device to verify * @param deviceId - ID of the device to verify * * @returns a VerificationRequest when the request has been sent to the other party. */ - public requestDeviceVerification(userId: string, deviceId: string): Promise { - throw new Error("not implemented"); + public async requestDeviceVerification(userId: string, deviceId: string): Promise { + const device: RustSdkCryptoJs.Device | undefined = await this.olmMachine.getDevice( + new RustSdkCryptoJs.UserId(userId), + new RustSdkCryptoJs.DeviceId(deviceId), + ); + + if (!device) { + throw new Error("Not a known device"); + } + + const [request, outgoingRequest]: [RustSdkCryptoJs.VerificationRequest, RustSdkCryptoJs.ToDeviceRequest] = + await device.requestVerification( + this.supportedVerificationMethods?.map(verificationMethodIdentifierToMethod), + ); + await this.outgoingRequestProcessor.makeOutgoingRequest(outgoingRequest); + return new RustVerificationRequest(request, this.outgoingRequestProcessor); } /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/src/rust-crypto/verification.ts b/src/rust-crypto/verification.ts new file mode 100644 index 00000000000..b9dfe6e4aa4 --- /dev/null +++ b/src/rust-crypto/verification.ts @@ -0,0 +1,429 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js"; +import { Emoji } from "@matrix-org/matrix-sdk-crypto-js"; + +import { + ShowQrCodeCallbacks, + ShowSasCallbacks, + VerificationPhase, + VerificationRequest, + VerificationRequestEvent, + VerificationRequestEventHandlerMap, + Verifier, + VerifierEvent, + VerifierEventHandlerMap, +} from "../crypto-api/verification"; +import { TypedEventEmitter } from "../models/typed-event-emitter"; +import { OutgoingRequest, OutgoingRequestProcessor } from "./OutgoingRequestProcessor"; + +/** + * An incoming, or outgoing, request to verify a user or a device via cross-signing. + */ +export class RustVerificationRequest + extends TypedEventEmitter + implements VerificationRequest +{ + private _verifier: Verifier | undefined; + + public constructor( + private readonly inner: RustSdkCryptoJs.VerificationRequest, + outgoingRequestProcessor: OutgoingRequestProcessor, + ) { + super(); + + const onChange = async (): Promise => { + this.emit(VerificationRequestEvent.Change); + + // if we now have a `Verification` where we lacked one before, wrap it. + // TODO: QR support + if (this._verifier === undefined) { + const verification: RustSdkCryptoJs.Qr | RustSdkCryptoJs.Sas | undefined = this.inner.getVerification(); + if (verification instanceof RustSdkCryptoJs.Sas) { + this._verifier = new RustSASVerifier(verification, this, outgoingRequestProcessor); + } + } + }; + inner.registerChangesCallback(onChange); + } + + /** + * Unique ID for this verification request. + * + * An ID isn't assigned until the first message is sent, so this may be `undefined` in the early phases. + */ + public get transactionId(): string | undefined { + return this.inner.flowId; + } + + /** + * For an in-room verification, the ID of the room. + * + * For to-device verifications, `undefined`. + */ + public get roomId(): string | undefined { + return this.inner.roomId?.toString(); + } + + /** + * True if this request was initiated by the local client. + * + * For in-room verifications, the initiator is who sent the `m.key.verification.request` event. + * For to-device verifications, the initiator is who sent the `m.key.verification.start` event. + */ + public get initiatedByMe(): boolean { + return this.inner.weStarted(); + } + + /** The user id of the other party in this request */ + public get otherUserId(): string { + return this.inner.otherUserId.toString(); + } + + /** For verifications via to-device messages: the ID of the other device. Otherwise, undefined. */ + public get otherDeviceId(): string | undefined { + return this.inner.otherDeviceId?.toString(); + } + + /** True if the other party in this request is one of this user's own devices. */ + public get isSelfVerification(): boolean { + return this.inner.isSelfVerification(); + } + + /** current phase of the request. */ + public get phase(): VerificationPhase { + const phase = this.inner.phase(); + + switch (phase) { + case RustSdkCryptoJs.VerificationRequestPhase.Created: + case RustSdkCryptoJs.VerificationRequestPhase.Requested: + return VerificationPhase.Requested; + case RustSdkCryptoJs.VerificationRequestPhase.Ready: + return VerificationPhase.Ready; + case RustSdkCryptoJs.VerificationRequestPhase.Transitioned: + return VerificationPhase.Started; + case RustSdkCryptoJs.VerificationRequestPhase.Done: + return VerificationPhase.Done; + case RustSdkCryptoJs.VerificationRequestPhase.Cancelled: + return VerificationPhase.Cancelled; + } + + throw new Error(`Unknown verification phase ${phase}`); + } + + /** True if the request has sent its initial event and needs more events to complete + * (ie it is in phase `Requested`, `Ready` or `Started`). + */ + public get pending(): boolean { + throw new Error("not implemented"); + } + + /** + * True if we have started the process of sending an `m.key.verification.ready` (but have not necessarily received + * the remote echo which causes a transition to {@link VerificationPhase.Ready}. + */ + public get accepting(): boolean { + throw new Error("not implemented"); + } + + /** + * True if we have started the process of sending an `m.key.verification.cancel` (but have not necessarily received + * the remote echo which causes a transition to {@link VerificationPhase.Cancelled}). + */ + public get declining(): boolean { + throw new Error("not implemented"); + } + + /** + * The remaining number of ms before the request will be automatically cancelled. + * + * `null` indicates that there is no timeout + */ + public get timeout(): number | null { + throw new Error("not implemented"); + } + + /** once the phase is Started (and !initiatedByMe) or Ready: common methods supported by both sides */ + public get methods(): string[] { + throw new Error("not implemented"); + } + + /** the method picked in the .start event */ + public get chosenMethod(): string | null { + const verification: RustSdkCryptoJs.Qr | RustSdkCryptoJs.Sas | undefined = this.inner.getVerification(); + // TODO: this isn't quite right. The existence of a Verification doesn't prove that we have .started. + if (verification instanceof RustSdkCryptoJs.Sas) { + return "m.sas.v1"; + } else { + return null; + } + } + + /** + * Checks whether the other party supports a given verification method. + * This is useful when setting up the QR code UI, as it is somewhat asymmetrical: + * if the other party supports SCAN_QR, we should show a QR code in the UI, and vice versa. + * For methods that need to be supported by both ends, use the `methods` property. + * + * @param method - the method to check + * @returns true if the other party said they supported the method + */ + public otherPartySupportsMethod(method: string): boolean { + const theirMethods: RustSdkCryptoJs.VerificationMethod[] | undefined = this.inner.theirSupportedMethods; + if (theirMethods === undefined) { + // no message from the other side yet + return false; + } + + const requiredMethod = verificationMethodsByIdentifier[method]; + return theirMethods.some((m) => m === requiredMethod); + } + + /** + * Accepts the request, sending a .ready event to the other party + * + * @returns Promise which resolves when the event has been sent. + */ + public accept(): Promise { + throw new Error("not implemented"); + } + + /** + * Cancels the request, sending a cancellation to the other party + * + * @param params - Details for the cancellation, including `reason` (defaults to "User declined"), and `code` + * (defaults to `m.user`). + * + * @returns Promise which resolves when the event has been sent. + */ + public cancel(params?: { reason?: string; code?: string }): Promise { + throw new Error("not implemented"); + } + + /** + * Create a {@link Verifier} to do this verification via a particular method. + * + * If a verifier has already been created for this request, returns that verifier. + * + * This does *not* send the `m.key.verification.start` event - to do so, call {@link Verifier#verifier} on the + * returned verifier. + * + * If no previous events have been sent, pass in `targetDevice` to set who to direct this request to. + * + * @param method - the name of the verification method to use. + * @param targetDevice - details of where to send the request to. + * + * @returns The verifier which will do the actual verification. + */ + public beginKeyVerification(method: string, targetDevice?: { userId?: string; deviceId?: string }): Verifier { + throw new Error("not implemented"); + } + + /** + * The verifier which is doing the actual verification, once the method has been established. + * Only defined when the `phase` is Started. + */ + public get verifier(): Verifier | undefined { + return this._verifier; + } + + /** + * Get the data for a QR code allowing the other device to verify this one, if it supports it. + * + * Only set after a .ready if the other party can scan a QR code, otherwise undefined. + */ + public getQRCodeBytes(): Buffer | undefined { + // TODO + return undefined; + } + + /** + * If this request has been cancelled, the cancellation code (e.g `m.user`) which is responsible for cancelling + * this verification. + */ + public get cancellationCode(): string | null { + throw new Error("not implemented"); + } + + /** + * The id of the user that cancelled the request. + * + * Only defined when phase is Cancelled + */ + public get cancellingUserId(): string | undefined { + throw new Error("not implemented"); + } +} + +export class RustSASVerifier extends TypedEventEmitter implements Verifier { + /** A promise which completes when the verification completes (or rejects when it is cancelled/fails) */ + private readonly completionPromise: Promise; + + private callbacks: ShowSasCallbacks | null = null; + + public constructor( + private readonly inner: RustSdkCryptoJs.Sas, + _verificationRequest: RustVerificationRequest, + private readonly outgoingRequestProcessor: OutgoingRequestProcessor, + ) { + super(); + + this.completionPromise = new Promise((resolve, reject) => { + const onChange = async (): Promise => { + this.updateCallbacks(); + + if (this.inner.isDone()) { + resolve(undefined); + } else if (this.inner.isCancelled()) { + const cancelInfo = this.inner.cancelInfo()!; + reject( + new Error( + `Verification cancelled by ${ + cancelInfo.cancelledbyUs() ? "us" : "them" + } with code ${cancelInfo.cancelCode()}: ${cancelInfo.reason()}`, + ), + ); + } + }; + inner.registerChangesCallback(onChange); + }); + // stop the runtime complaining if nobody catches a failure + this.completionPromise.catch(() => null); + } + + /** if we can now show the callbacks, do so */ + private updateCallbacks(): void { + if (this.callbacks === null) { + const emoji: Array | undefined = this.inner.emoji(); + const decimal = this.inner.decimals() as [number, number, number] | undefined; + + if (emoji === undefined && decimal === undefined) { + return; + } + + this.callbacks = { + sas: { + decimal: decimal, + emoji: emoji?.map((e) => [e.symbol, e.description]), + }, + confirm: async (): Promise => { + const requests: Array = await this.inner.confirm(); + for (const m of requests) { + await this.outgoingRequestProcessor.makeOutgoingRequest(m); + } + }, + mismatch: (): void => { + throw new Error("impl"); + }, + cancel: (): void => { + throw new Error("impl"); + }, + }; + this.emit(VerifierEvent.ShowSas, this.callbacks); + } + } + + /** + * Returns true if the verification has been cancelled, either by us or the other side. + */ + public get hasBeenCancelled(): boolean { + return this.inner.isCancelled(); + } + + /** + * The ID of the other user in the verification process. + */ + public get userId(): string { + return this.inner.otherUserId.toString(); + } + + /** + * Start the key verification, if it has not already been started. + * + * This means sending a `m.key.verification.start` if we are the first responder, or a `m.key.verification.accept` + * if the other side has already sent a start event. + * + * @returns Promise which resolves when the verification has completed, or rejects if the verification is cancelled + * or times out. + */ + public async verify(): Promise { + const req: undefined | OutgoingRequest = this.inner.accept(); + if (req) { + await this.outgoingRequestProcessor.makeOutgoingRequest(req); + } + await this.completionPromise; + } + + /** + * Cancel a verification. + * + * We will send an `m.key.verification.cancel` if the verification is still in flight. The verification promise + * will reject, and a {@link Crypto.VerifierEvent#Cancel} will be emitted. + * + * @param e - the reason for the cancellation. + */ + public cancel(e: Error): void { + // TODO: something with `e` + const req: undefined | OutgoingRequest = this.inner.cancel(); + if (req) { + this.outgoingRequestProcessor.makeOutgoingRequest(req); + } + } + + /** + * Get the details for an SAS verification, if one is in progress + * + * Returns `null`, unless this verifier is for a SAS-based verification and we are waiting for the user to confirm + * the SAS matches. + */ + public getShowSasCallbacks(): ShowSasCallbacks | null { + return this.callbacks; + } + + /** + * Get the details for reciprocating QR code verification, if one is in progress + * + * Returns `null`, unless this verifier is for reciprocating a QR-code-based verification (ie, the other user has + * already scanned our QR code), and we are waiting for the user to confirm. + */ + public getReciprocateQrCodeCallbacks(): ShowQrCodeCallbacks | null { + return null; + } +} + +/** For each specced verification method, the rust-side `VerificationMethod` corresponding to it */ +const verificationMethodsByIdentifier: Record = { + "m.sas.v1": RustSdkCryptoJs.VerificationMethod.SasV1, + "m.qr_code.scan.v1": RustSdkCryptoJs.VerificationMethod.QrCodeScanV1, + "m.qr_code.show.v1": RustSdkCryptoJs.VerificationMethod.QrCodeShowV1, + "m.reciprocate.v1": RustSdkCryptoJs.VerificationMethod.ReciprocateV1, +}; + +/** + * Convert a specced verification method identifier into a rust-side `VerificationMethod`. + * + * @param method - specced method identifier, for example `m.sas.v1`. + * @returns Rust-side `VerificationMethod` corresponding to `method`. + * @throws An error if the method is unknown. + */ +export function verificationMethodIdentifierToMethod(method: string): RustSdkCryptoJs.VerificationMethod { + const meth = verificationMethodsByIdentifier[method]; + if (meth === undefined) { + throw new Error(`Unknown verification method ${method}`); + } + return meth; +} diff --git a/yarn.lock b/yarn.lock index fb37fbf7171..bd25074a6e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1426,10 +1426,10 @@ dependencies: lodash "^4.17.21" -"@matrix-org/matrix-sdk-crypto-js@^0.1.0-alpha.10": - version "0.1.0-alpha.10" - resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.10.tgz#b6a6395cffd3197ae2e0a88f4eeae8b315571fd2" - integrity sha512-8V2NKuzGOFzEZeZVgF2is7gmuopdRbMZ064tzPDE0vN34iX6s3O8A4oxIT7SA3qtymwm3t1yEvTnT+0gfbmh4g== +"@matrix-org/matrix-sdk-crypto-js@^0.1.0-alpha.11": + version "0.1.0-alpha.11" + resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.11.tgz#24d705318c3159ef7dbe43bca464ac2bdd11e45d" + integrity sha512-HD3rskPkqrUUSaKzGLg97k/bN+OZrkcX7ODB/pNBs/jqq+/A0wDKqsszJotzFwsQcDPpWn78BmMyvBo4tLxKjw== "@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz": version "3.2.14"