Skip to content

Commit 9e5324a

Browse files
michaelkamphausenHerbCaudill
authored andcommitted
ensure on device removal that the member state and the local user context are updated with the new rotated user keys generation
1 parent 41b0608 commit 9e5324a

File tree

5 files changed

+52
-5
lines changed

5 files changed

+52
-5
lines changed

packages/auth/src/connection/test/authentication.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from 'util/testing/index.js'
1818
import { describe, expect, it } from 'vitest'
1919
import type { InviteeDeviceContext } from '../types.js'
20+
import { createDevice } from 'device/createDevice.js'
2021

2122
describe('connection', () => {
2223
describe('authentication', () => {
@@ -248,7 +249,7 @@ describe('connection', () => {
248249

249250
// Bob invites and admits his phone
250251

251-
const phone = bob.phone!
252+
let phone = bob.phone!
252253

253254
{
254255
const { seed } = bob.team.inviteDevice()
@@ -279,6 +280,12 @@ describe('connection', () => {
279280
{
280281
// Bob invites his phone again
281282

283+
// The phone needs a new deviceId, otherwise the connection will immediately
284+
// fail with DEVICE_REMOVED error after being established. It also gets a new
285+
// device key as the old one could be compromised.
286+
phone = createDevice({ userId: bob.userId, deviceName: phone.deviceName })
287+
bob.phone = phone
288+
282289
const { seed } = bob.team.inviteDevice()
283290
const phoneContext: InviteeDeviceContext = {
284291
userName: bob.userName,

packages/auth/src/team/Team.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,12 @@ export class Team extends EventEmitter<TeamEvents> {
137137
}
138138

139139
this.state = this.store.getState()
140+
this.checkForNewUserKeysGeneration()
140141

141142
// Wire up event listeners
142143
this.on('updated', () => {
144+
this.checkForNewUserKeysGeneration()
145+
143146
// If we're admin, check for pending key rotations
144147
this.checkForPendingKeyRotations()
145148
})
@@ -807,6 +810,15 @@ export class Team extends EventEmitter<TeamEvents> {
807810
if (isForServer) device.keys = newKeys // (a server plays the role of both a user and a device)
808811
}
809812

813+
private checkForNewUserKeysGeneration() {
814+
const { user } = this.context
815+
const latestUserKeys = this.allUserKeys().at(-1)
816+
817+
if (latestUserKeys && user.keys.generation < latestUserKeys.generation) {
818+
user.keys = latestUserKeys
819+
}
820+
}
821+
810822
private checkForPendingKeyRotations() {
811823
// Only admins can rotate keys
812824
if (!this.memberIsAdmin(this.userId)) {

packages/auth/src/team/reducer.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,9 @@ const getTransforms = (action: TeamAction): Transform[] => {
132132
}
133133

134134
case 'REMOVE_DEVICE': {
135-
const { deviceId } = action.payload
135+
const { deviceId, lockboxes } = action.payload
136136
return [
137-
removeDevice(deviceId), // Remove this device from the member's list of devices
137+
removeDevice(deviceId, lockboxes), // Remove this device from the member's list of devices
138138
]
139139
}
140140

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ describe('Team', () => {
130130

131131
// Keys have never been rotated
132132
expect(alice.team.teamKeys().generation).toBe(0)
133+
expect(bob.team.members(bob.userId)?.keys.generation).toBe(0)
134+
expect(bob.user.keys.generation).toBe(0)
133135
const { secretKey } = alice.team.teamKeys()
134136

135137
// Add bob's phone
@@ -139,6 +141,10 @@ describe('Team', () => {
139141
// Remove bob's phone
140142
bob.team.removeDevice(phone.deviceId)
141143

144+
// User keys have now been rotated once
145+
expect(bob.team.members(bob.userId)?.keys.generation).toBe(1)
146+
expect(bob.user.keys.generation).toBe(1)
147+
142148
// Team keys have now been rotated once
143149
expect(bob.team.teamKeys().generation).toBe(1)
144150
expect(bob.team.teamKeys().secretKey).not.toBe(secretKey)

packages/auth/src/team/transforms/removeDevice.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,40 @@
1+
import { Keyset } from '@localfirst/crdx'
2+
import { Lockbox } from 'lockbox/index.js'
13
import * as select from 'team/selectors/index.js'
24
import { type Member, type Transform } from 'team/types.js'
5+
import { KeyType } from 'util/types.js'
36

47
export const removeDevice =
5-
(deviceId: string): Transform =>
8+
(deviceId: string, lockboxes: Lockbox[] = []): Transform =>
69
state => {
710
const removedDevice = select.device(state, deviceId)
811

9-
const { userId } = select.memberByDeviceId(state, deviceId)
12+
const member = select.memberByDeviceId(state, deviceId)
13+
const { userId } = member
14+
let { keys } = member
15+
const userLockbox = lockboxes.find(
16+
({ contents }) => contents.type == KeyType.USER && contents.name == userId
17+
)
18+
19+
// When a device is removed, the user keys are rotated and the new key
20+
// generation is in the lockboxes. The unencrypted lockbox contents
21+
// contain the meta data along with the public keys which is all we need
22+
// to update the user keys of the member to the latest generation.
23+
if (userLockbox) {
24+
const { type, name, generation, encryption, signature } =
25+
userLockbox.contents as unknown as Keyset
26+
27+
if (keys.generation < generation && !!encryption && !!signature) {
28+
keys = { type, name, generation, encryption, signature }
29+
}
30+
}
1031

1132
const removeDeviceFromMember = (member: Member) =>
1233
member.userId === userId
1334
? {
1435
...member,
1536
devices: member.devices?.filter(d => d.deviceId !== removedDevice.deviceId),
37+
keys,
1638
}
1739
: member
1840

0 commit comments

Comments
 (0)