diff --git a/CHANGELOG.md b/CHANGELOG.md index 01a50fa93a..8417800e98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * Adds basic sigchain functions ([#2625](https://github.com/TryQuiet/quiet/issues/2625)) * Instantiates signature chain when creating communities and reloading application ([#2626](https://github.com/TryQuiet/quiet/issues/2626)) * Added in LFA-ready invite links ([#2627](https://github.com/TryQuiet/quiet/issues/2627)) +* Generating LFA-ready invite links when a sigchain is configured ([#2627](https://github.com/TryQuiet/quiet/issues/2627)) ### Fixes * Changed company name in app to "A Quiet LLC" ([#2642] (https://github.com/TryQuiet/quiet/issues/2642)) diff --git a/packages/backend/src/nest/auth/services/invites/invite.service.ts b/packages/backend/src/nest/auth/services/invites/invite.service.ts index dbe220e893..2ac8e9547a 100644 --- a/packages/backend/src/nest/auth/services/invites/invite.service.ts +++ b/packages/backend/src/nest/auth/services/invites/invite.service.ts @@ -21,6 +21,8 @@ const logger = createLogger('auth:inviteService') export const DEFAULT_MAX_USES = 1 export const DEFAULT_INVITATION_VALID_FOR_MS = 604_800_000 // 1 week +export const DEFAULT_LONG_LIVED_MAX_USES = 0 // no limit +export const DEFAULT_LONG_LIVED_VALID_FOR_MS = 0 // no limit class InviteService extends ChainServiceBase { public static init(sigChain: SigChain): InviteService { @@ -32,7 +34,11 @@ class InviteService extends ChainServiceBase { maxUses: number = DEFAULT_MAX_USES, seed?: string ): InviteResult { - const expiration = (Date.now() + validForMs) as UnixTimestamp + let expiration: UnixTimestamp = 0 as UnixTimestamp + if (validForMs > 0) { + expiration = (Date.now() + validForMs) as UnixTimestamp + } + const invitation: InviteResult = this.sigChain.team.inviteMember({ seed, expiration, @@ -41,6 +47,10 @@ class InviteService extends ChainServiceBase { return invitation } + public createLongLivedUserInvite(): InviteResult { + return this.createUserInvite(DEFAULT_LONG_LIVED_VALID_FOR_MS, DEFAULT_LONG_LIVED_MAX_USES) + } + public createDeviceInvite(validForMs: number = DEFAULT_INVITATION_VALID_FOR_MS, seed?: string): InviteResult { const expiration = (Date.now() + validForMs) as UnixTimestamp const invitation: InviteResult = this.sigChain.team.inviteDevice({ @@ -50,6 +60,23 @@ class InviteService extends ChainServiceBase { return invitation } + public isValidLongLivedUserInvite(id: Base58): boolean { + logger.info(`Validating LFA invite with ID ${id}`) + const invites = this.getAllInvites() + for (const invite of invites) { + if ( + invite.id === id && // is correct invite + !invite.revoked && // is not revoked + invite.maxUses == 0 && // is an unlimited invite + invite.expiration == 0 // is an unlimited invite + ) { + return true + } + } + + return false + } + public revoke(id: string) { this.sigChain.team.revokeInvitation(id) } diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.ts index a1bdbd16a4..47de201ffc 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.service.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.service.ts @@ -12,7 +12,7 @@ import { getLibp2pAddressesFromCsrs, removeFilesFromDir } from '../common/utils' import { LazyModuleLoader } from '@nestjs/core' import { createLibp2pAddress, filterValidAddresses, isPSKcodeValid } from '@quiet/common' -import { CertFieldsTypes, createRootCA, getCertFieldValue, loadCertificate } from '@quiet/identity' +import { CertFieldsTypes, getCertFieldValue, loadCertificate } from '@quiet/identity' import { ChannelMessageIdsResponse, ChannelSubscribedPayload, @@ -28,8 +28,6 @@ import { FileMetadata, GetMessagesPayload, InitCommunityPayload, - InvitationDataV2, - InvitationDataVersion, MessagesLoadedPayload, NetworkDataPayload, NetworkInfo, @@ -62,7 +60,6 @@ import { SocketService } from '../socket/socket.service' import { StorageService } from '../storage/storage.service' import { StorageEvents } from '../storage/storage.types' import { StorageServiceClient } from '../storageServiceClient/storageServiceClient.service' -import { ServerStoredCommunityMetadata } from '../storageServiceClient/storageServiceClient.types' import { Tor } from '../tor/tor.service' import { ConfigOptions, GetPorts, ServerIoProviderTypes } from '../types' import { ServiceState, TorInitState } from './connections-manager.types' @@ -71,6 +68,7 @@ import { createLogger } from '../common/logger' import { createUserCsr, getPubKey, loadPrivateKey, pubKeyFromCsr } from '@quiet/identity' import { config } from '@quiet/state-manager' import { SigChainService } from '../auth/sigchain.service' +import { Base58, InviteResult } from '3rd-party/auth/packages/auth/dist' @Injectable() export class ConnectionsManagerService extends EventEmitter implements OnModuleInit { @@ -589,7 +587,12 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI this.logger.error('Community name is required to create sigchain') return community } - this.sigChainService.createChain(community.name, identity.nickname, true) + + this.logger.info(`Creating new LFA chain`) + await this.sigChainService.createChain(community.name, identity.nickname, true) + // this is the forever invite that all users get + this.logger.info(`Creating long lived LFA invite code`) + this.socketService.emit(SocketActionTypes.CREATE_LONG_LIVED_LFA_INVITE) return community } @@ -908,13 +911,56 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI callback(await this.leaveCommunity()) }) + // Local First Auth + + this.socketService.on( + SocketActionTypes.CREATE_LONG_LIVED_LFA_INVITE, + async (callback?: (response: InviteResult | undefined) => void) => { + this.logger.info(`socketService - ${SocketActionTypes.CREATE_LONG_LIVED_LFA_INVITE}`) + if (this.sigChainService.activeChainTeamName != null) { + const invite = this.sigChainService.getActiveChain().invites.createLongLivedUserInvite() + this.serverIoProvider.io.emit(SocketActionTypes.CREATED_LONG_LIVED_LFA_INVITE, invite) + if (callback) callback(invite) + } else { + this.logger.warn(`No sigchain configured, skipping long lived LFA invite code generation!`) + if (callback) callback(undefined) + } + } + ) + + this.socketService.on( + SocketActionTypes.VALIDATE_OR_CREATE_LONG_LIVED_LFA_INVITE, + async ( + inviteId: Base58, + callback: (response: { isValid: boolean; newInvite?: InviteResult } | undefined) => void + ) => { + this.logger.info(`socketService - ${SocketActionTypes.VALIDATE_OR_CREATE_LONG_LIVED_LFA_INVITE}`) + if (this.sigChainService.activeChainTeamName != null) { + if (this.sigChainService.getActiveChain().invites.isValidLongLivedUserInvite(inviteId)) { + this.logger.info(`Invite is a valid long lived LFA invite code!`) + callback({ isValid: true }) + } else { + this.logger.info(`Invite is an invalid long lived LFA invite code! Generating a new code!`) + const newInvite = this.sigChainService.getActiveChain().invites.createLongLivedUserInvite() + this.serverIoProvider.io.emit(SocketActionTypes.CREATED_LONG_LIVED_LFA_INVITE, newInvite) + callback({ isValid: false, newInvite }) + } + } else { + this.logger.warn(`No sigchain configured, skipping long lived LFA invite code validation/generation!`) + callback(undefined) + } + } + ) + // Username registration + this.socketService.on(SocketActionTypes.ADD_CSR, async (payload: SaveCSRPayload) => { this.logger.info(`socketService - ${SocketActionTypes.ADD_CSR}`) await this.storageService?.saveCSR(payload) }) // Public Channels + this.socketService.on( SocketActionTypes.CREATE_CHANNEL, async (args: CreateChannelPayload, callback: (response?: CreateChannelResponse) => void) => { diff --git a/packages/backend/src/nest/socket/socket.service.ts b/packages/backend/src/nest/socket/socket.service.ts index 41e95ea66d..bfa31bc5b3 100644 --- a/packages/backend/src/nest/socket/socket.service.ts +++ b/packages/backend/src/nest/socket/socket.service.ts @@ -25,6 +25,7 @@ import { ConfigOptions, ServerIoProviderTypes } from '../types' import { suspendableSocketEvents } from './suspendable.events' import { createLogger } from '../common/logger' import type net from 'node:net' +import { Base58, InviteResult } from '@localfirst/auth' @Injectable() export class SocketService extends EventEmitter implements OnModuleInit { @@ -205,6 +206,29 @@ export class SocketService extends EventEmitter implements OnModuleInit { this.emit(SocketActionTypes.SET_USER_PROFILE, profile) }) + // ====== Local First Auth ====== + + socket.on( + SocketActionTypes.CREATE_LONG_LIVED_LFA_INVITE, + async (callback: (response: InviteResult | undefined) => void) => { + this.logger.info(`Creating long lived LFA invite code`) + this.emit(SocketActionTypes.CREATE_LONG_LIVED_LFA_INVITE, callback) + } + ) + + socket.on( + SocketActionTypes.VALIDATE_OR_CREATE_LONG_LIVED_LFA_INVITE, + async (inviteId: Base58, callback: (response: InviteResult | undefined) => void) => { + this.logger.info(`Validating long lived LFA invite with ID ${inviteId} or creating a new one`) + this.emit(SocketActionTypes.VALIDATE_OR_CREATE_LONG_LIVED_LFA_INVITE, inviteId, callback) + } + ) + + socket.on(SocketActionTypes.CREATED_LONG_LIVED_LFA_INVITE, (invite: InviteResult) => { + this.logger.info(`Created new long lived LFA invite code with id ${invite.id}`) + this.emit(SocketActionTypes.CREATED_LONG_LIVED_LFA_INVITE, invite) + }) + // ====== Misc ====== socket.on(SocketActionTypes.LOAD_MIGRATION_DATA, async (data: Record) => { diff --git a/packages/common/src/invitationLink/invitationLink.ts b/packages/common/src/invitationLink/invitationLink.ts index ce19397cb9..39f4bb10e4 100644 --- a/packages/common/src/invitationLink/invitationLink.ts +++ b/packages/common/src/invitationLink/invitationLink.ts @@ -239,6 +239,7 @@ const composeInvitationUrl = (baseUrl: string, data: InvitationDataV1 | Invitati url.searchParams.append(PSK_PARAM_KEY, data.psk) url.searchParams.append(OWNER_ORBIT_DB_IDENTITY_PARAM_KEY, data.ownerOrbitDbIdentity) url.searchParams.append(AUTH_DATA_KEY, encodeAuthData(data.authData)) + break } return url.href } diff --git a/packages/desktop/src/renderer/components/Settings/Tabs/Invite/Invite.component.test.tsx b/packages/desktop/src/renderer/components/Settings/Tabs/Invite/Invite.component.test.tsx index 3802f9669e..14f1a2a742 100644 --- a/packages/desktop/src/renderer/components/Settings/Tabs/Invite/Invite.component.test.tsx +++ b/packages/desktop/src/renderer/components/Settings/Tabs/Invite/Invite.component.test.tsx @@ -236,4 +236,244 @@ describe('CopyLink', () => { `) }) + + it('renderComponent - v2 - hidden long link', () => { + const invitationLink = composeInvitationShareUrl({ + pairs: [ + { + peerId: 'QmVTkUad2Gq3MkCa8gf12R1gsWDfk2yiTEqb6YGXDG2iQ3', + onionAddress: 'p3oqdr53dkgg3n5nuezlzyawhxvit5efxzlunvzp7n7lmva6fj3i43ad', + }, + { + peerId: 'Qmd2Un9AynokZrcZGsMuaqgupTtidHGQnUkNVfFFAef97C', + onionAddress: 'vnywuiyl7p7ig2murcscdyzksko53e4k3dpdm2yoopvvu25p6wwjqbad', + }, + { + peerId: 'QmXRY4rhAx8Muq8dMGkr9qknJdE6UHZDdGaDRTQEbwFN5b', + onionAddress: '6vu2bxki777it3cpayv6fq6vpl4ke3kzj7gxicfygm55dhhtphyfdvyd', + }, + { + peerId: 'QmT18UvnUBkseMc3SqnfPxpHwN8nzLrJeNSLZtc8rAFXhz', + onionAddress: 'y7yczmugl2tekami7sbdz5pfaemvx7bahwthrdvcbzw5vex2crsr26qd', + }, + ], + psk: '123435', + ownerOrbitDbIdentity: 'testOwnerOrbitDbIdentity', + authData: { + seed: '5ah8uYodiwuwVybT', + communityName: 'name', + }, + }) + const result = renderComponent( + + ) + expect(result.baseElement).toMatchInlineSnapshot(` + +
+
+
+
+

+ Add Members +

+
+
+
+
+
+ Your community link +
+
+
+

+ Anyone with Quiet app can follow this link to join this community. +
+ Only share with people you trust. +

+ +
+
+
+ +
+
+
+ + `) + }) + + it('renderComponent - v2 - revealed short link', () => { + const invitationLink = composeInvitationShareUrl({ + pairs: [ + { + peerId: 'QmVTkUad2Gq3MkCa8gf12R1gsWDfk2yiTEqb6YGXDG2iQ3', + onionAddress: 'p3oqdr53dkgg3n5nuezlzyawhxvit5efxzlunvzp7n7lmva6fj3i43ad', + }, + ], + psk: '12345', + ownerOrbitDbIdentity: 'testOwnerOrbitDbIdentity', + authData: { + seed: '5ah8uYodiwuwVybT', + communityName: 'name', + }, + }) + const result = renderComponent( + + ) + expect(result.baseElement).toMatchInlineSnapshot(` + +
+
+
+
+

+ Add Members +

+
+
+
+
+
+ Your community link +
+
+
+

+ Anyone with Quiet app can follow this link to join this community. +
+ Only share with people you trust. +

+ +
+
+
+ +
+
+
+ + `) + }) }) diff --git a/packages/desktop/src/renderer/components/Settings/Tabs/Invite/Invite.tsx b/packages/desktop/src/renderer/components/Settings/Tabs/Invite/Invite.tsx index c9bc95c8d4..1f95b581a8 100644 --- a/packages/desktop/src/renderer/components/Settings/Tabs/Invite/Invite.tsx +++ b/packages/desktop/src/renderer/components/Settings/Tabs/Invite/Invite.tsx @@ -1,20 +1,43 @@ -import React, { FC, useState } from 'react' -import { useSelector } from 'react-redux' -import { connection } from '@quiet/state-manager' +import React, { FC, useEffect, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { DateTime } from 'luxon' + +import { communities, connection } from '@quiet/state-manager' + import { InviteComponent } from './Invite.component' +import { createLogger } from '../../../../logger' + +const LOGGER = createLogger('Invite') export const Invite: FC = () => { - const invitationLink = useSelector(connection.selectors.invitationUrl) + LOGGER.info('Creating invite') + const dispatch = useDispatch() const [revealInputValue, setRevealInputValue] = useState(false) - const handleClickInputReveal = () => { revealInputValue ? setRevealInputValue(false) : setRevealInputValue(true) } + const inviteLink = useSelector(connection.selectors.invitationUrl) + const [invitationLink, setInvitationLink] = useState(inviteLink) + const [invitationReady, setInvitationReady] = useState(false) + useEffect(() => { + LOGGER.info('Generating invite code') + dispatch(connection.actions.createInvite({})) + LOGGER.info('Done generating invite code') + setInvitationReady(true) + }, []) + + useEffect(() => { + if (invitationReady) { + LOGGER.info(`Generating invitation URL using generated LFA code`) + setInvitationLink(inviteLink) + } + }, [invitationReady, inviteLink]) + return ( diff --git a/packages/desktop/src/renderer/components/Settings/Tabs/QRCode/QRCode.tsx b/packages/desktop/src/renderer/components/Settings/Tabs/QRCode/QRCode.tsx index de578f5cd9..dfc8b2ed4e 100644 --- a/packages/desktop/src/renderer/components/Settings/Tabs/QRCode/QRCode.tsx +++ b/packages/desktop/src/renderer/components/Settings/Tabs/QRCode/QRCode.tsx @@ -1,11 +1,26 @@ -import React from 'react' -import { useSelector } from 'react-redux' +import React, { useEffect, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' + +import { Site } from '@quiet/common' import { connection } from '@quiet/state-manager' import { QRCodeComponent } from './QRCode.component' -import { Site } from '@quiet/common' export const QRCode: React.FC = () => { - const invitationLink = useSelector(connection.selectors.invitationUrl) || Site.MAIN_PAGE + const dispatch = useDispatch() + const inviteLink = useSelector(connection.selectors.invitationUrl) + const [invitationLink, setInvitationLink] = useState(inviteLink) + const [invitationReady, setInvitationReady] = useState(false) + useEffect(() => { + dispatch(connection.actions.createInvite({})) + setInvitationReady(true) + }, []) + + useEffect(() => { + if (invitationReady) { + setInvitationLink(inviteLink || Site.MAIN_PAGE) + } + }, [invitationReady, inviteLink]) + return } diff --git a/packages/desktop/src/renderer/components/ui/OpenlinkModal/OpenlinkModal.test.tsx b/packages/desktop/src/renderer/components/ui/OpenlinkModal/OpenlinkModal.test.tsx index 3710296e72..65458abdda 100644 --- a/packages/desktop/src/renderer/components/ui/OpenlinkModal/OpenlinkModal.test.tsx +++ b/packages/desktop/src/renderer/components/ui/OpenlinkModal/OpenlinkModal.test.tsx @@ -173,7 +173,7 @@ describe('OpenlinkModal', () => { - www.tryquiet.org + tryquiet.org - I trust them with my data and I'm not using Quiet for anonymity protection. @@ -215,7 +215,7 @@ describe('OpenlinkModal', () => { - www.tryquiet.org + tryquiet.org again, but don't auto-load images. @@ -281,7 +281,7 @@ describe('OpenlinkModal', () => { href="" style="color: rgb(103, 191, 211); text-decoration: none; word-break: break-all;" > - Load image from site www.tryquiet.org + Load image from site tryquiet.org diff --git a/packages/desktop/src/renderer/sagas/invitation/customProtocol.saga.ts b/packages/desktop/src/renderer/sagas/invitation/customProtocol.saga.ts index aa02f8f139..119b73fab1 100644 --- a/packages/desktop/src/renderer/sagas/invitation/customProtocol.saga.ts +++ b/packages/desktop/src/renderer/sagas/invitation/customProtocol.saga.ts @@ -77,15 +77,18 @@ export function* customProtocolSaga( let isJoiningAnotherCommunity = false + let storedPsk: string | undefined = undefined + let currentPsk: string | undefined = undefined switch (data.version) { case InvitationDataVersion.v1: - const storedPsk = yield* select(communities.selectors.psk) - const currentPsk = data.psk + storedPsk = yield* select(communities.selectors.psk) + currentPsk = data.psk isJoiningAnotherCommunity = Boolean(storedPsk && storedPsk !== currentPsk) break - case InvitationDataVersion.v2: - const inviteData = yield* select(communities.selectors.inviteData) - isJoiningAnotherCommunity = Boolean(inviteData && !_.isEqual(inviteData, data)) + case InvitationDataVersion.v2: // Question: should we also check if the sig chain team name is different or something? is the psk enough? + storedPsk = yield* select(communities.selectors.psk) + currentPsk = data.psk + isJoiningAnotherCommunity = Boolean(storedPsk && storedPsk !== currentPsk) break } diff --git a/packages/mobile/src/components/ContextMenu/menus/InvitationContextMenu.container.tsx b/packages/mobile/src/components/ContextMenu/menus/InvitationContextMenu.container.tsx index 8d1d43f1a4..8735407564 100644 --- a/packages/mobile/src/components/ContextMenu/menus/InvitationContextMenu.container.tsx +++ b/packages/mobile/src/components/ContextMenu/menus/InvitationContextMenu.container.tsx @@ -1,22 +1,18 @@ -import React, { FC, useCallback, useEffect } from 'react' +import React, { FC, useCallback, useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { Share } from 'react-native' - import Clipboard from '@react-native-clipboard/clipboard' import { connection } from '@quiet/state-manager' import { navigationSelectors } from '../../../store/navigation/navigation.selectors' - import { useConfirmationBox } from '../../../hooks/useConfirmationBox' import { useContextMenu } from '../../../hooks/useContextMenu' import { MenuName } from '../../../const/MenuNames.enum' import { ContextMenu } from '../ContextMenu.component' import { ContextMenuItemProps } from '../ContextMenu.types' - import { navigationActions } from '../../../store/navigation/navigation.slice' import { ScreenNames } from '../../../const/ScreenNames.enum' - import { createLogger } from '../../../utils/logger' const logger = createLogger('invitationContextMenu:container') @@ -25,7 +21,20 @@ export const InvitationContextMenu: FC = () => { const dispatch = useDispatch() const screen = useSelector(navigationSelectors.currentScreen) - const invitationLink = useSelector(connection.selectors.invitationUrl) + + const inviteLink = useSelector(connection.selectors.invitationUrl) + const [invitationLink, setInvitationLink] = useState(inviteLink) + const [invitationReady, setInvitationReady] = useState(false) + useEffect(() => { + dispatch(connection.actions.createInvite({})) + setInvitationReady(true) + }, []) + + useEffect(() => { + if (invitationReady) { + setInvitationLink(inviteLink) + } + }, [invitationReady, inviteLink]) const invitationContextMenu = useContextMenu(MenuName.Invitation) @@ -41,7 +50,7 @@ export const InvitationContextMenu: FC = () => { ) const copyLink = async () => { - Clipboard.setString(invitationLink) + Clipboard.setString(invitationLink!) await confirmationBox.flash() } diff --git a/packages/mobile/src/screens/QRCode/QRCode.screen.tsx b/packages/mobile/src/screens/QRCode/QRCode.screen.tsx index a1bed11e28..b92585a26d 100644 --- a/packages/mobile/src/screens/QRCode/QRCode.screen.tsx +++ b/packages/mobile/src/screens/QRCode/QRCode.screen.tsx @@ -1,14 +1,14 @@ -import React, { FC, useCallback, useRef } from 'react' +import React, { FC, useCallback, useEffect, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import Share from 'react-native-share' import SVG from 'react-native-svg' + +import { Site } from '@quiet/common' import { connection } from '@quiet/state-manager' + import { navigationActions } from '../../store/navigation/navigation.slice' import { ScreenNames } from '../../const/ScreenNames.enum' - import { QRCode } from '../../components/QRCode/QRCode.component' -import { Site } from '@quiet/common' - import { createLogger } from '../../utils/logger' const logger = createLogger('qrCode:screen') @@ -18,7 +18,19 @@ export const QRCodeScreen: FC = () => { const svgRef = useRef() - const invitationLink = useSelector(connection.selectors.invitationUrl) || Site.MAIN_PAGE + const inviteLink = useSelector(connection.selectors.invitationUrl) + const [invitationLink, setInvitationLink] = useState(inviteLink) + const [invitationReady, setInvitationReady] = useState(false) + useEffect(() => { + dispatch(connection.actions.createInvite({})) + setInvitationReady(true) + }, []) + + useEffect(() => { + if (invitationReady) { + setInvitationLink(inviteLink || Site.MAIN_PAGE) + } + }, [invitationReady, inviteLink]) const handleBackButton = useCallback(() => { dispatch( @@ -42,5 +54,5 @@ export const QRCodeScreen: FC = () => { }) } - return + return } diff --git a/packages/mobile/src/store/init/deepLink/deepLink.saga.ts b/packages/mobile/src/store/init/deepLink/deepLink.saga.ts index 8c9c57a0bc..d8b3deadc8 100644 --- a/packages/mobile/src/store/init/deepLink/deepLink.saga.ts +++ b/packages/mobile/src/store/init/deepLink/deepLink.saga.ts @@ -80,15 +80,18 @@ export function* deepLinkSaga(action: PayloadAction=8" } }, + "node_modules/@localfirst/auth": { + "resolved": "../../3rd-party/auth/packages/auth/dist", + "link": true + }, "node_modules/@peculiar/asn1-schema": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.6.tgz", @@ -16346,6 +16352,9 @@ } } }, + "@localfirst/auth": { + "version": "file:../../3rd-party/auth/packages/auth/dist" + }, "@peculiar/asn1-schema": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.6.tgz", diff --git a/packages/state-manager/package.json b/packages/state-manager/package.json index 8444211beb..7c1f257527 100644 --- a/packages/state-manager/package.json +++ b/packages/state-manager/package.json @@ -26,6 +26,7 @@ "license": "GPL-3.0-or-later", "dependencies": { "@ipld/dag-cbor": "^6.0.15", + "@localfirst/auth": "file:../../3rd-party/auth/packages/auth/dist", "@quiet/common": "^2.0.2-alpha.1", "@quiet/logger": "^2.0.2-alpha.0", "@quiet/types": "^2.0.2-alpha.1", diff --git a/packages/state-manager/src/sagas/appConnection/connection.master.saga.ts b/packages/state-manager/src/sagas/appConnection/connection.master.saga.ts index 4e1dae288a..218c5751bc 100644 --- a/packages/state-manager/src/sagas/appConnection/connection.master.saga.ts +++ b/packages/state-manager/src/sagas/appConnection/connection.master.saga.ts @@ -1,13 +1,17 @@ -import { all, fork, cancelled } from 'typed-redux-saga' +import { all, fork, cancelled, takeEvery } from 'typed-redux-saga' import { uptimeSaga } from './uptime/uptime.saga' + +import { type Socket } from '../../types' import { createLogger } from '../../utils/logger' +import { connectionActions } from './connection.slice' +import { createInviteSaga } from './invite/createInvite.saga' const logger = createLogger('connectionMasterSaga') -export function* connectionMasterSaga(): Generator { +export function* connectionMasterSaga(socket: Socket): Generator { logger.info('connectionMasterSaga starting') try { - yield all([fork(uptimeSaga)]) + yield all([fork(uptimeSaga), takeEvery(connectionActions.createInvite.type, createInviteSaga, socket)]) } finally { logger.info('connectionMasterSaga stopping') if (yield cancelled()) { diff --git a/packages/state-manager/src/sagas/appConnection/connection.selectors.test.ts b/packages/state-manager/src/sagas/appConnection/connection.selectors.test.ts index 184546cc84..6dcbf5a2a2 100644 --- a/packages/state-manager/src/sagas/appConnection/connection.selectors.test.ts +++ b/packages/state-manager/src/sagas/appConnection/connection.selectors.test.ts @@ -6,8 +6,10 @@ import { connectionSelectors } from './connection.selectors' import { communitiesActions } from '../communities/communities.slice' import { connectionActions } from './connection.slice' import { type FactoryGirl } from 'factory-girl' -import { type Community } from '@quiet/types' +import { InvitationDataVersion, type Community } from '@quiet/types' import { composeInvitationShareUrl, createLibp2pAddress, p2pAddressesToPairs } from '@quiet/common' +import { Base58 } from '3rd-party/auth/packages/crypto/dist' +import { communitiesSelectors } from '../communities/communities.selectors' describe('communitiesSelectors', () => { setupCrypto() @@ -93,7 +95,7 @@ describe('communitiesSelectors', () => { expect(socketIOSecret2).toEqual(secret) }) - it('invitationUrl selector does not break if there is no community', () => { + it('invitationUrl selector does not break if there is no community or long lived invite', () => { const { store } = prepareStore() const invitationUrl = connectionSelectors.invitationUrl(store.getState()) expect(invitationUrl).toEqual('') @@ -136,4 +138,43 @@ describe('communitiesSelectors', () => { const invitationUrl = connectionSelectors.invitationUrl(store.getState()) expect(invitationUrl).toEqual('') }) + + it('invitationUrl selector returns proper v2 url when community and long lived invite are defined', async () => { + const { store } = prepareStore() + const factory = await getFactory(store) + const peerList = [ + createLibp2pAddress( + 'gloao6h5plwjy4tdlze24zzgcxll6upq2ex2fmu2ohhyu4gtys4nrjad', + 'QmZoiJNAvCffeEHBjk766nLuKVdkxkAT7wfFJDPPLsbKSA' + ), + ] + const psk = '12345' + const ownerOrbitDbIdentity = 'testOwnerOrbitDbIdentity' + await factory.create['payload']>('Community', { + peerList, + psk, + ownerOrbitDbIdentity, + }) + store.dispatch( + connectionActions.setLongLivedInvite({ + seed: '5ah8uYodiwuwVybT', + id: '5ah8uYodiwuwVybT' as Base58, + }) + ) + const authData = { + seed: '5ah8uYodiwuwVybT', + communityName: communitiesSelectors.currentCommunity(store.getState())!.name!, + } + const selectorInvitationUrl = connectionSelectors.invitationUrl(store.getState()) + const pairs = p2pAddressesToPairs(peerList) + const expectedUrl = composeInvitationShareUrl({ + pairs, + psk, + ownerOrbitDbIdentity, + authData, + version: InvitationDataVersion.v2, + }) + expect(expectedUrl).not.toEqual('') + expect(selectorInvitationUrl).toEqual(expectedUrl) + }) }) diff --git a/packages/state-manager/src/sagas/appConnection/connection.selectors.ts b/packages/state-manager/src/sagas/appConnection/connection.selectors.ts index e0a6be45c8..2a1d4858ae 100644 --- a/packages/state-manager/src/sagas/appConnection/connection.selectors.ts +++ b/packages/state-manager/src/sagas/appConnection/connection.selectors.ts @@ -6,12 +6,12 @@ import { peersStatsAdapter } from './connection.adapter' import { connectedPeers, isCurrentCommunityInitialized } from '../network/network.selectors' import { type NetworkStats } from './connection.types' import { type User } from '../users/users.types' -import { composeInvitationShareUrl, filterAndSortPeers, p2pAddressesToPairs, pairsToP2pAddresses } from '@quiet/common' +import { composeInvitationShareUrl, filterAndSortPeers, p2pAddressesToPairs } from '@quiet/common' import { areMessagesLoaded, areChannelsLoaded } from '../publicChannels/publicChannels.selectors' import { identitySelectors } from '../identity/identity.selectors' import { communitiesSelectors } from '../communities/communities.selectors' -import { InvitationDataVersion } from '@quiet/types' import { createLogger } from '../../utils/logger' +import { InvitationData, InvitationDataVersion } from '@quiet/types' const logger = createLogger('connectionSelectors') @@ -49,17 +49,38 @@ export const peerList = createSelector( } ) +export const longLivedInvite = createSelector(connectionSlice, reducerState => { + return reducerState.longLivedInvite +}) + export const invitationUrl = createSelector( communitiesSelectors.psk, communitiesSelectors.ownerOrbitDbIdentity, + communitiesSelectors.currentCommunity, peerList, - (communityPsk, ownerOrbitDbIdentity, sortedPeerList) => { + longLivedInvite, + (communityPsk, ownerOrbitDbIdentity, currentCommunity, sortedPeerList, longLivedInvite) => { if (!sortedPeerList || sortedPeerList?.length === 0) return '' if (!communityPsk) return '' if (!ownerOrbitDbIdentity) return '' const initialPeers = sortedPeerList.slice(0, 3) const pairs = p2pAddressesToPairs(initialPeers) - return composeInvitationShareUrl({ pairs, psk: communityPsk, ownerOrbitDbIdentity }) + let inviteData: InvitationData = { pairs, psk: communityPsk, ownerOrbitDbIdentity } + if (currentCommunity != null && currentCommunity.name != null && longLivedInvite != null) { + inviteData = { + ...inviteData, + version: InvitationDataVersion.v2, + authData: { + communityName: currentCommunity.name, + seed: longLivedInvite.seed, + }, + } + } else { + logger.warn( + `Community and/or LFA invite data is missing, can't create V2 invite link! \nCommunity non-null? ${currentCommunity != null} \nCommunity name non-null? ${currentCommunity?.name != null} \nLFA invite data non-null? ${longLivedInvite != null}` + ) + } + return composeInvitationShareUrl(inviteData) } ) @@ -94,6 +115,7 @@ export const connectionSelectors = { connectedPeersMapping, peerList, invitationUrl, + longLivedInvite, torBootstrapProcess, connectionProcess, isTorInitialized, diff --git a/packages/state-manager/src/sagas/appConnection/connection.slice.ts b/packages/state-manager/src/sagas/appConnection/connection.slice.ts index a5519825b3..cea91d76c6 100644 --- a/packages/state-manager/src/sagas/appConnection/connection.slice.ts +++ b/packages/state-manager/src/sagas/appConnection/connection.slice.ts @@ -2,6 +2,7 @@ import { createSlice, type EntityState, type PayloadAction } from '@reduxjs/tool import { StoreKeys } from '../store.keys' import { peersStatsAdapter } from './connection.adapter' import { ConnectionProcessInfo, type NetworkDataPayload, type NetworkStats } from '@quiet/types' +import { InviteResult } from '@localfirst/auth' export class ConnectionState { public lastConnectedTime = 0 @@ -14,6 +15,7 @@ export class ConnectionState { number: 5, text: ConnectionProcessInfo.CONNECTION_STARTED, } + public longLivedInvite: InviteResult | undefined = undefined } export const connectionSlice = createSlice({ @@ -48,6 +50,9 @@ export const connectionSlice = createSlice({ setTorInitialized: state => { state.isTorInitialized = true }, + setLongLivedInvite: (state, action: PayloadAction) => { + state.longLivedInvite = action.payload + }, setSocketIOSecret: (state, action: PayloadAction) => { state.socketIOSecret = action.payload }, @@ -73,6 +78,7 @@ export const connectionSlice = createSlice({ break } }, + createInvite: (state, _action: PayloadAction) => state, }, }) diff --git a/packages/state-manager/src/sagas/appConnection/invite/createInvite.saga.test.ts b/packages/state-manager/src/sagas/appConnection/invite/createInvite.saga.test.ts new file mode 100644 index 0000000000..bf712806b0 --- /dev/null +++ b/packages/state-manager/src/sagas/appConnection/invite/createInvite.saga.test.ts @@ -0,0 +1,135 @@ +import { combineReducers } from '@reduxjs/toolkit' +import { expectSaga } from 'redux-saga-test-plan' +import { prepareStore } from '../../../utils/tests/prepareStore' +import { connectionActions } from '../connection.slice' +import { setupCrypto } from '@quiet/identity' +import { reducers } from '../../reducers' +import { createInviteSaga } from './createInvite.saga' +import { SocketActionTypes } from '@quiet/types' +import { Socket } from '../../../types' +import { longLivedInvite } from '../connection.selectors' +import { Base58 } from '3rd-party/auth/packages/crypto/dist' + +describe('createInvite', () => { + it('create invite sets nothing when no sig chain is configured', async () => { + setupCrypto() + + const socket = { + emit: jest.fn(), + emitWithAck: jest.fn(() => { + return {} + }), + on: jest.fn(), + } as unknown as Socket + + const store = prepareStore().store + + const reducer = combineReducers(reducers) + await expectSaga(createInviteSaga, socket, connectionActions.createInvite({})) + .withReducer(reducer) + .withState(store.getState()) + .select(longLivedInvite) + .apply(socket, socket.emitWithAck, [SocketActionTypes.VALIDATE_OR_CREATE_LONG_LIVED_LFA_INVITE, undefined]) + .run() + }) + + it('create invite sets nothing when the long lived invite is already valid', async () => { + setupCrypto() + + const socket = { + emit: jest.fn(), + emitWithAck: jest.fn(() => { + return { + valid: true, + } + }), + on: jest.fn(), + } as unknown as Socket + + const store = prepareStore().store + + const existingInvite = { + seed: '5ah8uYodiwuwVybT', + id: '5ah8uYodiwuwVybT' as Base58, + } + store.dispatch(connectionActions.setLongLivedInvite(existingInvite)) + + const reducer = combineReducers(reducers) + await expectSaga(createInviteSaga, socket, connectionActions.createInvite({})) + .withReducer(reducer) + .withState(store.getState()) + .select(longLivedInvite) + .apply(socket, socket.emitWithAck, [ + SocketActionTypes.VALIDATE_OR_CREATE_LONG_LIVED_LFA_INVITE, + existingInvite.id, + ]) + .run() + }) +}) + +it('create invite updates long lived invite when the existing invite data is undefined', async () => { + setupCrypto() + + const newInvite = { + seed: '5ah8uYodiwuwVybT', + id: '5ah8uYodiwuwVybT' as Base58, + } + + const socket = { + emit: jest.fn(), + emitWithAck: jest.fn(() => { + return { + valid: false, + newInvite, + } + }), + on: jest.fn(), + } as unknown as Socket + + const store = prepareStore().store + + const reducer = combineReducers(reducers) + await expectSaga(createInviteSaga, socket, connectionActions.createInvite({})) + .withReducer(reducer) + .withState(store.getState()) + .select(longLivedInvite) + .apply(socket, socket.emitWithAck, [SocketActionTypes.VALIDATE_OR_CREATE_LONG_LIVED_LFA_INVITE, undefined]) + .putResolve(connectionActions.setLongLivedInvite(newInvite)) + .run() +}) + +it('create invite updates long lived invite when the existing invite data is defined but invalid', async () => { + setupCrypto() + + const newInvite = { + seed: '5ah8uYodiwuwVybT', + id: '5ah8uYodiwuwVybT' as Base58, + } + + const socket = { + emit: jest.fn(), + emitWithAck: jest.fn(() => { + return { + valid: false, + newInvite, + } + }), + on: jest.fn(), + } as unknown as Socket + + const store = prepareStore().store + const existingInvite = { + seed: '8ah8uYodiwuwVyb5', + id: '8ah8uYodiwuwVyb5' as Base58, + } + store.dispatch(connectionActions.setLongLivedInvite(existingInvite)) + + const reducer = combineReducers(reducers) + await expectSaga(createInviteSaga, socket, connectionActions.createInvite({})) + .withReducer(reducer) + .withState(store.getState()) + .select(longLivedInvite) + .apply(socket, socket.emitWithAck, [SocketActionTypes.VALIDATE_OR_CREATE_LONG_LIVED_LFA_INVITE, existingInvite.id]) + .putResolve(connectionActions.setLongLivedInvite(newInvite)) + .run() +}) diff --git a/packages/state-manager/src/sagas/appConnection/invite/createInvite.saga.ts b/packages/state-manager/src/sagas/appConnection/invite/createInvite.saga.ts new file mode 100644 index 0000000000..15a3d7f15e --- /dev/null +++ b/packages/state-manager/src/sagas/appConnection/invite/createInvite.saga.ts @@ -0,0 +1,35 @@ +import { apply, select, putResolve } from 'typed-redux-saga' +import { type PayloadAction } from '@reduxjs/toolkit' +import { InviteResult } from '@localfirst/auth' + +import { SocketActionTypes } from '@quiet/types' + +import { applyEmitParams, type Socket } from '../../../types' +import { connectionActions } from '../connection.slice' +import { connectionSelectors } from '../connection.selectors' +import { createLogger } from '../../../utils/logger' + +const logger = createLogger('connection:invite:createInvite') + +export function* createInviteSaga( + socket: Socket, + action: PayloadAction['payload']> +): Generator { + logger.info('Creating LFA invite code') + logger.info('Getting existing long lived invite code') + const existingLongLivedInvite: InviteResult | undefined = yield* select(connectionSelectors.longLivedInvite) + logger.info('Validating existing long lived invite code') + const lfaInviteData: { valid: boolean; newInvite?: InviteResult } | undefined = yield* apply( + socket, + socket.emitWithAck, + applyEmitParams(SocketActionTypes.VALIDATE_OR_CREATE_LONG_LIVED_LFA_INVITE, existingLongLivedInvite?.id) + ) + if (!lfaInviteData?.valid && lfaInviteData?.newInvite != null) { + logger.info(`Existing long-lived invite was invalid, the invite has been replaced`) + yield* putResolve(connectionActions.setLongLivedInvite(lfaInviteData.newInvite)) + } else if (!lfaInviteData?.valid && lfaInviteData?.newInvite == null) { + logger.warn( + `Existing invalid was missing or undefined and we failed to generate a new one - your sig chain may not be configured!` + ) + } +} diff --git a/packages/state-manager/src/sagas/communities/createNetwork/createNetwork.saga.ts b/packages/state-manager/src/sagas/communities/createNetwork/createNetwork.saga.ts index 24d68fe295..e6da42e451 100644 --- a/packages/state-manager/src/sagas/communities/createNetwork/createNetwork.saga.ts +++ b/packages/state-manager/src/sagas/communities/createNetwork/createNetwork.saga.ts @@ -60,6 +60,7 @@ export function* createNetworkSaga( if (payload.inviteData) { switch (payload.inviteData.version) { + case InvitationDataVersion.v2: // TODO: update to have actual logic https://github.com/TryQuiet/quiet/issues/2628 case InvitationDataVersion.v1: community.psk = payload.inviteData.psk community.ownerOrbitDbIdentity = payload.inviteData.ownerOrbitDbIdentity diff --git a/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts b/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts index 8bbb6fbdc1..04b1ba582b 100644 --- a/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts +++ b/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts @@ -47,6 +47,7 @@ import { import { createLogger } from '../../../utils/logger' import { identitySelectors } from '../../identity/identity.selectors' +import { InviteResult } from '@localfirst/auth' const logger = createLogger('startConnectionSaga') @@ -84,6 +85,8 @@ export function subscribe(socket: Socket) { | ReturnType | ReturnType | ReturnType + | ReturnType + | ReturnType | ReturnType | ReturnType | ReturnType @@ -150,6 +153,12 @@ export function subscribe(socket: Socket) { emit(communitiesActions.updateCommunityData(payload)) }) + // Local First Auth + + socket.on(SocketActionTypes.CREATED_LONG_LIVED_LFA_INVITE, (payload: InviteResult) => { + emit(connectionActions.setLongLivedInvite(payload)) + }) + // Errors socket.on(SocketActionTypes.ERROR, (payload: ErrorPayload) => { // FIXME: It doesn't look like log errors have the red error @@ -213,7 +222,7 @@ export function* useIO(socket: Socket): Generator { fork(communitiesMasterSaga, socket), fork(usersMasterSaga, socket), fork(appMasterSaga, socket), - fork(connectionMasterSaga), + fork(connectionMasterSaga, socket), fork(errorsMasterSaga), ]) } finally { diff --git a/packages/state-manager/src/types.ts b/packages/state-manager/src/types.ts index 22771ea1ae..a247a9b9ad 100644 --- a/packages/state-manager/src/types.ts +++ b/packages/state-manager/src/types.ts @@ -25,6 +25,7 @@ import { type DeleteChannelResponse, type Identity, } from '@quiet/types' +import { InviteResult } from '3rd-party/auth/packages/auth/dist' interface EventsMap { [event: string]: (...args: any[]) => void @@ -61,6 +62,11 @@ export interface EmitEvents { [SocketActionTypes.ADD_CSR]: EmitEvent [SocketActionTypes.SET_USER_PROFILE]: EmitEvent [SocketActionTypes.LOAD_MIGRATION_DATA]: EmitEvent> + // ====== Local First Auth ====== + [SocketActionTypes.VALIDATE_OR_CREATE_LONG_LIVED_LFA_INVITE]: EmitEvent< + string, + (response: { valid: boolean; newInvite?: InviteResult }) => void + > } export type Socket = IOSocket diff --git a/packages/types/src/socket.ts b/packages/types/src/socket.ts index af1bf3f763..6b89ce5ae5 100644 --- a/packages/types/src/socket.ts +++ b/packages/types/src/socket.ts @@ -65,6 +65,12 @@ export enum SocketActionTypes { CSRS_STORED = 'csrsStored', REGISTER_USER_CERTIFICATE = 'registerUserCertificate', + // ====== Local First Auth ====== + + CREATE_LONG_LIVED_LFA_INVITE = 'createLongLivedLfaInvite', + VALIDATE_OR_CREATE_LONG_LIVED_LFA_INVITE = 'validateOrCreateLongLivedLfaInvite', + CREATED_LONG_LIVED_LFA_INVITE = 'createdLongLivedLfaInvite', + // ====== Network ====== CLOSE = 'close',