From 5d50aa56f986c64356a32a4c64427871731a14cb Mon Sep 17 00:00:00 2001 From: philschatz <253202+philschatz@users.noreply.github.com> Date: Tue, 29 Dec 2020 22:39:28 -0600 Subject: [PATCH 1/7] :art: fix lint errors and turn on toolbar --- examples/grid/tsconfig.json | 2 +- examples/todo/src/components/App.tsx | 8 +++++++- examples/toolbar/src/components/Status.tsx | 10 ++++++++-- examples/toolbar/tsconfig.json | 2 +- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/examples/grid/tsconfig.json b/examples/grid/tsconfig.json index abdbecc9..10f42946 100644 --- a/examples/grid/tsconfig.json +++ b/examples/grid/tsconfig.json @@ -5,7 +5,7 @@ "baseUrl": "src", "esModuleInterop": true, "importHelpers": true, - "jsx": "react-jsx", + "jsx": "react", "lib": ["dom", "esnext"], "moduleResolution": "node", "noEmit": true, diff --git a/examples/todo/src/components/App.tsx b/examples/todo/src/components/App.tsx index 4c60b1bf..5f476c0c 100644 --- a/examples/todo/src/components/App.tsx +++ b/examples/todo/src/components/App.tsx @@ -1,9 +1,12 @@ import { randomDiscoveryKey } from 'lib/randomName' -import React from 'react' +import React, { useState } from 'react' +import Redux from 'redux' import { Provider } from 'react-redux' import { useQueryParam } from 'use-query-params' +import { Toolbar } from '@localfirst/toolbar' import { Todos } from '.' import { useStore } from '../redux/useStore' +import { storeManager } from '../redux/store' export const App: React.FC = () => { const [key, setKey] = useQueryParam('key') @@ -15,9 +18,12 @@ export const App: React.FC = () => { } const appStore = useStore(key, generateNewKey) + const [_, setAppStore] = useState() + const onStoreReady = (store: Redux.Store) => setAppStore(store) return appStore ? ( + ) : null diff --git a/examples/toolbar/src/components/Status.tsx b/examples/toolbar/src/components/Status.tsx index ea682d1c..bc5414d7 100644 --- a/examples/toolbar/src/components/Status.tsx +++ b/examples/toolbar/src/components/Status.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource @emotion/react */ import { CLOSE, OPEN, PEER, PEER_REMOVE, StoreManager } from '@localfirst/state' -import { useEffect, useState } from 'react' +import React, { useEffect, useState } from 'react' import { Group } from './Group' import { StatusLight } from './StatusLight' @@ -50,12 +50,18 @@ export const Status = ({ storeManager }: StatusProps) => { ? `one peer is connected` : `${peers.length} other peers are connected` const statusMessage = online ? `online; ${peerCountMessage}` : 'offline' + + const peerItems = peers.map(p =>
  • {p}
  • ) + return ( +
      + {peerItems} +
    ) } diff --git a/examples/toolbar/tsconfig.json b/examples/toolbar/tsconfig.json index f98679cd..1b9e856f 100644 --- a/examples/toolbar/tsconfig.json +++ b/examples/toolbar/tsconfig.json @@ -6,7 +6,7 @@ "composite": true, "declaration": true, "esModuleInterop": true, - "jsx": "react-jsx", + "jsx": "react", "lib": ["dom", "esnext"], "module": "commonjs", "moduleResolution": "node", From dd0f0aac5923c38c501df8a4b4c3a2aecfce7cbe Mon Sep 17 00:00:00 2001 From: philschatz <253202+philschatz@users.noreply.github.com> Date: Wed, 30 Dec 2020 10:50:34 -0600 Subject: [PATCH 2/7] :tada: invitations work using prompt() Not integrated into the UI yet --- examples/toolbar/src/components/Toolbar.tsx | 2 +- packages/state/package.json | 3 +- packages/state/src/Connection.test.ts | 12 ++- packages/state/src/Connection.ts | 45 ++++++++--- packages/state/src/ConnectionManager.ts | 90 ++++++++++++++++++++- packages/state/src/StoreManager.ts | 36 ++++++++- packages/state/src/Synchronizer.ts | 4 +- yarn.lock | 11 +++ 8 files changed, 183 insertions(+), 20 deletions(-) diff --git a/examples/toolbar/src/components/Toolbar.tsx b/examples/toolbar/src/components/Toolbar.tsx index 13fc825d..0de6c139 100644 --- a/examples/toolbar/src/components/Toolbar.tsx +++ b/examples/toolbar/src/components/Toolbar.tsx @@ -19,7 +19,7 @@ export const Toolbar = ({ children, }: React.PropsWithChildren>) => { // Hooks - const [discoveryKey, setDiscoveryKey] = useQueryParam('id', StringParam) + const [discoveryKey, setDiscoveryKey] = useQueryParam('key', StringParam) const [, setAppStore] = useState() const [busy, setBusy] = useState(false) diff --git a/packages/state/package.json b/packages/state/package.json index 81482285..225ed239 100644 --- a/packages/state/package.json +++ b/packages/state/package.json @@ -28,7 +28,8 @@ "fast-memoize": "2", "immutable": "4.0.0-rc.12", "redux-devtools-extension": "2", - "scryptsy": "2" + "scryptsy": "2", + "taco-js": "github:philschatz/auth#fix-invite-seed" }, "devDependencies": { "@localfirst/relay": "^1.0.7", diff --git a/packages/state/src/Connection.test.ts b/packages/state/src/Connection.test.ts index 02dbcc61..4a280c41 100644 --- a/packages/state/src/Connection.test.ts +++ b/packages/state/src/Connection.test.ts @@ -2,6 +2,7 @@ import A from 'automerge' import { Repo } from './Repo' import { Server } from '@localfirst/relay' import { newid } from '@localfirst/relay-client' +import * as Auth from 'taco-js' import { Connection } from './Connection' import { WebSocket } from 'mock-socket' @@ -33,6 +34,13 @@ describe('Connection', () => { let repo: Repo let server: Server + const user = Auth.createUser({ + userName: 'Alice', + deviceName: 'Laptop', + deviceType: 1 + }) + const team = Auth.createTeam('dream', {user}) + beforeAll(async () => { server = new Server({ port }) await server.listen({ silent: true }) @@ -58,7 +66,7 @@ describe('Connection', () => { it('should send messages to the peer when local state changes', async () => { const peer = new WebSocket(url) - const _ = new Connection(repo, peer, fakeDispatch) + const _ = new Connection(team, repo, peer, fakeDispatch) await _yield() expect(peer.send).toHaveBeenCalled() @@ -71,7 +79,7 @@ describe('Connection', () => { it('should call close on peer when close is called', () => { const peer = new WebSocket(url) - const connection = new Connection(repo, peer, fakeDispatch) + const connection = new Connection(team, repo, peer, fakeDispatch) connection.close() expect(peer.close).toHaveBeenCalled() }) diff --git a/packages/state/src/Connection.ts b/packages/state/src/Connection.ts index 42342647..1dd3796b 100644 --- a/packages/state/src/Connection.ts +++ b/packages/state/src/Connection.ts @@ -1,6 +1,7 @@ import debug from 'debug' import { EventEmitter } from 'events' import { AnyAction, Dispatch } from 'redux' +import * as Auth from 'taco-js' import { RECEIVE_MESSAGE_FROM_PEER } from './constants' import { Repo } from './Repo' import { Synchronizer } from './Synchronizer' @@ -14,14 +15,16 @@ const log = debug('lf:connection') * networking stack and with the Redux store. */ export class Connection extends EventEmitter { + private team: Auth.Team private Synchronizer: Synchronizer private peerSocket: WebSocket | null private dispatch: Dispatch private repo: Repo - constructor(repo: Repo, peerSocket: WebSocket, dispatch: Dispatch) { + constructor(team: Auth.Team, repo: Repo, peerSocket: WebSocket, dispatch: Dispatch) { super() log('new connection') + this.team = team this.repo = repo this.peerSocket = peerSocket this.dispatch = dispatch @@ -33,19 +36,43 @@ export class Connection extends EventEmitter { } receive = async ({ data }: any) => { - const message = JSON.parse(data.toString()) + let message = JSON.parse(data.toString()) log('receive %o', message) - this.emit('receive', message) - await this.Synchronizer.receive(message) // Synchronizer will update repo directly - // hit the dispatcher to force it to pick up - this.dispatch({ type: RECEIVE_MESSAGE_FROM_PEER }) + if (message.action === 'AUTH:JOIN') { + const proof = message.payload + this.team.admit(proof) + log('admitted user to team') + this.peerSocket?.send(JSON.stringify({action: 'AUTH:ADMITTED', payload: this.team.chain})) + } else { + if (message.action === 'ENCRYPTED') { + message = JSON.parse(this.team.decrypt(message.envelope)) + if (!this.team.verify(message)) { + throw new Error('ERROR! Signed with unknown keys') + } + message = message.contents + } + this.emit('receive', message) + await this.Synchronizer.receive(message) // Synchronizer will update repo directly + // hit the dispatcher to force it to pick up + this.dispatch({ type: RECEIVE_MESSAGE_FROM_PEER }) + } } - send = (message: Message) => { - log('send %o', JSON.stringify(message)) + send = (message: Message, forcePlaintext = false) => { + const enc = { + action: 'ENCRYPTED', + envelope: this.team.encrypt(this.team.sign(message)) + } + if (this.peerSocket) try { - this.peerSocket.send(JSON.stringify(message)) + if (forcePlaintext) { + log('send plaintext %o', JSON.stringify(message)) + this.peerSocket.send(JSON.stringify(message)) + } else { + log('send encrypted %o', JSON.stringify(message)) + this.peerSocket.send(JSON.stringify(enc)) + } } catch { log('tried to send but peer is no longer connected', this.peerSocket) } diff --git a/packages/state/src/ConnectionManager.ts b/packages/state/src/ConnectionManager.ts index bdf69243..86509d26 100644 --- a/packages/state/src/ConnectionManager.ts +++ b/packages/state/src/ConnectionManager.ts @@ -1,10 +1,13 @@ import { Client, newid, Peer } from '@localfirst/relay-client' import debug from 'debug' import { EventEmitter } from 'events' +import A from 'automerge' import * as Redux from 'redux' +import * as Auth from 'taco-js' import { Connection } from './Connection' -import { PEER, OPEN, CLOSE, PEER_REMOVE } from './constants' +import { PEER, OPEN, CLOSE, PEER_REMOVE, MESSAGE } from './constants' import { Repo } from './Repo' +import { TeamSignatureChain } from 'taco-js/dist/chain' const log = debug('lf:connectionmanager') @@ -16,9 +19,11 @@ export class ConnectionManager extends EventEmitter { private connections: { [peerId: string]: Connection } = {} private dispatch: Redux.Dispatch private repo: Repo + private invitationOrTeam: Invitation | Promise - constructor({ repo, dispatch, discoveryKey, urls, clientId = newid() }: ClientOptions) { + constructor({ invitationOrTeam, repo, dispatch, discoveryKey, urls, clientId = newid() }: ClientOptions) { super() + this.invitationOrTeam = invitationOrTeam this.repo = repo this.dispatch = dispatch @@ -31,13 +36,28 @@ export class ConnectionManager extends EventEmitter { this.client.on(CLOSE, () => this.emit(CLOSE)) } - private addPeer = (peer: Peer, discoveryKey: string) => { + private addPeer = async (peer: Peer, discoveryKey: string) => { if (!this.dispatch || !this.repo) return const socket = peer.get(discoveryKey) - if (socket) this.connections[peer.id] = new Connection(this.repo, socket, this.dispatch) + + // Use the team of perform a handshake to get the team + let team: Auth.Team + if (this.invitationOrTeam instanceof Promise) { + console.log('We have a team. So lets use it!') + team = await this.invitationOrTeam + } else { + console.log('We do not appear to have a team. What do we have?', this.invitationOrTeam) + // Try to join the team. If it succeeds then we can create a new Connection. + const p = performAuthHandshake(this.invitationOrTeam, socket) + this.invitationOrTeam = p + team = await p + } + + if (socket) this.connections[peer.id] = new Connection(team, this.repo, socket, this.dispatch) peer.on(CLOSE, () => this.removePeer(peer.id)) this.emit(PEER, Object.keys(this.connections)) log('added peer', peer.id) + } private removePeer = (peerId: string) => { @@ -60,10 +80,72 @@ export class ConnectionManager extends EventEmitter { } } +export interface Invitation { + username: string, + invitationSeed: string +} + interface ClientOptions { + invitationOrTeam: Invitation | Promise repo: Repo dispatch: Redux.Dispatch discoveryKey: string urls: string[] clientId?: string } + + +async function performAuthHandshake(invite: Invitation, socket: WebSocket): Promise { + function send(action: string, payload: T) { + socket.send(JSON.stringify({action, payload})) + } + + const proof = Auth.generateProof(invite.invitationSeed, invite.username) + console.log('Sending AUTH:JOIN to peer') + send('AUTH:JOIN', proof) + + return new Promise((resolve, reject) => { + const listener = ({data}: MessageEvent) => { + const message = JSON.parse(data) + const {type, action, payload} = message + if (type === 'HELLO') { + // ignore + return + } + switch(action) { + case 'AUTH:JOIN': + console.log('Well this is awkward. The peer without a Team is being asked to respond to an AUTH:JOIN event. All we can do is ignore it') + break + case 'AUTH:ADMITTED': + // const authHistoryChanges = payload + // const authHistoryDoc = A.applyChanges(A.from({}), authHistoryChanges) + const authHistoryDoc = payload + const user = Auth.createUser({ + userName: invite.username, + deviceName: 'Laptop', + deviceType: 1, // DeviceType.laptop, + seed: invite.invitationSeed + }) + const team = new Auth.Team({ + source: authHistoryDoc as TeamSignatureChain, + context: {user: user} + }) + team.encrypt('Just testing for runtime error. Howdy Everyone! If this fails then BUG???? none of the lockboxes have a publicKey that matches the ephemeral one for this invitee. Ensure that the code for the tempkeys in Auth.Team.join are seeded with "invitationSeed" instead of "this.seed"') + + // Update my keys + team.join(proof) + + // We successfully loaded the team. Resolve promises and remove this authentication listener + ;(window as any).__TEAM_RESOLVE(team) + socket.removeEventListener(MESSAGE, listener) + resolve(team) + break + default: + console.error(message) + throw new Error(`BUG: unsupported message action "${action}"`) + } + } + socket.addEventListener(MESSAGE, listener) + + }) +} \ No newline at end of file diff --git a/packages/state/src/StoreManager.ts b/packages/state/src/StoreManager.ts index 2f0446d8..5bc25e3c 100644 --- a/packages/state/src/StoreManager.ts +++ b/packages/state/src/StoreManager.ts @@ -5,7 +5,8 @@ import debug from 'debug' import { EventEmitter } from 'events' import { applyMiddleware, createStore, Middleware, Store } from 'redux' import { composeWithDevTools } from 'redux-devtools-extension' -import { ConnectionManager } from './ConnectionManager' +import * as Auth from 'taco-js' +import { ConnectionManager, Invitation } from './ConnectionManager' import { CLOSE, DEFAULT_RELAYS, OPEN, PEER, PEER_REMOVE } from './constants' import { getMiddleware } from './getMiddleware' import { getReducer } from './getReducer' @@ -96,8 +97,41 @@ export class StoreManager extends EventEmitter { // Create Redux store to expose to app this.store = this.createReduxStore(state) + let invitationOrTeam: Invitation | Promise + let TEAM_PROMISE: Promise | undefined = (window as any).__TEAM_PROMISE + + if (TEAM_PROMISE) { + invitationOrTeam = TEAM_PROMISE + } else { + + const invitationStr = prompt('Enter Invitation string or leave blank to create a new Team') + if (invitationStr) { + // Promise we will have a team eventually. And squirrel away the resolve function so we can call it when we have the team + ;(window as any).__TEAM_PROMISE = new Promise((resolve) => { (window as any).__TEAM_RESOLVE = resolve }) + + const [username, invitationSeed] = invitationStr.split('+') + invitationOrTeam = {username, invitationSeed} + } else { + // Create a new team + const user = Auth.createUser({ + userName: 'Alice', + deviceName: 'Laptop', + deviceType: 1 + }) + const team = Auth.createTeam('dream', {user}) + invitationOrTeam = Promise.resolve(team) + + ;(window as any).__TEAM_PROMISE = Promise.resolve(team) + + // Generate an invitation and alert the user so they can use it: + const {invitationSeed} = team.invite('Bob') + alert(`Invite using this: Bob+${invitationSeed}`) + } + } + // Connect to discovery server to find peers and sync up with them this.connectionManager = new ConnectionManager({ + invitationOrTeam, discoveryKey, dispatch: this.store.dispatch, repo: this.repo, diff --git a/packages/state/src/Synchronizer.ts b/packages/state/src/Synchronizer.ts index 391a378f..ea029be9 100644 --- a/packages/state/src/Synchronizer.ts +++ b/packages/state/src/Synchronizer.ts @@ -44,7 +44,7 @@ import { Clock, ClockMap, RepoHistory, RepoSnapshot } from './types' */ export class Synchronizer { public repo: Repo - private send: (msg: Message) => void + private send: (msg: Message, forcePlaintext?: boolean) => void private theirClock: ClockMap private isOpen = false private log: debug.Debugger @@ -171,7 +171,7 @@ export class Synchronizer { private async sendHello() { const documentCount = this.repo.count this.log('sending hello', documentCount) - this.send({ type: message.HELLO, documentCount }) + this.send({ type: message.HELLO, documentCount }, true/*forcePlaintext*/) } /** Checks whether we have more recent information than they do; if so, sends changes */ diff --git a/yarn.lock b/yarn.lock index 8f0d46c0..af57feb7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17712,6 +17712,17 @@ taco-js@0: ramda "0" xstate "4" +"taco-js@github:philschatz/auth#fix-invite-seed": + version "0.2.1" + resolved "https://codeload.github.com/philschatz/auth/tar.gz/7edcf68dd66eece6c18bf82cb82bbb856ab9874f" + dependencies: + "@herbcaudill/crypto" "0" + debug "4" + fast-memoize "2" + msgpack-lite "0" + ramda "0" + xstate "4" + tailwindcss@1, tailwindcss@^1.7.6: version "1.9.6" resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-1.9.6.tgz#0c5089911d24e1e98e592a31bfdb3d8f34ecf1a0" From a4324bfd3390f61412c383a7b77198aef1a8c1bd Mon Sep 17 00:00:00 2001 From: philschatz <253202+philschatz@users.noreply.github.com> Date: Sat, 2 Jan 2021 17:19:51 -0600 Subject: [PATCH 3/7] :tada: authentication works! but still uses prompt --- examples/toolbar/package.json | 2 +- examples/toolbar/src/components/Toolbar.tsx | 2 + packages/state/package.json | 2 +- packages/state/src/Connection.ts | 18 +- packages/state/src/ConnectionManager.ts | 74 +------- packages/state/src/StoreManager.ts | 51 +++--- packages/state/src/TeamManager.ts | 186 ++++++++++++++++++++ packages/state/src/types.ts | 5 + yarn.lock | 16 +- 9 files changed, 251 insertions(+), 105 deletions(-) create mode 100644 packages/state/src/TeamManager.ts diff --git a/examples/toolbar/package.json b/examples/toolbar/package.json index e953235c..836e0ff8 100644 --- a/examples/toolbar/package.json +++ b/examples/toolbar/package.json @@ -25,7 +25,7 @@ "react": "17", "react-dom": "^16.0.1", "redux": "4", - "taco-js": "0", + "taco-js": "github:philschatz/auth#975fe719f774b1e46811aa4344b65a0d4cec5a3c", "tailwindcss": "^1.7.6", "use-persisted-state": "0", "use-query-params": "0" diff --git a/examples/toolbar/src/components/Toolbar.tsx b/examples/toolbar/src/components/Toolbar.tsx index 0de6c139..f2f90d49 100644 --- a/examples/toolbar/src/components/Toolbar.tsx +++ b/examples/toolbar/src/components/Toolbar.tsx @@ -39,6 +39,7 @@ export const Toolbar = ({ const newDiscoveryKey = randomDiscoveryKey() setDiscoveryKey(newDiscoveryKey) const newStore = await storeManager.createStore(newDiscoveryKey) + await storeManager.listenToConnections(newDiscoveryKey) setAppStore(newStore) onStoreReady(newStore, newDiscoveryKey) setBusy(false) @@ -52,6 +53,7 @@ export const Toolbar = ({ setBusy(true) setDiscoveryKey(newDiscoveryKey) const newStore = await storeManager.joinStore(newDiscoveryKey) + await storeManager.listenToConnections(newDiscoveryKey) setAppStore(newStore) onStoreReady(newStore, newDiscoveryKey) setBusy(false) diff --git a/packages/state/package.json b/packages/state/package.json index 225ed239..343cd25f 100644 --- a/packages/state/package.json +++ b/packages/state/package.json @@ -29,7 +29,7 @@ "immutable": "4.0.0-rc.12", "redux-devtools-extension": "2", "scryptsy": "2", - "taco-js": "github:philschatz/auth#fix-invite-seed" + "taco-js": "github:philschatz/auth#975fe719f774b1e46811aa4344b65a0d4cec5a3c" }, "devDependencies": { "@localfirst/relay": "^1.0.7", diff --git a/packages/state/src/Connection.ts b/packages/state/src/Connection.ts index 1dd3796b..06205e3e 100644 --- a/packages/state/src/Connection.ts +++ b/packages/state/src/Connection.ts @@ -40,12 +40,22 @@ export class Connection extends EventEmitter { log('receive %o', message) if (message.action === 'AUTH:JOIN') { const proof = message.payload - this.team.admit(proof) - log('admitted user to team') - this.peerSocket?.send(JSON.stringify({action: 'AUTH:ADMITTED', payload: this.team.chain})) + try { + this.team.admit(proof) + log('admitted user to team') + this.peerSocket?.send(JSON.stringify({action: 'AUTH:ADMITTED', payload: this.team.chain})) + } catch(e) { + console.error('Admission to team failed', e) + return + } } else { if (message.action === 'ENCRYPTED') { - message = JSON.parse(this.team.decrypt(message.envelope)) + try { + message = JSON.parse(this.team.decrypt(message.envelope)) + } catch (e) { + console.error(e) // Decryption problem. Log it and move on + return + } if (!this.team.verify(message)) { throw new Error('ERROR! Signed with unknown keys') } diff --git a/packages/state/src/ConnectionManager.ts b/packages/state/src/ConnectionManager.ts index 86509d26..280e7f39 100644 --- a/packages/state/src/ConnectionManager.ts +++ b/packages/state/src/ConnectionManager.ts @@ -7,7 +7,7 @@ import * as Auth from 'taco-js' import { Connection } from './Connection' import { PEER, OPEN, CLOSE, PEER_REMOVE, MESSAGE } from './constants' import { Repo } from './Repo' -import { TeamSignatureChain } from 'taco-js/dist/chain' +import { performAuthHandshake } from './TeamManager' const log = debug('lf:connectionmanager') @@ -19,13 +19,15 @@ export class ConnectionManager extends EventEmitter { private connections: { [peerId: string]: Connection } = {} private dispatch: Redux.Dispatch private repo: Repo - private invitationOrTeam: Invitation | Promise + private invitationOrTeam: Invitation | Auth.Team + private discoveryKey: string constructor({ invitationOrTeam, repo, dispatch, discoveryKey, urls, clientId = newid() }: ClientOptions) { super() this.invitationOrTeam = invitationOrTeam this.repo = repo this.dispatch = dispatch + this.discoveryKey = discoveryKey // TODO: randomly select a URL if more than one is provided? select best based on ping? this.client = new Client({ id: clientId, url: urls[0] }) @@ -40,17 +42,13 @@ export class ConnectionManager extends EventEmitter { if (!this.dispatch || !this.repo) return const socket = peer.get(discoveryKey) - // Use the team of perform a handshake to get the team + // Use the team or perform a handshake to get the team let team: Auth.Team - if (this.invitationOrTeam instanceof Promise) { - console.log('We have a team. So lets use it!') + if (this.invitationOrTeam instanceof Auth.Team) { team = await this.invitationOrTeam } else { - console.log('We do not appear to have a team. What do we have?', this.invitationOrTeam) - // Try to join the team. If it succeeds then we can create a new Connection. - const p = performAuthHandshake(this.invitationOrTeam, socket) - this.invitationOrTeam = p - team = await p + team = await performAuthHandshake(this.repo, this.discoveryKey, this.invitationOrTeam, socket) + this.invitationOrTeam = team } if (socket) this.connections[peer.id] = new Connection(team, this.repo, socket, this.dispatch) @@ -86,7 +84,7 @@ export interface Invitation { } interface ClientOptions { - invitationOrTeam: Invitation | Promise + invitationOrTeam: Invitation | Auth.Team repo: Repo dispatch: Redux.Dispatch discoveryKey: string @@ -95,57 +93,3 @@ interface ClientOptions { } -async function performAuthHandshake(invite: Invitation, socket: WebSocket): Promise { - function send(action: string, payload: T) { - socket.send(JSON.stringify({action, payload})) - } - - const proof = Auth.generateProof(invite.invitationSeed, invite.username) - console.log('Sending AUTH:JOIN to peer') - send('AUTH:JOIN', proof) - - return new Promise((resolve, reject) => { - const listener = ({data}: MessageEvent) => { - const message = JSON.parse(data) - const {type, action, payload} = message - if (type === 'HELLO') { - // ignore - return - } - switch(action) { - case 'AUTH:JOIN': - console.log('Well this is awkward. The peer without a Team is being asked to respond to an AUTH:JOIN event. All we can do is ignore it') - break - case 'AUTH:ADMITTED': - // const authHistoryChanges = payload - // const authHistoryDoc = A.applyChanges(A.from({}), authHistoryChanges) - const authHistoryDoc = payload - const user = Auth.createUser({ - userName: invite.username, - deviceName: 'Laptop', - deviceType: 1, // DeviceType.laptop, - seed: invite.invitationSeed - }) - const team = new Auth.Team({ - source: authHistoryDoc as TeamSignatureChain, - context: {user: user} - }) - team.encrypt('Just testing for runtime error. Howdy Everyone! If this fails then BUG???? none of the lockboxes have a publicKey that matches the ephemeral one for this invitee. Ensure that the code for the tempkeys in Auth.Team.join are seeded with "invitationSeed" instead of "this.seed"') - - // Update my keys - team.join(proof) - - // We successfully loaded the team. Resolve promises and remove this authentication listener - ;(window as any).__TEAM_RESOLVE(team) - socket.removeEventListener(MESSAGE, listener) - resolve(team) - break - default: - console.error(message) - throw new Error(`BUG: unsupported message action "${action}"`) - } - } - socket.addEventListener(MESSAGE, listener) - - }) -} \ No newline at end of file diff --git a/packages/state/src/StoreManager.ts b/packages/state/src/StoreManager.ts index 5bc25e3c..65660dda 100644 --- a/packages/state/src/StoreManager.ts +++ b/packages/state/src/StoreManager.ts @@ -11,7 +11,8 @@ import { CLOSE, DEFAULT_RELAYS, OPEN, PEER, PEER_REMOVE } from './constants' import { getMiddleware } from './getMiddleware' import { getReducer } from './getReducer' import { Repo } from './Repo' -import { ProxyReducer, RepoSnapshot, Snapshot } from './types' +import { ProxyReducer, RepoSnapshot, Snapshot, ensure } from './types' +import { getTeamManager } from './TeamManager' let log = debug('lf:StoreManager') @@ -82,6 +83,11 @@ export class StoreManager extends EventEmitter { private getStore = async (discoveryKey: string, isCreating: boolean = false) => { this.log(`${isCreating ? 'creating' : 'joining'} ${discoveryKey}`) + // userStore and Toolbar both call joinStore and end up creating different Repo instances. Let's just reuse them. + if (this.store) { + return this.store + } + const clientId = localStorage.getItem('clientId') || newid() localStorage.setItem('clientId', clientId) @@ -96,19 +102,20 @@ export class StoreManager extends EventEmitter { const state = await this.repo.init(this.initialState, isCreating) // Create Redux store to expose to app this.store = this.createReduxStore(state) + return this.store + } + + async listenToConnections(discoveryKey: string) { - let invitationOrTeam: Invitation | Promise - let TEAM_PROMISE: Promise | undefined = (window as any).__TEAM_PROMISE + let invitationOrTeam: Invitation | Auth.Team + let maybeTeam: Auth.Team | undefined = await getTeamManager().instantiateTeamIfAvailable(ensure(this.repo), discoveryKey) - if (TEAM_PROMISE) { - invitationOrTeam = TEAM_PROMISE + if (maybeTeam) { + invitationOrTeam = maybeTeam } else { const invitationStr = prompt('Enter Invitation string or leave blank to create a new Team') if (invitationStr) { - // Promise we will have a team eventually. And squirrel away the resolve function so we can call it when we have the team - ;(window as any).__TEAM_PROMISE = new Promise((resolve) => { (window as any).__TEAM_RESOLVE = resolve }) - const [username, invitationSeed] = invitationStr.split('+') invitationOrTeam = {username, invitationSeed} } else { @@ -118,23 +125,29 @@ export class StoreManager extends EventEmitter { deviceName: 'Laptop', deviceType: 1 }) - const team = Auth.createTeam('dream', {user}) - invitationOrTeam = Promise.resolve(team) - - ;(window as any).__TEAM_PROMISE = Promise.resolve(team) - - // Generate an invitation and alert the user so they can use it: - const {invitationSeed} = team.invite('Bob') - alert(`Invite using this: Bob+${invitationSeed}`) + const t = Auth.createTeam('dream', {user}) + const team = await getTeamManager().instantiateTeamDefinitely(ensure(this.repo), discoveryKey, t.chain) + invitationOrTeam = team } } + // Generate an invitation and alert the user so they can use it: + if (invitationOrTeam instanceof Auth.Team) { + if (invitationOrTeam.memberIsAdmin(invitationOrTeam.context.user.userName)) { + const username = `Friend${(new Number(Math.round(Math.random() * 0x10000)).toString())}` + const {invitationSeed} = invitationOrTeam.invite(username) + console.log(`Invite using this:\n${username}+${invitationSeed}`) + alert(`Invite using this:\n${username}+${invitationSeed}`) + } + } + + // Connect to discovery server to find peers and sync up with them this.connectionManager = new ConnectionManager({ invitationOrTeam, discoveryKey, - dispatch: this.store.dispatch, - repo: this.repo, + dispatch: ensure(this.store).dispatch, + repo: ensure(this.repo), urls: this.urls, }) @@ -143,8 +156,6 @@ export class StoreManager extends EventEmitter { target: this, events: [OPEN, CLOSE, PEER, PEER_REMOVE], }) - - return this.store } private createReduxStore(state: RepoSnapshot) { diff --git a/packages/state/src/TeamManager.ts b/packages/state/src/TeamManager.ts new file mode 100644 index 00000000..82e6ca61 --- /dev/null +++ b/packages/state/src/TeamManager.ts @@ -0,0 +1,186 @@ +import { EventEmitter } from 'events' +import A from 'automerge' +import * as Auth from 'taco-js' +import { MESSAGE } from './constants' +import { Repo } from './Repo' + +const pendingSaves: Promise[] = [] + + +// A Team is associated with a discoveryKey and may be in one of the following states: +// - loaded +// - loading from storage +// - invited-to +export class TeamManager { + + private teams: Map? , Invitation?]> = new Map() + + getInvitation(discoveryKey: string) { + const t = this.teams.get(discoveryKey) + return t && t[1] + } + + async instantiateTeamIfAvailable(repo: Repo, discoveryKey: string, invitation?: Invitation): Promise { + let team = this.teams.get(discoveryKey) + if (team) { + return team[0] + } else if (invitation) { + this.teams.set(discoveryKey, [undefined, invitation]) + } else { + // Try loading the team from Storage + const authKey = `${discoveryKey}:AUTH` + if (repo.has(authKey)) { + const state = await repo.get(authKey) + const user = Auth.loadUser() + const team = new Auth.Team({ + source: state, + context: {user} + }) + this.addListener(repo, discoveryKey, team) + return team + } + } + } + + async instantiateTeamDefinitely(repo: Repo, discoveryKey: string, state: any): Promise { + const authKey = `${discoveryKey}:AUTH` + let team = this.teams.get(discoveryKey) + if (team && team[0]) { + throw new Error('BUG: Should not already have a team loaded up') + } else { + // Try loading the team from Storage + repo.set(authKey, A.from(removeUndefined(state))) + const user = Auth.loadUser() + const team = new Auth.Team({ + source: state, + context: {user} + }) + + await this.addTeam(repo, discoveryKey, team) + return team + } + } + + async addTeam(repo: Repo, discoveryKey: string, team: Auth.Team) { + const authKey = `${discoveryKey}:AUTH` + if (this.teams.has(discoveryKey)) { + throw new Error('BUG? seems this already has a Team') + } + this.teams.set(discoveryKey, [team, undefined]) + await repo.set(authKey, A.from(removeUndefined(team.chain))) + this.addListener(repo, discoveryKey, team) + } + + private addListener(repo: Repo, discoveryKey: string, team: Auth.Team) { + const authKey = `${discoveryKey}:AUTH` + + team.on('updated', async ({head: headId}: {head: string}) => { + const headNode = removeUndefined(team.chain.links[headId]) + // Perform saves in order. Otherwise the storage gets corrupted + if (pendingSaves.length > 0) { + await Promise.all(pendingSaves) + pendingSaves.splice(0, pendingSaves.length) // clear when done saving + } + + pendingSaves.push(repo.change(authKey, (doc) => { + if (!doc.root) { + doc.root = headId + } + if (!doc.links) { + doc.links = {} + } + if (!doc.links[headId]) { + doc.head = headId + doc.links[headId] = headNode + // Loop over all the previous nodes and ensure they were added (out of sync updated events) + let curr = headNode + while (true) { + const prevId = curr.body.prev + if (!prevId) { + break + } + const prev = team.chain.links[prevId] + if (!doc.links[prevId]) { + doc.links[prevId] = removeUndefined(prev) + } else { + break // prev node is already in the chain so we can skip it and all the previous ones + } + curr = prev + } + } + })) + }) + } +} + +export interface Invitation { + username: string, + invitationSeed: string +} + +const teamManager = new TeamManager() +export function getTeamManager() { + return teamManager +} + +function removeUndefined(obj: T): T { + return JSON.parse(JSON.stringify(obj)) as T +} + +export async function performAuthHandshake(repo: Repo, discoveryKey: string, invite: Invitation, socket: WebSocket): Promise { + function send(action: string, payload: T) { + socket.send(JSON.stringify({action, payload})) + } + console.log('generating proof using invitation:', invite) + const proof = Auth.generateProof(invite.invitationSeed, invite.username) + console.log('Sending AUTH:JOIN to peer') + send('AUTH:JOIN', proof) + + return new Promise((resolve, reject) => { + const listener = async ({data}: MessageEvent) => { + const message = JSON.parse(data) + const {type, action, payload} = message + if (type === 'HELLO') { + // ignore + return + } + switch(action) { + case 'ENCRYPTED': + console.log('Ignoring encrypted message') + break + case 'AUTH:JOIN': + console.log('Well this is awkward. The peer without a Team is being asked to respond to an AUTH:JOIN event. All we can do is ignore it') + break + case 'AUTH:ADMITTED': + // const authHistoryChanges = payload + // const authHistoryDoc = A.applyChanges(A.from({}), authHistoryChanges) + const authHistoryDoc = payload + const user = Auth.createUser({ + userName: invite.username, + deviceName: 'Laptop', + deviceType: 1, // DeviceType.laptop, + seed: invite.invitationSeed + }) + const team = new Auth.Team({ + source: authHistoryDoc, + context: {user: user} + }) + team.encrypt('Just testing for runtime error. Howdy Everyone! If this fails then BUG???? none of the lockboxes have a publicKey that matches the ephemeral one for this invitee. Ensure that the code for the tempkeys in Auth.Team.join are seeded with "invitationSeed" instead of "this.seed"') + + // Update my keys + team.join(proof) + + // We successfully loaded the team. Resolve promises and remove this authentication listener + socket.removeEventListener(MESSAGE, listener) + resolve(team) + await getTeamManager().addTeam(repo, discoveryKey, team) + break + default: + console.error(message) + throw new Error(`BUG: unsupported message action "${action}"`) + } + } + socket.addEventListener(MESSAGE, listener) + + }) + } \ No newline at end of file diff --git a/packages/state/src/types.ts b/packages/state/src/types.ts index 1d4049f0..2dd0a3c1 100644 --- a/packages/state/src/types.ts +++ b/packages/state/src/types.ts @@ -69,3 +69,8 @@ export interface RepoSnapshot { } export * from '@localfirst/storage-abstract' + +export function ensure(arg: T | undefined) { + if (!arg) { throw new Error('BUG: Assertion failed and argument was undefined at this point in time')} + return arg +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index af57feb7..8972f6c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17700,21 +17700,9 @@ table@^6.0.4: slice-ansi "^4.0.0" string-width "^4.2.0" -taco-js@0: +"taco-js@github:philschatz/auth#975fe719f774b1e46811aa4344b65a0d4cec5a3c": version "0.2.1" - resolved "https://registry.yarnpkg.com/taco-js/-/taco-js-0.2.1.tgz#7643fbb45cfabfae826067d222f30cf1881a4473" - integrity sha512-lipvIwKK0HlmCRGBb2JtBrU1E8PJVXPM8m2uroEgT7UbhzKv2MWBK7Z9xtwro/pZ7GI8fHhH6USmOQSbhrciww== - dependencies: - "@herbcaudill/crypto" "0" - debug "4" - fast-memoize "2" - msgpack-lite "0" - ramda "0" - xstate "4" - -"taco-js@github:philschatz/auth#fix-invite-seed": - version "0.2.1" - resolved "https://codeload.github.com/philschatz/auth/tar.gz/7edcf68dd66eece6c18bf82cb82bbb856ab9874f" + resolved "https://codeload.github.com/philschatz/auth/tar.gz/975fe719f774b1e46811aa4344b65a0d4cec5a3c" dependencies: "@herbcaudill/crypto" "0" debug "4" From be464ea71d60f1ac977c1ac370f97e84a2dd38cf Mon Sep 17 00:00:00 2001 From: philschatz <253202+philschatz@users.noreply.github.com> Date: Sat, 2 Jan 2021 19:19:18 -0600 Subject: [PATCH 4/7] :tada: Invitation is in URL and works in multiple browsers --- packages/state/package.json | 1 + packages/state/src/StoreManager.ts | 27 +++++++++++++++++++-------- packages/state/src/TeamManager.ts | 18 +++++++----------- yarn.lock | 9 +++++++++ 4 files changed, 36 insertions(+), 19 deletions(-) diff --git a/packages/state/package.json b/packages/state/package.json index 343cd25f..fcc77cf4 100644 --- a/packages/state/package.json +++ b/packages/state/package.json @@ -27,6 +27,7 @@ "debug": "4", "fast-memoize": "2", "immutable": "4.0.0-rc.12", + "query-string": "^6.13.8", "redux-devtools-extension": "2", "scryptsy": "2", "taco-js": "github:philschatz/auth#975fe719f774b1e46811aa4344b65a0d4cec5a3c" diff --git a/packages/state/src/StoreManager.ts b/packages/state/src/StoreManager.ts index 65660dda..92e87a22 100644 --- a/packages/state/src/StoreManager.ts +++ b/packages/state/src/StoreManager.ts @@ -6,6 +6,7 @@ import { EventEmitter } from 'events' import { applyMiddleware, createStore, Middleware, Store } from 'redux' import { composeWithDevTools } from 'redux-devtools-extension' import * as Auth from 'taco-js' +import querystring from 'query-string' import { ConnectionManager, Invitation } from './ConnectionManager' import { CLOSE, DEFAULT_RELAYS, OPEN, PEER, PEER_REMOVE } from './constants' import { getMiddleware } from './getMiddleware' @@ -113,12 +114,14 @@ export class StoreManager extends EventEmitter { if (maybeTeam) { invitationOrTeam = maybeTeam } else { - - const invitationStr = prompt('Enter Invitation string or leave blank to create a new Team') - if (invitationStr) { - const [username, invitationSeed] = invitationStr.split('+') - invitationOrTeam = {username, invitationSeed} - } else { + const {invitationUser, invitationSeed} = querystring.parse(window.location.search, { + parseBooleans: false, + parseNumbers: false, + arrayFormat: "none" + }) + if (invitationUser && invitationSeed) { + invitationOrTeam = {username: invitationUser.toString(), invitationSeed: invitationSeed.toString()} + } else if (confirm('You were not given an invitation to this page. Do you want to create a new Team?')) { // Create a new team const user = Auth.createUser({ userName: 'Alice', @@ -128,6 +131,9 @@ export class StoreManager extends EventEmitter { const t = Auth.createTeam('dream', {user}) const team = await getTeamManager().instantiateTeamDefinitely(ensure(this.repo), discoveryKey, t.chain) invitationOrTeam = team + } else { + alert('You have chosen not to create or join a team. There is nothing left to do. Closing.') + return } } @@ -136,8 +142,13 @@ export class StoreManager extends EventEmitter { if (invitationOrTeam.memberIsAdmin(invitationOrTeam.context.user.userName)) { const username = `Friend${(new Number(Math.round(Math.random() * 0x10000)).toString())}` const {invitationSeed} = invitationOrTeam.invite(username) - console.log(`Invite using this:\n${username}+${invitationSeed}`) - alert(`Invite using this:\n${username}+${invitationSeed}`) + const qs = querystring.stringify({ + ...querystring.parse(window.location.search), + invitationUser: username, + invitationSeed + }) + window.history.replaceState(null, '', `?${qs}`) + alert(`Invite a person by copying and pasting the URL in the browser to your friend`) } } diff --git a/packages/state/src/TeamManager.ts b/packages/state/src/TeamManager.ts index 82e6ca61..c9732d35 100644 --- a/packages/state/src/TeamManager.ts +++ b/packages/state/src/TeamManager.ts @@ -6,6 +6,7 @@ import { Repo } from './Repo' const pendingSaves: Promise[] = [] +const AUTH_KEY = '_AUTH' // A Team is associated with a discoveryKey and may be in one of the following states: // - loaded @@ -28,9 +29,8 @@ export class TeamManager { this.teams.set(discoveryKey, [undefined, invitation]) } else { // Try loading the team from Storage - const authKey = `${discoveryKey}:AUTH` - if (repo.has(authKey)) { - const state = await repo.get(authKey) + if (repo.has(AUTH_KEY)) { + const state = await repo.get(AUTH_KEY) const user = Auth.loadUser() const team = new Auth.Team({ source: state, @@ -43,13 +43,12 @@ export class TeamManager { } async instantiateTeamDefinitely(repo: Repo, discoveryKey: string, state: any): Promise { - const authKey = `${discoveryKey}:AUTH` let team = this.teams.get(discoveryKey) if (team && team[0]) { throw new Error('BUG: Should not already have a team loaded up') } else { // Try loading the team from Storage - repo.set(authKey, A.from(removeUndefined(state))) + repo.set(AUTH_KEY, A.from(removeUndefined(state))) const user = Auth.loadUser() const team = new Auth.Team({ source: state, @@ -62,18 +61,15 @@ export class TeamManager { } async addTeam(repo: Repo, discoveryKey: string, team: Auth.Team) { - const authKey = `${discoveryKey}:AUTH` if (this.teams.has(discoveryKey)) { throw new Error('BUG? seems this already has a Team') } this.teams.set(discoveryKey, [team, undefined]) - await repo.set(authKey, A.from(removeUndefined(team.chain))) + await repo.set(AUTH_KEY, A.from(removeUndefined(team.chain))) this.addListener(repo, discoveryKey, team) } - private addListener(repo: Repo, discoveryKey: string, team: Auth.Team) { - const authKey = `${discoveryKey}:AUTH` - + private addListener(repo: Repo, discoveryKey: string, team: Auth.Team) { team.on('updated', async ({head: headId}: {head: string}) => { const headNode = removeUndefined(team.chain.links[headId]) // Perform saves in order. Otherwise the storage gets corrupted @@ -82,7 +78,7 @@ export class TeamManager { pendingSaves.splice(0, pendingSaves.length) // clear when done saving } - pendingSaves.push(repo.change(authKey, (doc) => { + pendingSaves.push(repo.change(AUTH_KEY, (doc) => { if (!doc.root) { doc.root = headId } diff --git a/yarn.lock b/yarn.lock index 8972f6c5..45f801b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15495,6 +15495,15 @@ query-string@^5.0.0: object-assign "^4.1.0" strict-uri-encode "^1.0.0" +query-string@^6.13.8: + version "6.13.8" + resolved "https://registry.yarnpkg.com/query-string/-/query-string-6.13.8.tgz#8cf231759c85484da3cf05a851810d8e825c1159" + integrity sha512-jxJzQI2edQPE/NPUOusNjO/ZOGqr1o2OBa/3M00fU76FsLXDVbJDv/p7ng5OdQyorKrkRz1oqfwmbe5MAMePQg== + dependencies: + decode-uri-component "^0.2.0" + split-on-first "^1.0.0" + strict-uri-encode "^2.0.0" + querystring-es3@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" From 799f9e38901513e7fc522b48bf0ee343602971c6 Mon Sep 17 00:00:00 2001 From: philschatz <253202+philschatz@users.noreply.github.com> Date: Sat, 2 Jan 2021 20:17:09 -0600 Subject: [PATCH 5/7] :tada: show avatars when authenticated --- examples/toolbar/src/components/Status.tsx | 33 +++++++++++++++++++--- packages/state/src/Connection.ts | 10 +++++-- packages/state/src/ConnectionManager.ts | 12 +++++--- packages/state/src/StoreManager.ts | 6 ++-- packages/state/src/constants.ts | 1 + 5 files changed, 49 insertions(+), 13 deletions(-) diff --git a/examples/toolbar/src/components/Status.tsx b/examples/toolbar/src/components/Status.tsx index bc5414d7..447cefa6 100644 --- a/examples/toolbar/src/components/Status.tsx +++ b/examples/toolbar/src/components/Status.tsx @@ -3,6 +3,7 @@ import { CLOSE, OPEN, PEER, PEER_REMOVE, StoreManager } from '@localfirst/state' import React, { useEffect, useState } from 'react' import { Group } from './Group' import { StatusLight } from './StatusLight' +import { PEER_UPDATE } from '@localfirst/state/dist/src/constants' interface StatusProps { storeManager: StoreManager @@ -12,8 +13,8 @@ export const Status = ({ storeManager }: StatusProps) => { const [online, setOnline] = useState(false) const [peers, setPeers] = useState([]) - const onPeer = (updatedPeers: string[]) => { - setPeers(updatedPeers) + const onPeer = (updatedPeers: string[], updatedAuthenticatedUserInfo: {generation: number, name: string, type: 'ADMIN' | 'MEMBER'}[]) => { + setPeers(updatedAuthenticatedUserInfo.map((v, i) => `${(v && v.type) === 'ADMIN' ? '👑' : ''} ${(v && v.name) ? v.name : `?${updatedPeers[i]}?`}`)) } const onOpen = () => { @@ -31,6 +32,7 @@ export const Status = ({ storeManager }: StatusProps) => { storeManager.on(CLOSE, onClose) storeManager.on(PEER, onPeer) storeManager.on(PEER_REMOVE, onPeer) + storeManager.on(PEER_UPDATE, onPeer) return removeListeners // return cleanup function } @@ -51,13 +53,13 @@ export const Status = ({ storeManager }: StatusProps) => { : `${peers.length} other peers are connected` const statusMessage = online ? `online; ${peerCountMessage}` : 'offline' - const peerItems = peers.map(p =>
  • {p}
  • ) + const peerItems = peers.map(p =>
  • ) return (