Skip to content

Commit

Permalink
allow servers & other members to admit devices on a member's behalf
Browse files Browse the repository at this point in the history
  • Loading branch information
HerbCaudill committed Nov 19, 2023
1 parent c37d2b3 commit 34c4059
Show file tree
Hide file tree
Showing 6 changed files with 94 additions and 58 deletions.
45 changes: 23 additions & 22 deletions packages/auth/src/connection/Connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
redactKeys,
type DecryptFnParams,
type SyncState,
type UserWithSecrets,
} from '@localfirst/crdx'
import { asymmetric, randomKey, symmetric, type Hash, type Payload } from '@localfirst/crypto'
import { deriveSharedKey } from 'connection/deriveSharedKey.js'
Expand Down Expand Up @@ -333,33 +332,35 @@ export class Connection extends EventEmitter<ConnectionEvents> {
joinTeam: assign((context, event) => {
const { payload } = event as AcceptInvitationMessage
const { serializedGraph, teamKeyring } = payload
const { user, device } = context
const { device, invitationSeed } = context

const rehydrateTeam = (user: UserWithSecrets) =>
new Team({ source: serializedGraph, context: { user, device }, teamKeyring })
// We've been given the serialized and encrypted graph, and the team keyring. We can decrypt
// the graph and reconstruct the team.

// Join the team
if (user !== undefined) {
// Joining as a new member
const getDeviceUser = () => {
// If we're joining as a new device for an existing member, we don't know our user id or
// user keys yet, so we need to get those from the graph.
const { id: invitationId } = this.myProofOfInvitation(context)

// Reconstruct team from serialized graph
const team = rehydrateTeam(user)
// We use the invitation seed to generate the starter keys for the new device.
// We can use these to unlock a lockbox on the team graph that contains our user keys.
const starterKeys = invitations.generateStarterKeys(invitationSeed!)
return getDeviceUserFromGraph({
serializedGraph,
teamKeyring,
starterKeys,
invitationId,
})
}
const user = context.user ?? getDeviceUser()

// Add our current device to the team chain
team.join(teamKeyring)
// Reconstruct team from serialized graph
const team = new Team({ source: serializedGraph, context: { user, device }, teamKeyring })

return { team }
} else {
// Joining as a new device for an existing member.
// We don't know our user id or user keys yet, so we need to get those from the graph.
const { id: invitationId } = this.myProofOfInvitation(context)
const user = getDeviceUserFromGraph({ serializedGraph, teamKeyring, device, invitationId })

// Now we can rehydrate the graph
const team = rehydrateTeam(user)
// Add our current device to the team chain
team.join(teamKeyring)

return { user, team }
}
return { user, team }
}),

// AUTHENTICATING
Expand Down
15 changes: 9 additions & 6 deletions packages/auth/src/connection/getDeviceUserFromGraph.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { Base58, Keyring, UserWithSecrets } from '@localfirst/crdx'
import type { FirstUseDeviceWithSecrets } from 'device/index.js'
import type { Base58, Keyring, KeysetWithSecrets, UserWithSecrets } from '@localfirst/crdx'
import { KeyType, assert } from 'util/index.js'
import { getTeamState } from '../team/getTeamState.js'
import * as select from '../team/selectors/index.js'
Expand All @@ -9,23 +8,27 @@ const { USER } = KeyType
export const getDeviceUserFromGraph = ({
serializedGraph,
teamKeyring,
device,
starterKeys,
invitationId,
}: {
serializedGraph: string
teamKeyring: Keyring
device: FirstUseDeviceWithSecrets
starterKeys: KeysetWithSecrets
invitationId: Base58
}): UserWithSecrets => {
const state = getTeamState(serializedGraph, teamKeyring)

const { userId } = select.getInvitation(state, invitationId)
assert(userId) // since this is a device invitation the invitation info includes the userId that created it

const { userName } = select.member(state, userId)
assert(userName) // this user must exist in the team graph
const keys = select.keys(state, device.keys, { type: USER, name: userId })

const userKeys = select.keys(state, starterKeys, { type: USER, name: userId })

return {
userName,
userId,
keys,
keys: userKeys,
}
}
6 changes: 3 additions & 3 deletions packages/auth/src/connection/test/authentication.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ describe('connection', () => {
expect(alice.team.members(bob.userId).devices).toHaveLength(2)
})

it.todo('lets a different member admit an invited device', async () => {
it('lets a different member admit an invited device', async () => {
const { alice, bob } = setup('alice', 'bob')

await connect(alice, bob)
Expand All @@ -214,9 +214,9 @@ describe('connection', () => {
}
const join = joinTestChannel(new TestChannel())
const aliceConnection = join(alice.connectionContext).start()
const phoneConnection = join(phoneContext).start()
const bobPhoneConnection = join(phoneContext).start()

await all([aliceConnection, phoneConnection], 'connected')
await all([aliceConnection, bobPhoneConnection], 'connected')

alice.team = aliceConnection.team!

Expand Down
15 changes: 6 additions & 9 deletions packages/auth/src/team/Team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import type {
Payload,
Store,
UnixTimestamp,
User,
UserWithSecrets,
} from '@localfirst/crdx'
import {
Expand Down Expand Up @@ -483,9 +482,6 @@ export class Team extends EventEmitter {
* Once an existing device (Bob's laptop or Alice or Charlie) verifies Bob's phone's proof, they
* send it the team graph. Using the graph, the phone instantiates the team, then adds itself as
* a device.
*
* Note: A member can only invite their own devices. A non-admin member can only remove their own
* device; an admin member can remove a device for anyone.
*/
public inviteDevice({
seed = invitations.randomSeed(),
Expand All @@ -505,8 +501,11 @@ export class Team extends EventEmitter {
const maxUses = 1 // Can't invite multiple devices with the same invitation
const invitation = invitations.create({ seed, expiration, maxUses, userId: this.userId })

// In order for the invited device to be able to access the user's keys, we put the user keys in
// a lockbox that can be opened by an ephemeral keyset generated from the secret invitation
// seed.
const starterKeys = invitations.generateStarterKeys(seed)
const userLockboxForStarterKeys = lockbox.create(this.context.user.keys, starterKeys)
const lockboxUserKeysForDeviceStarterKeys = lockbox.create(this.context.user.keys, starterKeys)

const { id } = invitation

Expand All @@ -515,7 +514,7 @@ export class Team extends EventEmitter {
type: 'INVITE_DEVICE',
payload: {
invitation,
lockboxes: [userLockboxForStarterKeys],
lockboxes: [lockboxUserKeysForDeviceStarterKeys],
},
})

Expand Down Expand Up @@ -598,9 +597,7 @@ export class Team extends EventEmitter {

const { id } = proof
const invitation = this.getInvitation(id)
const { userId } = invitation

assert(this.userId === userId, "Can't admit someone else's device")
const userId = invitation.userId!

// Now we can add the userId to the device and post it to the graph
const device: Device = { ...firstUseDevice, userId }
Expand Down
29 changes: 15 additions & 14 deletions packages/auth/src/team/test/invitations.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type UserWithSecrets, createKeyset, type UnixTimestamp } from '@localfirst/crdx'
import { redactDevice } from 'index.js'
import { generateProof } from 'invitation/index.js'
import { generateProof, generateStarterKeys } from 'invitation/index.js'
import * as teams from 'team/index.js'
import { KeyType } from 'util/index.js'
import { setup } from 'util/testing/index.js'
Expand Down Expand Up @@ -258,24 +258,30 @@ describe('Team', () => {
const serializedGraph = aliceLaptop.team.save()
const teamKeyring = aliceLaptop.team.teamKeyring()

// 📱 Alice's phone needs to get Alice's user keys from the graph in order to instantiate the team
const keys = teams.getDeviceUserFromGraph({
// 📱 Alice's phone needs to get her user keys.

// To do that, she uses the invitation seed to generate starter keys, which she can use to
// unlock a lockbox stored on the graph containing her user keys.
const starterKeys = generateStarterKeys(seed)
const aliceUser = teams.getDeviceUserFromGraph({
serializedGraph,
teamKeyring,
device: alicePhone,
starterKeys,
invitationId: proofOfInvitation.id,
})
const { userId } = alicePhone
const user: UserWithSecrets = { userId, userName: userId, keys }

const phoneTeam = teams.load(serializedGraph, { user, device: alicePhone }, teamKeyring)
const phoneTeam = teams.load(
serializedGraph,
{ user: aliceUser, device: alicePhone },
teamKeyring
)

// ✅ Now Alice has 💻📱 two devices on the signature chain
expect(phoneTeam.members(aliceLaptop.userId).devices).toHaveLength(2)
expect(aliceLaptop.team.members(aliceLaptop.userId).devices).toHaveLength(2)
})

it("doesn't let someone else admit Alice's device", () => {
it("lets someone else admit Alice's device", () => {
const { alice, bob } = setup('alice', 'bob')

// 👩🏾 Alice only has 💻 one device on the signature chain
Expand All @@ -294,12 +300,7 @@ describe('Team', () => {
bob.team = teams.load(savedTeam, bob.localContext, alice.team.teamKeys())

// 📱 Alice's phone connects with 👨🏻‍🦲 Bob and she presents the proof
const tryToAdmitPhone = () => {
bob.team.admitDevice(proofOfInvitation, redactDevice(alice.phone!))
}

// ❌ Alice's phone can only present its invitation to one of Alice's other devices
expect(tryToAdmitPhone).toThrow("Can't admit someone else's device")
bob.team.admitDevice(proofOfInvitation, redactDevice(alice.phone!))
})
})
})
Expand Down
42 changes: 38 additions & 4 deletions packages/auth/src/team/test/servers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ import {
loadTeam,
type Connection,
type InitialContext,
type InviteeDeviceInitialContext,
type MemberInitialContext,
type Team,
} from '../../index.js'
} from 'index.js'

describe('Team', () => {
describe('a server', () => {
Expand Down Expand Up @@ -143,10 +144,10 @@ describe('Team', () => {
})
const { seed: bobInvite } = alice.team.inviteMember()

// The server learns about the invitation by connecting with alice
// The server learns about the invitation by connecting with Alice
await connectWithServer(alice, server)

// Now if bob connects to the server, the server can admit him
// Now if Bob connects to the server, the server can admit him
server.team.admitMember(invitation.generateProof(bobInvite), bob.user.keys, bob.userId)
expect(server.team.members().length).toBe(2)
})
Expand Down Expand Up @@ -186,7 +187,40 @@ describe('Team', () => {
expect(bob.team.roles('MANAGER')).toBeDefined()
})

it.todo('can admit a device invited by a member', () => {})
it('can admit a device invited by a member', async () => {
const { server, alice, bob } = setup('alice', 'bob')
const { seed } = bob.team.inviteDevice()

// Bob's laptop connects to the server, so now it knows about the invitation
await connectWithServer(bob, server)

// Bob's laptop disconnects from the server
bob.connection[host].stop()
server.connection[bob.userId].stop()

// Bob's phone connects to the server
const phoneContext: InviteeDeviceInitialContext = {
userName: bob.userName,
device: bob.phone!,
invitationSeed: seed,
}
const join = joinTestChannel(new TestChannel())
const serverConnection = join(server.connectionContext).start()
const phoneConnection = join(phoneContext).start()
await all([serverConnection, phoneConnection], 'connected')

// const phoneTeam = phoneConnection.team!

// Bob reconnects to the server
await connectWithServer(bob, server)

// 👨🏻‍🦲👍📱 Bob's phone is added to his list of devices
expect(bob.team.members(bob.userId).devices).toHaveLength(2)

// ✅ 👩🏾👍📱 Alice knows about Bob's phone
await connectWithServer(alice, server)
expect(alice.team.members(bob.userId).devices).toHaveLength(2)
})

it('can change its own keys', async () => {
const { alice } = setupHumans('alice', 'bob')
Expand Down

0 comments on commit 34c4059

Please sign in to comment.