Skip to content

Commit 41b0608

Browse files
michaelkamphausenHerbCaudill
authored andcommitted
send all generations of user keys to an invited device to ensure it can decrypt all generations of team keys and the complete team graph
1 parent 96fdca9 commit 41b0608

File tree

4 files changed

+131
-25
lines changed

4 files changed

+131
-25
lines changed

packages/auth/src/connection/Connection.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable object-shorthand */
22
import { EventEmitter } from '@herbcaudill/eventemitter42'
3-
import type { DecryptFnParams } from '@localfirst/crdx'
3+
import type { DecryptFnParams, KeysetWithSecrets } from '@localfirst/crdx'
44
import {
55
generateMessage,
66
headsAreEqual,
@@ -214,21 +214,26 @@ export class Connection extends EventEmitter<ConnectionEvents> {
214214
const { device, invitationSeed } = context
215215
assert(invitationSeed)
216216

217-
const user =
218-
context.user ??
219-
// If we're joining as a new device for an existing member, we won't have a user object
220-
// yet, so we need to get those from the graph. We use the invitation seed to generate
221-
// the starter keys for the new device. We can use these to unlock a lockbox on the team
222-
// graph that contains our user keys.
223-
getDeviceUserFromGraph({ serializedGraph, teamKeyring, invitationSeed })
224-
217+
let user = context.user
218+
let allUserKeys: KeysetWithSecrets[] | undefined = undefined
219+
220+
// If we're joining as a new device for an existing member, we won't have a user object
221+
// and all generations of existing user keys yet, so we need to get those from the graph.
222+
// We use the invitation seed to generate the starter keys for the new device. We can use
223+
// these to unlock the lockboxes on the team graph that contain our user keys.
224+
if (!user) {
225+
const userWithKeys = getDeviceUserFromGraph({ serializedGraph, teamKeyring, invitationSeed })
226+
user = userWithKeys.user
227+
allUserKeys = userWithKeys.allUserKeys
228+
}
229+
225230
// When admitting us, our peer added our user to the team graph. We've been given the
226231
// serialized and encrypted graph, and the team keyring. We can now decrypt the graph and
227232
// reconstruct the team in order to join it.
228233
const team = new Team({ source: serializedGraph, context: { user, device }, teamKeyring })
229234

230235
// We join the team, which adds our device to the team graph.
231-
team.join(teamKeyring)
236+
team.join(teamKeyring, allUserKeys)
232237
this.emit('joined', { team, user, teamKeyring })
233238
return { user, team }
234239
}),

