Skip to content

Commit

Permalink
Merge branch 'main' into pr/michaelkamphausen/126
Browse files Browse the repository at this point in the history
  • Loading branch information
HerbCaudill committed Sep 24, 2024
2 parents df73dc5 + 593f5c7 commit eaf5956
Show file tree
Hide file tree
Showing 12 changed files with 280 additions and 47 deletions.
21 changes: 12 additions & 9 deletions packages/auth/src/connection/Connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ import {
NEITHER_IS_MEMBER,
SERVER_REMOVED,
TIMEOUT,
UNHANDLED,
createErrorMessage,
type ConnectionErrorType,
UNHANDLED,
} from 'connection/errors.js'
import { getDeviceUserFromGraph } from 'connection/getDeviceUserFromGraph.js'
import * as identity from 'connection/identity.js'
Expand Down Expand Up @@ -214,22 +214,25 @@ export class Connection extends EventEmitter<ConnectionEvents> {
const { device, invitationSeed } = context
assert(invitationSeed)

const user =
context.user ??
// If we're joining as a new device for an existing member, we won't have a user object
// yet, so we need to get those from the graph. 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.
getDeviceUserFromGraph({ serializedGraph, teamKeyring, invitationSeed })
// If we're joining as a new device for an existing member, we won't have a user object or
// user keys yet, so we need to get those from the graph. We use the invitation seed to
// generate the starter keys for the new device. We can use these to unlock the lockboxes
// on the team graph that contain our user keys.
const { user, userKeyring } =
context.user === undefined
? getDeviceUserFromGraph({ serializedGraph, teamKeyring, invitationSeed })
: { user: context.user, userKeyring: undefined }

// When admitting us, our peer added our user to the team graph. We've been given the
// serialized and encrypted graph, and the team keyring. We can now decrypt the graph and
// reconstruct the team in order to join it.
const team = new Team({ source: serializedGraph, context: { user, device }, teamKeyring })

// We join the team, which adds our device to the team graph.
team.join(teamKeyring)
team.join(teamKeyring, userKeyring)

this.emit('joined', { team, user, teamKeyring })

return { user, team }
}),

