From 8ed13b22475e91a2fef6eeffa92e5981a628d883 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Wed, 8 Feb 2023 09:14:59 -0800 Subject: [PATCH] Username hashing --- .github/workflows/ci.yml | 2 +- config/default.json | 2 +- config/production.json | 2 +- package.json | 4 +- protos/SignalStorage.proto | 3 + .../EditUsernameModalBody.stories.tsx | 4 +- ts/services/storageRecordOps.ts | 6 + ts/services/username.ts | 31 +++- ts/test-electron/state/ducks/username_test.ts | 2 +- ts/test-mock/pnp/merge_test.ts | 7 + ts/test-mock/pnp/username_test.ts | 134 +++++++++++++++++- ts/textsecure/WebAPI.ts | 77 ++++++---- ts/types/Username.ts | 2 +- ts/util/lookupConversationWithoutUuid.ts | 6 +- yarn.lock | 42 +++--- 15 files changed, 255 insertions(+), 69 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 079047ca6..004763d3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -222,7 +222,7 @@ jobs: - name: Upload mock server test logs on failure if: failure() - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: logs path: artifacts diff --git a/config/default.json b/config/default.json index e21ecc254..cfc6f6d75 100644 --- a/config/default.json +++ b/config/default.json @@ -2,7 +2,7 @@ "serverUrl": "https://chat.staging.signal.org", "storageUrl": "https://storage-staging.signal.org", "directoryUrl": "https://cdsi.staging.signal.org", - "directoryMRENCLAVE": "ef4787a56a154ac6d009138cac17155acd23cfe4329281252365dd7c252e7fbf", + "directoryMRENCLAVE": "0f6fd79cdfdaa5b2e6337f534d3baf999318b0c462a7ac1f41297a3e4b424a57", "cdn": { "0": "https://cdn-staging.signal.org", "2": "https://cdn2-staging.signal.org" diff --git a/config/production.json b/config/production.json index 56f13cd95..c952c1e5f 100644 --- a/config/production.json +++ b/config/production.json @@ -2,7 +2,7 @@ "serverUrl": "https://chat.signal.org", "storageUrl": "https://storage.signal.org", "directoryUrl": "https://cdsi.signal.org", - "directoryMRENCLAVE": "ef4787a56a154ac6d009138cac17155acd23cfe4329281252365dd7c252e7fbf", + "directoryMRENCLAVE": "0f6fd79cdfdaa5b2e6337f534d3baf999318b0c462a7ac1f41297a3e4b424a57", "cdn": { "0": "https://cdn.signal.org", "2": "https://cdn2.signal.org" diff --git a/package.json b/package.json index 334d51da5..4155909af 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "@popperjs/core": "2.11.6", "@react-spring/web": "9.5.5", "@signalapp/better-sqlite3": "8.4.1", - "@signalapp/libsignal-client": "0.21.1", + "@signalapp/libsignal-client": "0.22.0", "@signalapp/ringrtc": "2.24.0", "@types/fabric": "4.5.3", "array-move": "2.1.0", @@ -191,7 +191,7 @@ "@babel/preset-typescript": "7.17.12", "@electron/fuses": "1.5.0", "@mixer/parallel-prettier": "2.0.1", - "@signalapp/mock-server": "2.13.0", + "@signalapp/mock-server": "2.14.0", "@storybook/addon-a11y": "6.5.6", "@storybook/addon-actions": "6.5.6", "@storybook/addon-controls": "6.5.6", diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index 578298b6d..1f4a02980 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -173,6 +173,9 @@ message AccountRecord { reserved 28; // deprecatedStoriesDisabled optional bool storiesDisabled = 29; optional OptionalBool storyViewReceiptsEnabled = 30; + reserved 31; // hasReadOnboardingStory + reserved 32; // hasSeenGroupStoryEducationSheet + optional string username = 33; } message StoryDistributionListRecord { diff --git a/ts/components/EditUsernameModalBody.stories.tsx b/ts/components/EditUsernameModalBody.stories.tsx index b7205ac1c..1e9fed153 100644 --- a/ts/components/EditUsernameModalBody.stories.tsx +++ b/ts/components/EditUsernameModalBody.stories.tsx @@ -20,7 +20,7 @@ const i18n = setupI18n('en', enMessages); const DEFAULT_RESERVATION: UsernameReservationType = { username: 'reserved.56', previousUsername: undefined, - reservationToken: 'unused token', + hash: new Uint8Array(), }; export default { @@ -87,7 +87,7 @@ const Template: Story = args => { reservation = { username: `reserved.${args.discriminator}`, previousUsername: undefined, - reservationToken: 'unused token', + hash: new Uint8Array(), }; } return ; diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index afbd74c9b..e2284e599 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -236,6 +236,10 @@ export function toAccountRecord( if (avatarUrl !== undefined) { accountRecord.avatarUrl = avatarUrl; } + const username = conversation.get('username'); + if (username !== undefined) { + accountRecord.username = username; + } accountRecord.noteToSelfArchived = Boolean(conversation.get('isArchived')); accountRecord.noteToSelfMarkedUnread = Boolean( conversation.get('markedUnread') @@ -1136,6 +1140,7 @@ export async function mergeAccountRecord( hasViewedOnboardingStory, storiesDisabled, storyViewReceiptsEnabled, + username, } = accountRecord; const updatedConversations = new Array(); @@ -1407,6 +1412,7 @@ export async function mergeAccountRecord( conversation.set({ isArchived: Boolean(noteToSelfArchived), markedUnread: Boolean(noteToSelfMarkedUnread), + username: dropNull(username), storageID, storageVersion, }); diff --git a/ts/services/username.ts b/ts/services/username.ts index 5b7537e1f..d985e183f 100644 --- a/ts/services/username.ts +++ b/ts/services/username.ts @@ -1,8 +1,12 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { usernames } from '@signalapp/libsignal-client'; + import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue'; +import { strictAssert } from '../util/assert'; import { sleep } from '../util/sleep'; +import { getMinNickname, getMaxNickname } from '../util/Username'; import type { UsernameReservationType } from '../types/Username'; import { ReserveUsernameError } from '../types/Username'; import * as Errors from '../types/errors'; @@ -58,14 +62,29 @@ export async function reserveUsername( } try { - const { username, reservationToken } = await server.reserveUsername({ + const candidates = usernames.generateCandidates( nickname, + getMinNickname(), + getMaxNickname() + ); + const hashes = candidates.map(username => usernames.hash(username)); + + const { usernameHash } = await server.reserveUsername({ + hashes, abortSignal, }); + const index = hashes.findIndex(hash => hash.equals(usernameHash)); + if (index === -1) { + log.warn('reserveUsername: failed to find username hash in the response'); + return { ok: false, error: ReserveUsernameError.Unprocessable }; + } + + const username = candidates[index]; + return { ok: true, - reservation: { previousUsername, username, reservationToken }, + reservation: { previousUsername, username, hash: usernameHash }, }; } catch (error) { if (error instanceof HTTPError) { @@ -116,18 +135,20 @@ export async function confirmUsername( throw new Error('server interface is not available!'); } - const { previousUsername, username, reservationToken } = reservation; + const { previousUsername, username, hash } = reservation; const me = window.ConversationController.getOurConversationOrThrow(); if (me.get('username') !== previousUsername) { throw new Error('Username has changed on another device'); } + const proof = usernames.generateProof(username); + strictAssert(usernames.hash(username).equals(hash), 'username hash mismatch'); try { await server.confirmUsername({ - usernameToConfirm: username, - reservationToken, + hash, + proof, abortSignal, }); diff --git a/ts/test-electron/state/ducks/username_test.ts b/ts/test-electron/state/ducks/username_test.ts index 98c74dbc2..ac8ea1f74 100644 --- a/ts/test-electron/state/ducks/username_test.ts +++ b/ts/test-electron/state/ducks/username_test.ts @@ -25,7 +25,7 @@ import { ReserveUsernameError } from '../../../types/Username'; const DEFAULT_RESERVATION = { username: 'abc.12', previousUsername: undefined, - reservationToken: 'def', + hash: new Uint8Array(), }; describe('electron/state/ducks/username', () => { diff --git a/ts/test-mock/pnp/merge_test.ts b/ts/test-mock/pnp/merge_test.ts index ed3f43e5a..f1a78f3be 100644 --- a/ts/test-mock/pnp/merge_test.ts +++ b/ts/test-mock/pnp/merge_test.ts @@ -432,6 +432,13 @@ describe('pnp/merge', function needsName() { debug('Open PNI conversation'); await leftPane.locator(`[data-testid="${pniContact.device.pni}"]`).click(); + debug('Wait for ACI conversation to go away'); + await window + .locator(`.module-conversation-hero >> ${pniContact.profileName}`) + .waitFor({ + state: 'hidden', + }); + debug('Verify absence of messages in the PNI conversation'); { const messages = window.locator('.module-message__text'); diff --git a/ts/test-mock/pnp/username_test.ts b/ts/test-mock/pnp/username_test.ts index 415e3cc69..8803d9f65 100644 --- a/ts/test-mock/pnp/username_test.ts +++ b/ts/test-mock/pnp/username_test.ts @@ -17,6 +17,8 @@ export const debug = createDebug('mock:test:username'); const IdentifierType = Proto.ManifestRecord.Identifier.Type; const USERNAME = 'signalapp.55'; +const NICKNAME = 'signalapp'; +const CARL_USERNAME = 'carl.84'; describe('pnp/username', function needsName() { this.timeout(durations.MINUTE); @@ -92,9 +94,7 @@ describe('pnp/username', function needsName() { .waitFor(); debug('adding profile key for username contact'); - let state: StorageState = await phone.expectStorageState( - 'consistency check' - ); + let state = await phone.expectStorageState('consistency check'); state = state.updateContact(usernameContact, { profileKey: usernameContact.profileKey.serialize(), }); @@ -134,4 +134,132 @@ describe('pnp/username', function needsName() { assert.strictEqual(removed[0].contact?.username, USERNAME); } }); + + it('reserves/confirms/deletes username', async () => { + const { phone, server } = bootstrap; + + const window = await app.getWindow(); + + debug('opening avatar context menu'); + await window + .locator('.module-main-header .module-Avatar__contents') + .click(); + + debug('opening profile editor'); + await window + .locator('.module-avatar-popup .module-avatar-popup__profile') + .click(); + + debug('opening username editor'); + const profileEditor = window.locator('.ProfileEditor'); + await profileEditor.locator('.ProfileEditor__row >> "Username"').click(); + + debug('entering new username'); + const usernameField = profileEditor.locator('.Input__input'); + await usernameField.type(NICKNAME); + + debug('waiting for generated discriminator'); + const discriminator = profileEditor.locator( + '.EditUsernameModalBody__discriminator:not(:empty)' + ); + await discriminator.waitFor(); + + const discriminatorValue = await discriminator.innerText(); + assert.match(discriminatorValue, /^\.\d+$/); + + const username = `${NICKNAME}${discriminatorValue}`; + + debug('saving username'); + let state = await phone.expectStorageState('consistency check'); + await profileEditor.locator('.module-Button >> "Save"').click(); + + debug('checking the username is saved'); + { + await profileEditor + .locator(`.ProfileEditor__row >> "${username}"`) + .waitFor(); + + const uuid = await server.lookupByUsername(username); + assert.strictEqual(uuid, phone.device.uuid); + + const newState = await phone.waitForStorageState({ + after: state, + }); + + const { added, removed } = newState.diff(state); + assert.strictEqual(added.length, 1, 'only one record must be added'); + assert.strictEqual(removed.length, 1, 'only one record must be removed'); + + assert.strictEqual(added[0]?.account?.username, username); + + state = newState; + } + + debug('deleting username'); + await profileEditor + .locator('button[aria-label="Copy or delete username"]') + .click(); + await profileEditor.locator('button[aria-label="Delete"]').click(); + await window + .locator('.module-Modal .module-Modal__button-footer button >> "Delete"') + .click(); + await profileEditor.locator('.ProfileEditor__row >> "Username"').waitFor(); + + debug('confirming username deletion'); + { + const uuid = await server.lookupByUsername(username); + assert.strictEqual(uuid, undefined); + + const newState = await phone.waitForStorageState({ + after: state, + }); + + const { added, removed } = newState.diff(state); + assert.strictEqual(added.length, 1, 'only one record must be added'); + assert.strictEqual(removed.length, 1, 'only one record must be removed'); + + assert.strictEqual(added[0]?.account?.username, ''); + + state = newState; + } + }); + + it('looks up contacts by username', async () => { + const { desktop, server } = bootstrap; + + debug('creating a contact with username'); + const carl = await server.createPrimaryDevice({ + profileName: 'Carl', + }); + + await server.setUsername(carl.device.uuid, CARL_USERNAME); + + const window = await app.getWindow(); + + debug('entering username into search field'); + await window.locator('button[aria-label="New conversation"]').click(); + + const searchInput = window.locator('.module-SearchInput__container input'); + await searchInput.type(`@${CARL_USERNAME}`); + + debug('starting lookup'); + await window.locator(`button >> "@${CARL_USERNAME}"`).click(); + + debug('sending a message'); + { + const composeArea = window.locator( + '.composition-area-wrapper, .conversation .ConversationView' + ); + const compositionInput = composeArea.locator( + '[data-testid=CompositionInput]' + ); + + await compositionInput.type('Hello Carl'); + await compositionInput.press('Enter'); + + const { body, source } = await carl.waitForMessage(); + assert.strictEqual(body, 'Hello Carl'); + assert.strictEqual(source, desktop); + } + }); }); diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 43224fb5d..5436b3358 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -24,7 +24,7 @@ import { explodePromise } from '../util/explodePromise'; import { getUserAgent } from '../util/getUserAgent'; import { getStreamWithTimeout } from '../util/getStreamWithTimeout'; import { formatAcceptLanguageHeader } from '../util/userLanguages'; -import { toWebSafeBase64 } from '../util/webSafeBase64'; +import { toWebSafeBase64, fromWebSafeBase64 } from '../util/webSafeBase64'; import { getBasicAuth } from '../util/getBasicAuth'; import { isPnpEnabled } from '../util/isPnpEnabled'; import type { SocketStatus } from '../types/SocketStatus'; @@ -505,9 +505,9 @@ const URL_CALLS = { subscriptions: 'v1/subscription', supportUnauthenticatedDelivery: 'v1/devices/unauthenticated_delivery', updateDeviceName: 'v1/accounts/name', - username: 'v1/accounts/username', - reservedUsername: 'v1/accounts/username/reserved', - confirmUsername: 'v1/accounts/username/confirm', + username: 'v1/accounts/username_hash', + reserveUsername: 'v1/accounts/username_hash/reserve', + confirmUsername: 'v1/accounts/username_hash/confirm', whoami: 'v1/accounts/whoami', }; @@ -687,10 +687,18 @@ export type ProfileType = Readonly<{ badges?: unknown; }>; -export type AccountType = Readonly<{ - uuid?: string; +export type GetAccountForUsernameOptionsType = Readonly<{ + hash: Uint8Array; }>; +const getAccountForUsernameResultZod = z.object({ + uuid: z.string(), +}); + +export type GetAccountForUsernameResultType = z.infer< + typeof getAccountForUsernameResultZod +>; + export type GetIceServersResultType = Readonly<{ username: string; password: string; @@ -784,19 +792,20 @@ export type VerifyAciRequestType = Array<{ aci: string; fingerprint: string }>; export type VerifyAciResponseType = z.infer; export type ReserveUsernameOptionsType = Readonly<{ - nickname: string; + hashes: ReadonlyArray; abortSignal?: AbortSignal; }>; export type ConfirmUsernameOptionsType = Readonly<{ - usernameToConfirm: string; - reservationToken: string; + hash: Uint8Array; + proof: Uint8Array; abortSignal?: AbortSignal; }>; const reserveUsernameResultZod = z.object({ - username: z.string(), - reservationToken: z.string(), + usernameHash: z + .string() + .transform(x => Bytes.fromBase64(fromWebSafeBase64(x))), }); export type ReserveUsernameResultType = z.infer< typeof reserveUsernameResultZod @@ -874,7 +883,9 @@ export type WebAPIType = { identifier: string, options: GetProfileOptionsType ) => Promise; - getAccountForUsername: (username: string) => Promise; + getAccountForUsername: ( + options: GetAccountForUsernameOptionsType + ) => Promise; getProfileUnauth: ( identifier: string, options: GetProfileUnauthOptionsType @@ -1628,16 +1639,21 @@ export function initialize({ })) as ProfileType; } - async function getAccountForUsername(usernameToFetch: string) { - return (await _ajax({ - call: 'username', - httpType: 'GET', - urlParameters: `/${encodeURIComponent(usernameToFetch)}`, - responseType: 'json', - redactUrl: _createRedactor(usernameToFetch), - unauthenticated: true, - accessKey: undefined, - })) as ProfileType; + async function getAccountForUsername({ + hash, + }: GetAccountForUsernameOptionsType) { + const hashBase64 = toWebSafeBase64(Bytes.toBase64(hash)); + return getAccountForUsernameResultZod.parse( + await _ajax({ + call: 'username', + httpType: 'GET', + urlParameters: `/${hashBase64}`, + responseType: 'json', + redactUrl: _createRedactor(hashBase64), + unauthenticated: true, + accessKey: undefined, + }) + ); } async function putProfile( @@ -1774,15 +1790,18 @@ export function initialize({ abortSignal, }); } + async function reserveUsername({ - nickname, + hashes, abortSignal, }: ReserveUsernameOptionsType) { const response = await _ajax({ - call: 'reservedUsername', + call: 'reserveUsername', httpType: 'PUT', jsonData: { - nickname, + usernameHashes: hashes.map(hash => + toWebSafeBase64(Bytes.toBase64(hash)) + ), }, responseType: 'json', abortSignal, @@ -1791,16 +1810,16 @@ export function initialize({ return reserveUsernameResultZod.parse(response); } async function confirmUsername({ - usernameToConfirm, - reservationToken, + hash, + proof, abortSignal, }: ConfirmUsernameOptionsType) { await _ajax({ call: 'confirmUsername', httpType: 'PUT', jsonData: { - usernameToConfirm, - reservationToken, + usernameHash: toWebSafeBase64(Bytes.toBase64(hash)), + zkProof: toWebSafeBase64(Bytes.toBase64(proof)), }, abortSignal, }); diff --git a/ts/types/Username.ts b/ts/types/Username.ts index 43d881c40..1ba745ea9 100644 --- a/ts/types/Username.ts +++ b/ts/types/Username.ts @@ -4,7 +4,7 @@ export type UsernameReservationType = Readonly<{ username: string; previousUsername: string | undefined; - reservationToken: string; + hash: Uint8Array; }>; export enum ReserveUsernameError { diff --git a/ts/util/lookupConversationWithoutUuid.ts b/ts/util/lookupConversationWithoutUuid.ts index 76c89a222..a1c5e0a57 100644 --- a/ts/util/lookupConversationWithoutUuid.ts +++ b/ts/util/lookupConversationWithoutUuid.ts @@ -1,6 +1,8 @@ // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { usernames } from '@signalapp/libsignal-client'; + import { ToastFailedToFetchUsername } from '../components/ToastFailedToFetchUsername'; import { ToastFailedToFetchPhoneNumber } from '../components/ToastFailedToFetchPhoneNumber'; import type { UserNotFoundModalStateType } from '../state/ducks/globalModals'; @@ -145,7 +147,9 @@ async function checkForUsername( } try { - const account = await server.getAccountForUsername(username); + const account = await server.getAccountForUsername({ + hash: usernames.hash(username), + }); if (!account.uuid) { log.error("checkForUsername: Returned account didn't include a uuid"); diff --git a/yarn.lock b/yarn.lock index 619f9276f..497f226b3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2162,37 +2162,30 @@ bindings "^1.5.0" tar "^6.1.0" -"@signalapp/libsignal-client@0.21.1": - version "0.21.1" - resolved "https://registry.yarnpkg.com/@signalapp/libsignal-client/-/libsignal-client-0.21.1.tgz#381d6162ae0e7719dc625780c1d6c3f9f558c33d" - integrity sha512-PiQfzB1sI5G6BEDl7Vq/4gj1btLkqvPwd4F9b0IHmWqgLAuCDR/ROnrKtEVRZlWmmWLMTwxo61E0eUhnesgrTQ== +"@signalapp/libsignal-client@0.22.0", "@signalapp/libsignal-client@^0.22.0": + version "0.22.0" + resolved "https://registry.yarnpkg.com/@signalapp/libsignal-client/-/libsignal-client-0.22.0.tgz#d57441612df46f90df68fc5d9ad45b857b9d2c44" + integrity sha512-f1PJuxpcbmhvHxzbf0BvSJhNA3sqXrwnTf2GtfFB2CQoqTEiGCRYfyFZjwUBByiFFI5mTWKER6WGAw5AvG/3+A== dependencies: node-gyp-build "^4.2.3" uuid "^8.3.0" -"@signalapp/libsignal-client@^0.20.0": - version "0.20.0" - resolved "https://registry.yarnpkg.com/@signalapp/libsignal-client/-/libsignal-client-0.20.0.tgz#d3160575e61592dcc85cb216ff9f38074b24d554" - integrity sha512-uVrDVQKIVNVIGaGfacmpd10O0vSywC5D83PZRRlTBetMideLjxwksj1lco6t5QbM4m6G5Bwc5/xZ/cjzsUjCOA== +"@signalapp/mock-server@2.14.0": + version "2.14.0" + resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-2.14.0.tgz#6309d944cf46e58f6141df45075de882d964ae0a" + integrity sha512-NSLnfjho4HCyrz4Y6cyoIK0f+iuhOrxEFmaabdVUepOaSHPZi1MTyYYo0d6NzD/PGREAQFJuzqNaE+7zhSiPEQ== dependencies: - node-gyp-build "^4.2.3" - uuid "^8.3.0" - -"@signalapp/mock-server@2.13.0": - version "2.13.0" - resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-2.13.0.tgz#928c95a4890b21c811d29b3b0765aa3a35d28006" - integrity sha512-YZZdEYhVoWT4Ih4ZJLu1AaLtn+i8RFO/Tr55/72TvrtcWLjolik3YTnGqHnAcxNR+6mfeCfhpp7+rtexYnnefw== - dependencies: - "@signalapp/libsignal-client" "^0.20.0" + "@signalapp/libsignal-client" "^0.22.0" debug "^4.3.2" long "^4.0.0" micro "^9.3.4" microrouter "^3.1.3" protobufjs "^6.10.2" - typescript "^4.5.5" + typescript "^4.9.5" url-pattern "^1.0.3" uuid "^8.3.2" ws "^8.4.2" + zod "^3.20.2" "@signalapp/ringrtc@2.24.0": version "2.24.0" @@ -17806,10 +17799,10 @@ typescript@4.9.3: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.3.tgz#3aea307c1746b8c384435d8ac36b8a2e580d85db" integrity sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA== -typescript@^4.5.5: - version "4.5.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.5.tgz#d8c953832d28924a9e3d37c73d729c846c5896f3" - integrity sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA== +typescript@^4.9.5: + version "4.9.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" + integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== uc.micro@^1.0.1: version "1.0.5" @@ -19011,6 +19004,11 @@ zod@3.5.1: resolved "https://registry.yarnpkg.com/zod/-/zod-3.5.1.tgz#e93ce58e182bb76f7d29ccd24feee72611f9a129" integrity sha512-Gg9GTai0iDHowuYM9VNhdFMmesgt44ufzqaE5CPHshpuK5fCzbibdqCnrWuYH6ZmOn/N+BlGmwZtVSijhKmhKw== +zod@^3.20.2: + version "3.20.2" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.20.2.tgz#068606642c8f51b3333981f91c0a8ab37dfc2807" + integrity sha512-1MzNQdAvO+54H+EaK5YpyEy0T+Ejo/7YLHS93G3RnYWh5gaotGHwGeN/ZO687qEDU2y4CdStQYXVHIgrUl5UVQ== + zwitch@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"