packages/auth/src/connection/getDeviceUserFromGraph.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Keyring, UserWithSecrets } from '@localfirst/crdx'
1+
import type { Keyring, KeysetWithSecrets, UserWithSecrets } from '@localfirst/crdx'
22
import { assert } from '@localfirst/shared'
33
import { generateProof } from 'invitation/generateProof.js'
44
import { generateStarterKeys } from 'invitation/generateStarterKeys.js'
@@ -11,7 +11,9 @@ const { USER } = KeyType
1111
/**
1212
* If we're joining as a new device for an existing member, we don't have a user object yet, so we
1313
* need to get those from the graph. We use the invitation seed to generate the starter keys for the
14-
* new device. We can use these to unlock a lockbox on the team graph that contains our user keys.
14+
* new device. We can use these to unlock the lockboxes on the team graph that contain our user keys.
15+
* Because we need all user key generations to decrypt the team graph, we return all of them along
16+
* with the user object that contains only the latest keys generation.
1517
*/
1618
export const getDeviceUserFromGraph = ({
1719
serializedGraph,
@@ -21,7 +23,10 @@ export const getDeviceUserFromGraph = ({
2123
serializedGraph: Uint8Array
2224
teamKeyring: Keyring
2325
invitationSeed: string
24-
}): UserWithSecrets => {
26+
}): {
27+
user: UserWithSecrets,
28+
allUserKeys: KeysetWithSecrets[]
29+
} => {
2530
const starterKeys = generateStarterKeys(invitationSeed)
2631
const invitationId = generateProof(invitationSeed).id
2732
const state = getTeamState(serializedGraph, teamKeyring)
@@ -32,11 +37,15 @@ export const getDeviceUserFromGraph = ({
3237
const { userName } = select.member(state, userId)
3338
assert(userName) // this user must exist in the team graph
3439

35-
const userKeys = select.keys(state, starterKeys, { type: USER, name: userId })
36-
37-
return {
40+
const allUserKeys = select.keyMap(state, starterKeys)[USER]?.[userId]
41+
const user = {
3842
userName,
3943
userId,
40-
keys: userKeys,
44+
keys: allUserKeys.at(-1)
45+
}
46+
47+
return {
48+
user,
49+
allUserKeys
4150
}
4251
}

packages/auth/src/team/Team.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -505,7 +505,8 @@ export class Team extends EventEmitter<TeamEvents> {
505505
// a lockbox that can be opened by an ephemeral keyset generated from the secret invitation
506506
// seed.
507507
const starterKeys = invitations.generateStarterKeys(seed)
508-
const lockboxUserKeysForDeviceStarterKeys = lockbox.create(this.context.user.keys, starterKeys)
508+
const lockboxesUserKeysForDeviceStarterKeys = this.allUserKeys()
509+
.map(keys => lockbox.create(keys, starterKeys))
509510

510511
const { id } = invitation
511512

@@ -514,7 +515,7 @@ export class Team extends EventEmitter<TeamEvents> {
514515
type: 'INVITE_DEVICE',
515516
payload: {
516517
invitation,
517-
lockboxes: [lockboxUserKeysForDeviceStarterKeys],
518+
lockboxes: lockboxesUserKeysForDeviceStarterKeys,
518519
},
519520
})
520521

@@ -603,20 +604,20 @@ export class Team extends EventEmitter<TeamEvents> {
603604
}
604605

605606
/** Once the new member has received the graph and can instantiate the team, they call this to add their device. */
606-
public join = (teamKeyring: Keyring) => {
607+
public join = (teamKeyring: Keyring, allUserKeys = [this.context.user.keys]) => {
607608
assert(!this.isServer, "Can't join as member on server")
608609

609-
const { user, device } = this.context
610+
const { device } = this.context
610611
const teamKeys = getLatestGeneration(teamKeyring)
611612

612-
const lockboxUserKeysForDevice = lockbox.create(user.keys, device.keys)
613+
const lockboxesUserKeysForDevice = allUserKeys.map(keys => lockbox.create(keys, device.keys))
613614

614615
this.dispatch(
615616
{
616617
type: 'ADD_DEVICE',
617618
payload: {
618619
device: redactDevice(device),
619-
lockboxes: [lockboxUserKeysForDevice],
620+
lockboxes: lockboxesUserKeysForDevice,
620621
},
621622
},
622623
teamKeys
@@ -762,6 +763,9 @@ export class Team extends EventEmitter<TeamEvents> {
762763
public keys = (scope: KeyMetadata | KeyScope) =>
763764
select.keys(this.state, this.context.device.keys, scope)
764765

766+
public allUserKeys = (userId = this.userId) =>
767+
select.keyMap(this.state, this.context.device.keys)[USER]?.[userId] || []
768+
765769
/** Returns the keys for the given role. */
766770
public roleKeys = (roleName: string, generation?: number) =>
767771
this.keys({ type: KeyType.ROLE, name: roleName, generation })

packages/auth/src/team/test/invitations.test.ts

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createKeyset, type UnixTimestamp } from '@localfirst/crdx'
22
import { signatures } from '@localfirst/crypto'
3-
import { redactDevice, type FirstUseDevice } from 'index.js'
3+
import { redactDevice, Team, type FirstUseDevice } from 'index.js'
44
import { generateProof } from 'invitation/index.js'
55
import * as teams from 'team/index.js'
66
import { KeyType } from 'util/index.js'
@@ -287,7 +287,7 @@ describe('Team', () => {
287287

288288
// To do that, she uses the invitation seed to generate starter keys, which she can use to
289289
// unlock a lockbox stored on the graph containing her user keys.
290-
const aliceUser = teams.getDeviceUserFromGraph({
290+
const { user: aliceUser } = teams.getDeviceUserFromGraph({
291291
serializedGraph,
292292
teamKeyring,
293293
invitationSeed: seed,
@@ -353,6 +353,94 @@ describe('Team', () => {
353353
// 🦹‍♀️ GRRR I would've got away with it too, if it weren't for you meddling cryptographic algorithms!
354354
expect(submitBadProof).toThrow('Signature provided is not valid')
355355
})
356+
357+
it('an invited device needs access to all generations of user and team keys', () => {
358+
const { alice: aliceLaptop } = setup('alice')
359+
const alicePhone = aliceLaptop.phone!
360+
361+
const changeKeys = () => {
362+
const newKeys = { type: KeyType.USER, name: aliceLaptop.userId }
363+
aliceLaptop.team.changeKeys(createKeyset(newKeys))
364+
}
365+
366+
// Alice rotates her keys two times
367+
changeKeys()
368+
changeKeys()
369+
370+
// key rotation results in two new keys generations for team keys, admin keys and alice user keys
371+
expect(aliceLaptop.team.teamKeys().generation).toBe(2)
372+
expect(aliceLaptop.team.adminKeys().generation).toBe(2)
373+
expect(aliceLaptop.team.members(aliceLaptop.userId).keys.generation).toBe(2)
374+
expect(aliceLaptop.user.keys.generation).toBe(2)
375+
expect(Object.values(aliceLaptop.team.teamKeyring())).toHaveLength(3)
376+
expect(aliceLaptop.team.allUserKeys()).toHaveLength(3)
377+
// 3 times 3 generations of team keys, admin keys, alice user keys
378+
expect(aliceLaptop.team.state.lockboxes.length).toBe(9)
379+
380+
// 💻 on her laptop, Alice generates an invitation for her phone
381+
const { seed } = aliceLaptop.team.inviteDevice()
382+
383+
// 📱 Alice's phone uses the seed to generate her proof of invitation and sends it to the laptop
384+
const proofOfInvitation = generateProof(seed)
385+
386+
// 💻 Alice's laptop verifies the proof
387+
aliceLaptop.team.admitDevice(proofOfInvitation, redactDevice(alicePhone))
388+
389+
// 👍 The proof was good, so the laptop sends the phone the team's graph and keyring
390+
const serializedGraph = aliceLaptop.team.save()
391+
const teamKeyring = aliceLaptop.team.teamKeyring()
392+
393+
// 📱 Alice's phone needs to get her user keys.
394+
395+
// Alice's laptop also sends all generations of user keys in encrypted lockboxes for each key,
396+
// which Alice's phone decrypts using her starter keys generated from the invitation seed.
397+
// Alice's phone needs every generation of user keys to unlock every generation of team keys so
398+
// the phone can decrypt the whole team graph using all the secret keys of the team keys generations.
399+
const { user: aliceUser, allUserKeys } = teams.getDeviceUserFromGraph({
400+
serializedGraph,
401+
teamKeyring,
402+
invitationSeed: seed,
403+
})
404+
405+
// on invitation creation, Alice's laptop added 3 lockboxes to send 3 generations of user keys
406+
// to Alice's phone using the starter keys for encryption
407+
expect(aliceLaptop.team.state.lockboxes.length).toBe(12)
408+
409+
const phoneTeam = new Team({
410+
source: serializedGraph,
411+
context: { user: aliceUser, device: alicePhone },
412+
teamKeyring
413+
})
414+
phoneTeam.join(teamKeyring, allUserKeys)
415+
416+
// ✅ Now Alice has 💻📱 two devices on the signature chain
417+
expect(phoneTeam.members(aliceLaptop.userId).devices).toHaveLength(2)
418+
expect(aliceLaptop.team.members(aliceLaptop.userId).devices).toHaveLength(2)
419+
420+
// Alice's phone added 3 more lockboxes for 3 generations of user keys while joining,
421+
// this time using it's own secret device keys for encryption
422+
expect(phoneTeam.state.lockboxes.length).toBe(15)
423+
424+
// Alice's phone has all user keys and team keys generations, the latest admin keys,
425+
// and the latest user keys generation stored on it's member object
426+
expect(Object.values(phoneTeam.teamKeyring())).toHaveLength(3)
427+
expect(phoneTeam.allUserKeys()).toHaveLength(3)
428+
expect(phoneTeam.adminKeys().generation).toBe(2)
429+
expect(phoneTeam.members(aliceLaptop.userId).keys.generation).toBe(2)
430+
431+
const serializedPhoneTeam = phoneTeam.save()
432+
433+
// In case some keys went missing while serializing and deserializing
434+
// the phone's local context, some required keys wouldn't be available to decrypt,
435+
// resulting in the error "Can't decrypt link: don't have the correct keyset"
436+
expect(() =>
437+
new Team({
438+
source: serializedPhoneTeam,
439+
context: { user: aliceUser, device: alicePhone },
440+
teamKeyring: phoneTeam.teamKeyring()
441+
})
442+
).not.toThrow()
443+
})
356444
})
357445
})
358446
})

0 commit comments

Comments
 (0)