Expand Down
24 changes: 15 additions & 9 deletions packages/auth/src/connection/getDeviceUserFromGraph.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Keyring, UserWithSecrets } from '@localfirst/crdx'
import { getLatestGeneration, type Keyring, type UserWithSecrets } from '@localfirst/crdx'
import { assert } from '@localfirst/shared'
import { generateProof } from 'invitation/generateProof.js'
import { generateStarterKeys } from 'invitation/generateStarterKeys.js'
Expand All @@ -11,7 +11,12 @@ const { USER } = KeyType
/**
* If we're joining as a new device for an existing member, we don't have a user object yet, so we
* need to get those from the graph. 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.
* new device. We can use these to unlock the lockboxes on the team graph that contain our user
* keys.
*
* Because we need all previous user keys to decrypt the team graph, we return a keyring containing
* the full history of user keys, along with a user object containing just the latest generation of
* keys.
*/
export const getDeviceUserFromGraph = ({
serializedGraph,
Expand All @@ -21,7 +26,10 @@ export const getDeviceUserFromGraph = ({
serializedGraph: Uint8Array
teamKeyring: Keyring
invitationSeed: string
}): UserWithSecrets => {
}): {
user: UserWithSecrets
userKeyring: Keyring
} => {
const starterKeys = generateStarterKeys(invitationSeed)
const invitationId = generateProof(invitationSeed).id
const state = getTeamState(serializedGraph, teamKeyring)
Expand All @@ -32,11 +40,9 @@ export const getDeviceUserFromGraph = ({
const { userName } = select.member(state, userId)
assert(userName) // this user must exist in the team graph

const userKeys = select.keys(state, starterKeys, { type: USER, name: userId })
const userKeyring = select.keyring(state, { type: USER, name: userId }, starterKeys)
const keys = getLatestGeneration(userKeyring)
const user = { userName, userId, keys }

return {
userName,
userId,
keys: userKeys,
}
return { user, userKeyring }
}
9 changes: 8 additions & 1 deletion packages/auth/src/connection/test/authentication.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from 'util/testing/index.js'
import { describe, expect, it } from 'vitest'
import type { InviteeDeviceContext } from '../types.js'
import { createDevice } from 'device/createDevice.js'

describe('connection', () => {
describe('authentication', () => {
Expand Down Expand Up @@ -248,7 +249,7 @@ describe('connection', () => {

// Bob invites and admits his phone

const phone = bob.phone!
let phone = bob.phone!

{
const { seed } = bob.team.inviteDevice()
Expand Down Expand Up @@ -279,6 +280,12 @@ describe('connection', () => {
{
// Bob invites his phone again

// The phone needs a new deviceId, otherwise the connection will immediately
// fail with DEVICE_REMOVED error after being established. It also gets a new
// device key as the old one could be compromised.
phone = createDevice({ userId: bob.userId, deviceName: phone.deviceName })
bob.phone = phone

const { seed } = bob.team.inviteDevice()
const phoneContext: InviteeDeviceContext = {
userName: bob.userName,
Expand Down
43 changes: 29 additions & 14 deletions packages/auth/src/team/Team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
UserWithSecrets,
} from '@localfirst/crdx'
import {
createKeyring,
createKeyset,
createStore,
getLatestGeneration,
Expand Down Expand Up @@ -137,9 +138,12 @@ export class Team extends EventEmitter<TeamEvents> {
}

this.state = this.store.getState()
this.updateUserKeys()

// Wire up event listeners
this.on('updated', () => {
this.updateUserKeys()

// If we're admin, check for pending key rotations
this.checkForPendingKeyRotations()
})
Expand Down Expand Up @@ -502,20 +506,17 @@ export class Team extends EventEmitter<TeamEvents> {
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.
// lockboxes that can be opened by an ephemeral keyset generated from the secret invitation seed.
const starterKeys = invitations.generateStarterKeys(seed)
const lockboxUserKeysForDeviceStarterKeys = lockbox.create(this.context.user.keys, starterKeys)
const allUserKeys = Object.values(this.userKeyring())
const lockboxes = allUserKeys.map(keys => lockbox.create(keys, starterKeys))

const { id } = invitation

// Post invitation to graph
this.dispatch({
type: 'INVITE_DEVICE',
payload: {
invitation,
lockboxes: [lockboxUserKeysForDeviceStarterKeys],
},
payload: { invitation, lockboxes },
})

// Return the secret invitation seed (to pass on to invitee) and the invitation id (which could be used to revoke later)
Expand Down Expand Up @@ -585,8 +586,9 @@ export class Team extends EventEmitter<TeamEvents> {

const { id } = proof

// we know the team keys, so we can put them in a lockbox for the new member now (even if we're not an admin)
const lockboxTeamKeysForMember = lockbox.create(this.teamKeys(), memberKeys)
// we know the team keys, so we can put them in lockboxes for the new member now (even if we're not an admin)
const allTeamKeys = Object.values(this.teamKeyring())
const lockboxes = allTeamKeys.map(keys => lockbox.create(keys, memberKeys))

// Post admission to the graph
this.dispatch({
Expand All @@ -595,7 +597,7 @@ export class Team extends EventEmitter<TeamEvents> {
id,
userName,
memberKeys: redactKeys(memberKeys),
lockboxes: [lockboxTeamKeysForMember],
lockboxes,
},
})
}
Expand Down Expand Up @@ -623,20 +625,21 @@ export class Team extends EventEmitter<TeamEvents> {
}

/** Once the new member has received the graph and can instantiate the team, they call this to add their device. */
public join = (teamKeyring: Keyring) => {
public join = (teamKeyring: Keyring, userKeyring = createKeyring(this.context.user.keys)) => {
assert(!this.isServer, "Can't join as member on server")

const { user, device } = this.context
const { device } = this.context
const teamKeys = getLatestGeneration(teamKeyring)

const lockboxUserKeysForDevice = lockbox.create(user.keys, device.keys)
// Create a lockbox for each generation of user keys
const lockboxes = Object.values(userKeyring).map(keys => lockbox.create(keys, device.keys))

this.dispatch(
{
type: 'ADD_DEVICE',
payload: {
device: redactDevice(device),
lockboxes: [lockboxUserKeysForDevice],
lockboxes,
},
},
teamKeys
Expand Down Expand Up @@ -782,6 +785,9 @@ export class Team extends EventEmitter<TeamEvents> {
public keys = (scope: KeyMetadata | KeyScope) =>
select.keys(this.state, this.context.device.keys, scope)

public userKeyring = (userId = this.userId) =>
select.keyring(this.state, { type: USER, name: userId }, this.context.device.keys)

/** Returns the keys for the given role. */
public roleKeys = (roleName: string, generation?: number) =>
this.keys({ type: KeyType.ROLE, name: roleName, generation })
Expand Down Expand Up @@ -823,6 +829,15 @@ export class Team extends EventEmitter<TeamEvents> {
if (isForServer) device.keys = newKeys // (a server plays the role of both a user and a device)
}

private updateUserKeys() {
const { user } = this.context
const latestUserKeys = getLatestGeneration(this.userKeyring())

if (latestUserKeys && user.keys.generation < latestUserKeys.generation) {
user.keys = latestUserKeys
}
}

private checkForPendingKeyRotations() {
// Only admins can rotate keys
if (!this.memberIsAdmin(this.userId)) {
Expand Down
4 changes: 2 additions & 2 deletions packages/auth/src/team/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,9 @@ const getTransforms = (action: TeamAction): Transform[] => {
}

case 'REMOVE_DEVICE': {
const { deviceId } = action.payload
const { deviceId, lockboxes } = action.payload
return [
removeDevice(deviceId), // Remove this device from the member's list of devices
removeDevice(deviceId, lockboxes), // Remove this device from the member's list of devices
]
}

Expand Down
1 change: 1 addition & 0 deletions packages/auth/src/team/selectors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export * from './hasRole.js'
export * from './hasServer.js'
export * from './invitation.js'
export * from './keyMap.js'
export * from './keyring.js'
export * from './keys.js'
export * from './lockboxesInScope.js'
export * from './member.js'
Expand Down
13 changes: 13 additions & 0 deletions packages/auth/src/team/selectors/keyring.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { type KeysetWithSecrets, createKeyring, type KeyScope } from '@localfirst/crdx'
import { type TeamState } from 'team/types.js'
import { keyMap } from './keyMap.js'

/**
* Returns a keyring containing all generations of keys for the given scope.
*/

export const keyring = (state: TeamState, scope: KeyScope, keys: KeysetWithSecrets) => {
const foo = keyMap(state, keys)
const allKeys = foo[scope.type]?.[scope.name]
return createKeyring(allKeys)
}
10 changes: 4 additions & 6 deletions packages/auth/src/team/selectors/teamKeyring.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { type KeysetWithSecrets, createKeyring } from '@localfirst/crdx'
import { type KeysetWithSecrets } from '@localfirst/crdx'
import { type TeamState } from 'team/types.js'
import { KeyType } from 'util/types.js'
import { keyMap } from './keyMap.js'
import { keyring } from './keyring.js'

const { TEAM } = KeyType

export const teamKeyring = (state: TeamState, keys: KeysetWithSecrets) => {
const allTeamKeys = keyMap(state, keys)[TEAM][TEAM]
return createKeyring(allTeamKeys)
}
export const teamKeyring = (state: TeamState, keys: KeysetWithSecrets) =>
keyring(state, { type: TEAM, name: TEAM }, keys)
6 changes: 6 additions & 0 deletions packages/auth/src/team/test/devices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ describe('Team', () => {

// Keys have never been rotated
expect(alice.team.teamKeys().generation).toBe(0)
expect(bob.team.members(bob.userId)?.keys.generation).toBe(0)
expect(bob.user.keys.generation).toBe(0)
const { secretKey } = alice.team.teamKeys()

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

// User keys have now been rotated once
expect(bob.team.members(bob.userId)?.keys.generation).toBe(1)
expect(bob.user.keys.generation).toBe(1)

// Team keys have now been rotated once
expect(bob.team.teamKeys().generation).toBe(1)
expect(bob.team.teamKeys().secretKey).not.toBe(secretKey)
Expand Down
Loading

0 comments on commit eaf5956

Please sign in to comment.