From 9f805bef437354d91e826af06fcf525e73c6755a Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:48:18 -0400 Subject: [PATCH 01/14] Make lockboxes required on most actions --- packages/auth/src/team/types.ts | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/auth/src/team/types.ts b/packages/auth/src/team/types.ts index 28a1259f..8c6dde19 100644 --- a/packages/auth/src/team/types.ts +++ b/packages/auth/src/team/types.ts @@ -94,6 +94,11 @@ type BasePayload = { lockboxes?: Lockbox[] } +type BasePayloadLockboxesRequired = { + // Some actions require lockboxes to be present + lockboxes: Lockbox[] +} + export type RootAction = { type: typeof ROOT payload: BasePayload & { @@ -105,7 +110,7 @@ export type RootAction = { export type AddMemberAction = { type: 'ADD_MEMBER' - payload: BasePayload & { + payload: BasePayloadLockboxesRequired & { member: Member roles?: string[] } @@ -113,14 +118,14 @@ export type AddMemberAction = { export type RemoveMemberAction = { type: 'REMOVE_MEMBER' - payload: BasePayload & { + payload: BasePayloadLockboxesRequired & { userId: string } } export type AddRoleAction = { type: 'ADD_ROLE' - payload: BasePayload & Role + payload: BasePayloadLockboxesRequired & Role } export type RemoveRoleAction = { @@ -132,7 +137,7 @@ export type RemoveRoleAction = { export type AddMemberRoleAction = { type: 'ADD_MEMBER_ROLE' - payload: BasePayload & { + payload: BasePayloadLockboxesRequired & { userId: string roleName: string permissions?: PermissionsMap @@ -141,7 +146,7 @@ export type AddMemberRoleAction = { export type RemoveMemberRoleAction = { type: 'REMOVE_MEMBER_ROLE' - payload: BasePayload & { + payload: BasePayloadLockboxesRequired & { userId: string roleName: string } @@ -149,14 +154,14 @@ export type RemoveMemberRoleAction = { export type AddDeviceAction = { type: 'ADD_DEVICE' - payload: BasePayload & { + payload: BasePayloadLockboxesRequired & { device: Device } } export type RemoveDeviceAction = { type: 'REMOVE_DEVICE' - payload: BasePayload & { + payload: BasePayloadLockboxesRequired & { deviceId: string } } @@ -201,35 +206,35 @@ export type AdmitDeviceAction = { export type ChangeMemberKeysAction = { type: 'CHANGE_MEMBER_KEYS' - payload: BasePayload & { + payload: BasePayloadLockboxesRequired & { keys: Keyset } } export type RotateKeysAction = { type: 'ROTATE_KEYS' - payload: BasePayload & { + payload: BasePayloadLockboxesRequired & { userId: string } } export type AddServerAction = { type: 'ADD_SERVER' - payload: BasePayload & { + payload: BasePayloadLockboxesRequired & { server: Server } } export type RemoveServerAction = { type: 'REMOVE_SERVER' - payload: BasePayload & { + payload: BasePayloadLockboxesRequired & { host: Host } } export type ChangeServerKeysAction = { type: 'CHANGE_SERVER_KEYS' - payload: BasePayload & { + payload: BasePayloadLockboxesRequired & { keys: Keyset } } @@ -250,9 +255,7 @@ export type SetTeamNameAction = { export type AddLockboxesAction = { type: 'ADD_LOCKBOXES' - payload: BasePayload & { - lockboxes: Lockbox[] - } + payload: BasePayloadLockboxesRequired } export type SetMetadataAction = { From 47ace13ad8c08c0be64cf1a2e88f622e41351a9a Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:35:25 -0400 Subject: [PATCH 02/14] Add tests for missing lockboxes --- .../auth/src/connection/test/sync.test.ts | 2 +- packages/auth/src/team/Team.ts | 29 +++- packages/auth/src/team/test/devices.test.ts | 75 +++++++++++ packages/auth/src/team/test/keys.test.ts | 67 +++++++++- packages/auth/src/team/test/members.test.ts | 59 ++++++++ packages/auth/src/team/test/roles.test.ts | 126 ++++++++++++++++-- packages/auth/src/team/types.ts | 4 +- packages/auth/src/team/validate.ts | 18 +++ packages/crdx/src/sync/test/sync.test.ts | 14 -- 9 files changed, 364 insertions(+), 30 deletions(-) diff --git a/packages/auth/src/connection/test/sync.test.ts b/packages/auth/src/connection/test/sync.test.ts index c8b0b95a..e4eb819f 100644 --- a/packages/auth/src/connection/test/sync.test.ts +++ b/packages/auth/src/connection/test/sync.test.ts @@ -178,7 +178,7 @@ describe('connection', () => { expect(charlie.team.hasRole('managers')).toBe(true) }) - it('syncs up three ways - changes made after connecting', async () => { + it('syncs up three ways - changes made after connecting', async () => { const { alice, bob, charlie } = setup('alice', 'bob', 'charlie') // ðŸ‘ĐðŸū<->ðŸ‘ĻðŸŧ‍ðŸĶē<->ðŸ‘ģðŸ―â€â™‚ïļ Alice, Bob, and Charlie all connect to each other diff --git a/packages/auth/src/team/Team.ts b/packages/auth/src/team/Team.ts index 05dce8bf..0e27c992 100644 --- a/packages/auth/src/team/Team.ts +++ b/packages/auth/src/team/Team.ts @@ -253,6 +253,10 @@ export class Team extends EventEmitter { return select.members(this.state, userIdOrIds, options) // Many members } + public hasMember(userId: string): boolean { + return select.hasMember(this.state, userId) + } + /** * Adds a member to the team, along with an (optional) device. Since this method assumes that you * know the member's secret keys, it only makes sense for unit tests. In real-world scenarios, @@ -292,6 +296,12 @@ export class Team extends EventEmitter { /** Remove a member from the team */ public remove = (userId: string) => { + this.logger.debug('Removing user', userId) + if (!this.hasMember(userId)) { + this.logger.warn('Attempted to remove nonexistent member', userId) + return + } + // Create new keys & lockboxes for any keys this person had access to const { lockboxes, updatedUserKeys } = this.rotateKeys({ type: USER, name: userId }) @@ -358,6 +368,8 @@ export class Team extends EventEmitter { } } + this.logger.debug('Adding role', role.roleName) + // We're creating this role so we need to generate new keys const roleKeys = createKeyset({ type: KeyType.ROLE, name: role.roleName }, this.seed) @@ -380,6 +392,7 @@ export class Team extends EventEmitter { /** Remove a role from the team */ public removeRole = (roleName: string) => { + this.logger.debug('Removing role', roleName) this._isRoleRemovable(roleName, true) this.dispatch({ @@ -507,6 +520,7 @@ export class Team extends EventEmitter { /** Remove a member's device */ public removeDevice = (deviceId: string) => { + this.logger.debug('Removing device', deviceId) if (!this.hasDevice(deviceId)) throw new Error(`Device ${deviceId} not found`) // Create new keys & lockboxes for any keys this device had access to @@ -784,6 +798,7 @@ export class Team extends EventEmitter { * other.) */ public addServer = (server: Server) => { + this.logger.debug('Adding server', server.host) const lockboxes = this.createMemberLockboxes(castServer.toMember(server)) this.dispatch({ @@ -794,6 +809,7 @@ export class Team extends EventEmitter { /** Removes a server from the team. */ public removeServer = (host: string) => { + this.logger.debug('Removing server', host) const { lockboxes } = this.rotateKeys({ type: KeyType.SERVER, name: host }) this.dispatch({ type: 'REMOVE_SERVER', @@ -950,6 +966,7 @@ export class Team extends EventEmitter { public changeKeys = (newKeys: KeysetWithSecrets) => { const { device, user } = this.context const { type } = newKeys + this.logger.debug('Changing user or device keys', type) assert(type !== DEVICE, "Can't change device keys") const isForUser = type === USER @@ -987,6 +1004,7 @@ export class Team extends EventEmitter { } private checkForPendingKeyRotations() { + this.logger.debug('Checking for pending key rotations') // Only admins can rotate keys if (!this.memberIsAdmin(this.userId)) { return @@ -1026,7 +1044,7 @@ export class Team extends EventEmitter { * compromised scope. If it is just a scope, new keys will be randomly generated for that scope. */ private readonly rotateKeys = (compromised: KeyScope | KeysetWithSecrets): RotatedLockboxesWithUpdatedUserKeys => { - this.logger.debug('rotating keys for scope', getScope(compromised)) + this.logger.debug('Rotating keys for scope', getScope(compromised)) const newKeyset = isKeyset(compromised) ? compromised // We're given a keyset - use it as the new keys : createKeyset(compromised) // We're just given a scope - generate new keys for it @@ -1068,6 +1086,15 @@ export class Team extends EventEmitter { } } + /** + * After rotation user keys need to be updated, if necessary + * + * NOTE: some cases (e.g. removing a user) produce new keys for a given user but we don't want to update their keys since they won't propagate + * + * @param newUserKeys Set of USER keysets that were updated during rotation + * @param lockboxes Lockboxes generated during rotation + * @param skipUserIds User IDs that we shouldn't update + */ private readonly updateMemberKeysWithLockboxes = (newUserKeys: Set, lockboxes: lockbox.Lockbox[], skipUserIds: string[] = []): void => { for (const keyset of newUserKeys) { if (skipUserIds.includes(keyset.name)) { diff --git a/packages/auth/src/team/test/devices.test.ts b/packages/auth/src/team/test/devices.test.ts index e28d2e57..1c055b1f 100644 --- a/packages/auth/src/team/test/devices.test.ts +++ b/packages/auth/src/team/test/devices.test.ts @@ -128,5 +128,80 @@ describe('Team', () => { expect(bobUser.keys.generation).toBe(1) expect(bobUser.keysHistory).toHaveLength(2) }) + + it('fails to add a device when no lockboxes provided', () => { + const { alice, bob } = setup() + expect(alice.team.members().length).toBe(2) + + // generate bob's phone + const phone = redactDevice(bob.phone!) + + // bob's phone doesn't exist yet + expect(bob.team.hasDevice(phone.deviceId)).toBe(false) + + const tryToAddDeviceWithoutLockboxesEmpty = () => { + bob.team.dispatch({ + type: 'ADD_DEVICE', + payload: { + device: phone, + lockboxes: [], + }, + }) + } + + const tryToAddDeviceWithoutLockboxesNullish = () => { + alice.team.dispatch({ + type: 'ADD_DEVICE', + payload: { + device: phone, + lockboxes: undefined, + } as any, + }) + } + + expect(tryToAddDeviceWithoutLockboxesEmpty).toThrow() + expect(tryToAddDeviceWithoutLockboxesNullish).toThrow() + + // bob's phone still doesn't exist + expect(bob.team.hasDevice(phone.deviceId)).toBe(false) + }) + + it('fails to remove a device when no lockboxes provided', () => { + const { alice, bob } = setup() + expect(alice.team.members().length).toBe(2) + + // Add bob's phone + const phone = redactDevice(bob.phone!) + bob.team.addForTesting(bob.user, [], [], phone) + + // bob's phone exists + expect(bob.team.hasDevice(phone.deviceId)).toBe(true) + + const tryToRemoveDeviceWithoutLockboxesEmpty = () => { + bob.team.dispatch({ + type: 'REMOVE_DEVICE', + payload: { + deviceId: phone.deviceId, + lockboxes: [], + }, + }) + } + + const tryToRemoveDeviceWithoutLockboxesNullish = () => { + alice.team.dispatch({ + type: 'REMOVE_DEVICE', + payload: { + deviceId: phone.deviceId, + lockboxes: undefined, + } as any, + }) + } + + expect(tryToRemoveDeviceWithoutLockboxesEmpty).toThrow() + expect(tryToRemoveDeviceWithoutLockboxesNullish).toThrow() + + // bob's phone still exists + expect(bob.team.hasDevice(phone.deviceId)).toBe(true) + }) }) }) diff --git a/packages/auth/src/team/test/keys.test.ts b/packages/auth/src/team/test/keys.test.ts index 3d164488..c45df3c0 100644 --- a/packages/auth/src/team/test/keys.test.ts +++ b/packages/auth/src/team/test/keys.test.ts @@ -89,9 +89,9 @@ describe('Team', () => { }) it("Bob can't change Alice's keys", () => { - const { bob } = setup('alice', { user: 'bob', admin: false }) + const { bob, alice } = setup('alice', { user: 'bob', admin: false }) - const newKeys = createKeyset({ type: USER, name: 'alice' }) + const newKeys = createKeyset({ type: USER, name: alice.userId }) const tryToChangeAlicesKeys = () => { bob.team.changeKeys(newKeys) } @@ -115,8 +115,8 @@ describe('Team', () => { it("Eve can't change Bob's keys", () => { // Eve is tricker than Bob -- rather than try to go through the team object, she's going to // try to tamper with the team chain directly. - const { eve } = setup('alice', 'bob', { user: 'eve', admin: false }) - const newKeys = createKeyset({ type: USER, name: 'bob' }) + const { eve, bob } = setup('alice', 'bob', { user: 'eve', admin: false }) + const newKeys = createKeyset({ type: USER, name: bob.userId }) // @ts-expect-error - rotateKeys is private const { lockboxes } = eve.team.rotateKeys(newKeys) @@ -133,5 +133,64 @@ describe('Team', () => { expect(tryToChangeBobsKeys).toThrow() }) + + it("Alice can't change Bob's keys when no lockboxes are provided", () => { + // Alice, despite having permissions, tries to rotate Bob's keys without publishing rotated + // team/role keys by initiating a key change without lockboxes + const { alice, bob } = setup('alice', 'bob', { user: 'eve', admin: false }) + const newKeys = createKeyset({ type: USER, name: bob.userId }) + + const tryToChangeBobsKeysWithoutLockboxesEmpty = () => { + alice.team.dispatch({ + type: 'CHANGE_MEMBER_KEYS', + payload: { + keys: redactKeys(newKeys), + lockboxes: [], + }, + }) + } + + const tryToChangeBobsKeysWithoutLockboxesNullish = () => { + alice.team.dispatch({ + type: 'CHANGE_MEMBER_KEYS', + payload: { + keys: redactKeys(newKeys), + lockboxes: undefined, + } as any, + }) + } + + expect(tryToChangeBobsKeysWithoutLockboxesEmpty).toThrow() + expect(tryToChangeBobsKeysWithoutLockboxesNullish).toThrow() + }) + + it("Alice can't rotate Bob's keys when no lockboxes are provided", () => { + // Alice, despite having permissions, tries to rotate Bob's keys without publishing rotated + // team/role keys by initiating a key change without lockboxes + const { alice, bob } = setup('alice', 'bob', { user: 'eve', admin: false }) + + const tryToRotateBobsKeysWithoutLockboxesEmpty = () => { + alice.team.dispatch({ + type: 'ROTATE_KEYS', + payload: { + userId: bob.userId, + lockboxes: [], + }, + }) + } + + const tryToRotateBobsKeysWithoutLockboxesNullish = () => { + alice.team.dispatch({ + type: 'ROTATE_KEYS', + payload: { + userId: bob.userId, + lockboxes: undefined, + } as any, + }) + } + + expect(tryToRotateBobsKeysWithoutLockboxesEmpty).toThrow() + expect(tryToRotateBobsKeysWithoutLockboxesNullish).toThrow() + }) }) }) diff --git a/packages/auth/src/team/test/members.test.ts b/packages/auth/src/team/test/members.test.ts index e081f94e..e5a07fdf 100644 --- a/packages/auth/src/team/test/members.test.ts +++ b/packages/auth/src/team/test/members.test.ts @@ -2,6 +2,7 @@ import { ADMIN } from 'role/index.js' import { setup } from 'util/testing/index.js' import 'util/testing/expect/toLookLikeKeyset.js' import { describe, expect, it } from 'vitest' +import { redactUser } from '../redactUser.js' describe('Team', () => { describe('members', () => { @@ -30,6 +31,36 @@ describe('Team', () => { expect(bob2.userName).toBe('bob') }) + it('fails to add a member when no lockboxes provided', () => { + const { alice, bob, eve } = setup('alice', 'bob', { user: 'eve', addToTeam: false, admin: false, member: false }) + expect(alice.team.members().length).toBe(2) + + const potentialMember = redactUser(eve.user) + + const tryToAddMemberWithoutLockboxesEmpty = () => { + alice.team.dispatch({ + type: 'ADD_MEMBER', + payload: { + member: potentialMember, + lockboxes: [], + }, + }) + } + + const tryToAddMemberWithoutLockboxesNullish = () => { + alice.team.dispatch({ + type: 'ADD_MEMBER', + payload: { + member: potentialMember, + lockboxes: undefined, + } as any, + }) + } + + expect(tryToAddMemberWithoutLockboxesEmpty).toThrow() + expect(tryToAddMemberWithoutLockboxesNullish).toThrow() + }) + it('makes lockboxes for added members', () => { // Alice creates a team, adds Bob const { bob } = setup('alice', { user: 'bob', admin: false }) @@ -114,6 +145,34 @@ describe('Team', () => { expect(bobUser.keysHistory).toHaveLength(1) }) + it('fails to remove a member when no lockboxes provided', () => { + const { alice, bob } = setup('alice', 'bob') + expect(alice.team.members().length).toBe(2) + + const tryToRemoveMemberWithoutLockboxesEmpty = () => { + alice.team.dispatch({ + type: 'REMOVE_MEMBER', + payload: { + userId: bob.userId, + lockboxes: [], + }, + }) + } + + const tryToRemoveMemberWithoutLockboxesNullish = () => { + alice.team.dispatch({ + type: 'REMOVE_MEMBER', + payload: { + userId: bob.userId, + lockboxes: undefined, + } as any, + }) + } + + expect(tryToRemoveMemberWithoutLockboxesEmpty).toThrow() + expect(tryToRemoveMemberWithoutLockboxesNullish).toThrow() + }) + it("doesn't do anything if asked to remove a nonexistent member", () => { const { alice } = setup('alice') diff --git a/packages/auth/src/team/test/roles.test.ts b/packages/auth/src/team/test/roles.test.ts index afaf0109..4296ee02 100644 --- a/packages/auth/src/team/test/roles.test.ts +++ b/packages/auth/src/team/test/roles.test.ts @@ -50,6 +50,44 @@ describe('Team', () => { expect(alice.team.membersInRole(MANAGERS).map(m => m.userName)).toEqual(['alice', 'bob']) }) + it('fails to add a role when no lockboxes provided', () => { + const { alice, bob } = setup('alice', { user: 'bob', admin: false }) + expect(alice.team.members().length).toBe(2) + + // Managers role doesn't exist yet + expect(alice.team.hasRole(MANAGERS)) + + const tryToAddRoleWithoutLockboxesEmpty = () => { + alice.team.dispatch({ + type: 'ADD_ROLE', + payload: { + roleName: ADMIN, + createdBy: alice.userId, + permissions: undefined, + lockboxes: [], + }, + }) + } + + const tryToAddRoleWithoutLockboxesNullish = () => { + alice.team.dispatch({ + type: 'ADD_ROLE', + payload: { + roleName: ADMIN, + createdBy: alice.userId, + permissions: undefined, + lockboxes: undefined, + } as any, + }) + } + + expect(tryToAddRoleWithoutLockboxesEmpty).toThrow() + expect(tryToAddRoleWithoutLockboxesNullish).toThrow() + + // Managers role still doesn't exist + expect(alice.team.hasRole(MANAGERS)).toBe(false) + }) + it('admins have access to all role keys', () => { const { alice } = setup('alice') @@ -87,6 +125,42 @@ describe('Team', () => { expect(bobsAdminKeys).toLookLikeKeyset() }) + it('fails to add a member to role when no lockboxes provided', () => { + const { alice, bob } = setup('alice', { user: 'bob', admin: false }) + expect(alice.team.members().length).toBe(2) + + // ðŸ‘ĻðŸŧ‍ðŸĶē Bob isn't an admin yet + expect(alice.team.memberIsAdmin(bob.userId)).toBe(false) + + const tryToAddMemberWithoutLockboxesEmpty = () => { + alice.team.dispatch({ + type: 'ADD_MEMBER_ROLE', + payload: { + userId: bob.userId, + roleName: ADMIN, + lockboxes: [], + }, + }) + } + + const tryToAddMemberWithoutLockboxesNullish = () => { + alice.team.dispatch({ + type: 'ADD_MEMBER_ROLE', + payload: { + userId: bob.userId, + roleName: ADMIN, + lockboxes: undefined, + } as any, + }) + } + + expect(tryToAddMemberWithoutLockboxesEmpty).toThrow() + expect(tryToAddMemberWithoutLockboxesNullish).toThrow() + + // ðŸ‘ĻðŸŧ‍ðŸĶē Bob still isn't an admin + expect(alice.team.memberIsAdmin(bob.userId)).toBe(false) + }) + it('removes a member from a role', () => { const { alice, bob } = setup('alice', 'bob') @@ -118,6 +192,42 @@ describe('Team', () => { expect(bobLooksForAdminKeys).toThrow() }) + it('fails to remove a member from role when no lockboxes provided', () => { + const { alice, bob } = setup('alice', 'bob') + expect(alice.team.members().length).toBe(2) + + // ðŸ‘ĻðŸŧ‍ðŸĶē Bob is an admin + expect(alice.team.memberIsAdmin(bob.userId)).toBe(true) + + const tryToRemoveMemberWithoutLockboxesEmpty = () => { + alice.team.dispatch({ + type: 'REMOVE_MEMBER_ROLE', + payload: { + userId: bob.userId, + roleName: ADMIN, + lockboxes: [], + }, + }) + } + + const tryToRemoveMemberWithoutLockboxesNullish = () => { + alice.team.dispatch({ + type: 'REMOVE_MEMBER_ROLE', + payload: { + userId: bob.userId, + roleName: ADMIN, + lockboxes: undefined, + } as any, + }) + } + + expect(tryToRemoveMemberWithoutLockboxesEmpty).toThrow() + expect(tryToRemoveMemberWithoutLockboxesNullish).toThrow() + + // ðŸ‘ĻðŸŧ‍ðŸĶē Bob is still an admin + expect(alice.team.memberIsAdmin(bob.userId)).toBe(true) + }) + it('self-assigns a role using pre-shared keys', () => { const { alice, bob } = setup('alice', { user: 'bob', admin: false, member: false }) @@ -307,7 +417,7 @@ describe('Team', () => { }) it('does not allow a non-admin to remove a member', () => { - const { bob } = setup( + const { bob, charlie } = setup( 'alice', { user: 'bob', admin: false }, { user: 'charlie', admin: false } @@ -315,7 +425,7 @@ describe('Team', () => { // ðŸ‘ĻðŸŧ‍ðŸĶē Bob tries to remove ðŸ‘ģðŸ―â€â™‚ïļ Charlie const remove = () => { - bob.team.remove('charlie') + bob.team.remove(charlie.userId) } // ðŸ‘ĻðŸŧ‍ðŸĶē Bob can't because he is not an admin @@ -323,7 +433,7 @@ describe('Team', () => { }) it('does not allow a non-admin to add a member to a role', () => { - const { bob } = setup( + const { bob, charlie } = setup( 'alice', { user: 'bob', admin: false }, { user: 'charlie', admin: false } @@ -331,7 +441,7 @@ describe('Team', () => { // ðŸ‘ĻðŸŧ‍ðŸĶē Bob tries to make ðŸ‘ģðŸ―â€â™‚ïļ Charlie an admin const add = () => { - bob.team.addMemberRole('charlie', ADMIN) + bob.team.addMemberRole(charlie.userId, ADMIN) } // ðŸ‘ĻðŸŧ‍ðŸĶē Bob can't because he is not an admin @@ -339,14 +449,14 @@ describe('Team', () => { }) it('does not allow a non-admin to remove a member from a role', () => { - const { charlie } = setup('alice', 'bob', { + const { charlie, bob } = setup('alice', 'bob', { user: 'charlie', admin: false, }) // ðŸ‘ģðŸ―â€â™‚ïļ Charlie tries to remove ðŸ‘ĻðŸŧ‍ðŸĶē Bob as admin const remove = () => { - charlie.team.removeMemberRole('bob', ADMIN) + charlie.team.removeMemberRole(bob.userId, ADMIN) } // ðŸ‘ģðŸ―â€â™‚ïļ Charlie can't because he is not an admin @@ -357,7 +467,7 @@ describe('Team', () => { const { alice } = setup('alice', { user: 'bob', admin: false }) const remove = () => { - alice.team.removeMemberRole('alice', ADMIN) + alice.team.removeMemberRole(alice.userId, ADMIN) } expect(remove).toThrow() @@ -367,7 +477,7 @@ describe('Team', () => { const { alice } = setup('alice', 'bob') const remove = () => { - alice.team.removeMemberRole('alice', ADMIN) + alice.team.removeMemberRole(alice.userId, ADMIN) } expect(remove).not.toThrow() diff --git a/packages/auth/src/team/types.ts b/packages/auth/src/team/types.ts index 8c6dde19..1d544e7e 100644 --- a/packages/auth/src/team/types.ts +++ b/packages/auth/src/team/types.ts @@ -89,12 +89,12 @@ export const isNewTeam = (options: NewOrExisting): options is NewTeamOptions => // ********* ACTIONS -type BasePayload = { +export type BasePayload = { // Every action might include new lockboxes lockboxes?: Lockbox[] } -type BasePayloadLockboxesRequired = { +export type BasePayloadLockboxesRequired = { // Some actions require lockboxes to be present lockboxes: Lockbox[] } diff --git a/packages/auth/src/team/validate.ts b/packages/auth/src/team/validate.ts index e17fd8ab..08364ed7 100644 --- a/packages/auth/src/team/validate.ts +++ b/packages/auth/src/team/validate.ts @@ -11,6 +11,7 @@ import { type TeamStateValidatorSet, } from './types.js' import { MEMBER } from '../role/constants.js' +import { isActionAllowedWithoutLockboxes } from './lockboxesRequiredForAction.js' export const validate: TeamStateValidator = (previousState: TeamState, link: TeamLink, extendableLogger?: Logger) => { const logger = extendableLogger != null ? extendableLogger.extend('validate') : new Logger({ moduleName: 'auth:validate' }) @@ -170,6 +171,23 @@ const validators: TeamStateValidatorSet = { } return VALID }, + + /** Validate the presence of lockboxes on an action payload when required */ + lockboxesArePresentWhenRequired(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { + const logger = extendableLogger.extend('lockboxesArePresentWhenRequired') + const action = link.body + if (isActionAllowedWithoutLockboxes(action)) { + return VALID + } + const { lockboxes } = link.body.payload + if (lockboxes == null) { + return fail(`Action ${action.type} requires lockboxes but value on payload was nullish`, previousState, link, logger) + } + if (lockboxes.length === 0) { + return fail(`Action ${action.type} requires lockboxes but value on payload was empty`, previousState, link, logger) + } + return VALID + }, } const fail = (message: string, previousState: TeamState, link: TeamLink, extendableLogger: Logger) => { diff --git a/packages/crdx/src/sync/test/sync.test.ts b/packages/crdx/src/sync/test/sync.test.ts index aad440ab..f26dc34d 100644 --- a/packages/crdx/src/sync/test/sync.test.ts +++ b/packages/crdx/src/sync/test/sync.test.ts @@ -642,20 +642,6 @@ describe('sync', () => { }) describe('failure handling', () => { - const appendLinkInThePast = (graph: Graph, user: UserWithSecrets) => { - const IN_THE_PAST = new Date('2020-01-01').getTime() - const now = Date.now() - setSystemTime(IN_THE_PAST) - const updatedGraph = append({ - graph, - action: { type: 'FOO', payload: 'pizza' }, - user, - keys, - }) - setSystemTime(now) - return updatedGraph - } - it('single failure', () => { const { userRecords: { alice, eve }, From 21884835d00ff58ba39b2c24526eb57f49c8841b Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:35:57 -0400 Subject: [PATCH 03/14] Missed a file --- .../src/team/lockboxesRequiredForAction.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 packages/auth/src/team/lockboxesRequiredForAction.ts diff --git a/packages/auth/src/team/lockboxesRequiredForAction.ts b/packages/auth/src/team/lockboxesRequiredForAction.ts new file mode 100644 index 00000000..f382a8db --- /dev/null +++ b/packages/auth/src/team/lockboxesRequiredForAction.ts @@ -0,0 +1,23 @@ +import { type TeamAction } from './types.js' + +// These actions don't require lockboxes to be present on payloads +const NON_LOCKBOX_REQUIRED_ACTIONS: Array = [ + 'SET_METADATA', + 'MESSAGE', + 'SET_TEAM_NAME', + 'ADMIT_DEVICE', + 'ADMIT_MEMBER', + 'REVOKE_INVITATION', + 'INVITE_DEVICE', + 'INVITE_MEMBER', + 'REMOVE_ROLE', + 'ROOT', +] + +export const isActionAllowedWithoutLockboxes = (action: TeamAction): boolean => { + return isActionTypeAllowedWithoutLockboxes(action.type) +} + +export const isActionTypeAllowedWithoutLockboxes = (actionType: TeamAction['type']): boolean => { + return NON_LOCKBOX_REQUIRED_ACTIONS.includes(actionType) +} From 7eea760f278ee3006949227a155df449a9c1dee0 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:24:51 -0400 Subject: [PATCH 04/14] Validate specific lockboxes on actions --- package.json | 8 +- packages/auth/src/connection/Connection.ts | 1 + .../connection/test/authentication.test.ts | 3 +- packages/auth/src/team/Team.ts | 19 +- .../src/team/lockboxesRequiredForAction.ts | 2 +- packages/auth/src/team/reducer.ts | 1 + packages/auth/src/team/selectors/member.ts | 9 + .../auth/src/team/selectors/membersInRole.ts | 10 +- packages/auth/src/team/selectors/server.ts | 9 + packages/auth/src/team/test/members.test.ts | 46 +++ packages/auth/src/team/types.ts | 11 +- packages/auth/src/team/validate.ts | 292 +++++++++++++++++- 12 files changed, 383 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index 0302a751..369ce34c 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,10 @@ "lint": "xo", "linklocal": "node ./scripts/link-local.js", "unlinklocal": "node ./scripts/link-local.js --unlink && pnpm install", - "test": "vitest", - "test:all": "run-s build lint test:run test:cy test:pw", - "test:run": "vitest run", - "test:log": "cross-env DEBUG=localfirst*,automerge* DEBUG_COLORS=1 pnpm test run", + "test": "cross-env ALLOW_ADD_MEMBER_TEST=true vitest", + "test:all": "cross-env ALLOW_ADD_MEMBER_TEST=true run-s build lint test:run test:cy test:pw", + "test:run": "cross-env ALLOW_ADD_MEMBER_TEST=true vitest run", + "test:log": "cross-env ALLOW_ADD_MEMBER_TEST=true cross-env DEBUG=localfirst*,automerge* DEBUG_COLORS=1 pnpm test run", "test:cy:ui": "pnpm -F @localfirst/taco-chat test:cy:ui", "test:cy": "pnpm -F @localfirst/taco-chat test:cy", "test:pw": "pnpm -F @localfirst/automerge-repo-todos test:pw", diff --git a/packages/auth/src/connection/Connection.ts b/packages/auth/src/connection/Connection.ts index 8405cf0a..1adc2123 100644 --- a/packages/auth/src/connection/Connection.ts +++ b/packages/auth/src/connection/Connection.ts @@ -867,6 +867,7 @@ export class Connection extends EventEmitter { }, error: error => { this.logger.error('Connection encountered an unhandled error', error) + console.error('error', error) this.#messageQueue.send(createErrorMessage(UNHANDLED, 'REMOTE')) this.emit('localError', { type: UNHANDLED, message: 'Unhandled error' }) this.#fail(UNHANDLED) diff --git a/packages/auth/src/connection/test/authentication.test.ts b/packages/auth/src/connection/test/authentication.test.ts index 2a5db0e4..c75f0ce5 100644 --- a/packages/auth/src/connection/test/authentication.test.ts +++ b/packages/auth/src/connection/test/authentication.test.ts @@ -248,7 +248,8 @@ describe('connection', () => { it('admits a first-use device before continuing authentication', async () => { const { bob } = setup('bob') bob.team.addRole(MEMBER) - const { userId: _userId, ...phone } = bob.phone! + const phone = bob.phone! + const { userId: _userId } = phone const { seed } = bob.team.inviteDevice() const phoneContext: InviteeDeviceContext = { userName: bob.userName, diff --git a/packages/auth/src/team/Team.ts b/packages/auth/src/team/Team.ts index 0e27c992..3e42e709 100644 --- a/packages/auth/src/team/Team.ts +++ b/packages/auth/src/team/Team.ts @@ -279,8 +279,8 @@ export class Team extends EventEmitter { // Post the member to the graph this.dispatch({ - type: 'ADD_MEMBER', - payload: { member, roles: [...member.roles, ...rolesWithoutLockboxes], lockboxes }, + type: 'ADD_MEMBER_TEST', + payload: { member, roles: member.roles, lockboxes }, }) } @@ -524,7 +524,7 @@ export class Team extends EventEmitter { if (!this.hasDevice(deviceId)) throw new Error(`Device ${deviceId} not found`) // Create new keys & lockboxes for any keys this device had access to - const { lockboxes, updatedUserKeys } = this.rotateKeys({ type: DEVICE, name: deviceId }) + const { lockboxes, updatedUserKeys } = this.rotateKeys({ type: DEVICE, name: deviceId }, true) // update the keys on the member records this.updateMemberKeysWithLockboxes(updatedUserKeys, lockboxes) @@ -757,7 +757,6 @@ export class Team extends EventEmitter { const lockboxUserKeysForDevice = lockbox.create(user.keys, device.keys) - this.logger.debug('Adding device on join') this.dispatch( { type: 'ADD_DEVICE', @@ -810,7 +809,7 @@ export class Team extends EventEmitter { /** Removes a server from the team. */ public removeServer = (host: string) => { this.logger.debug('Removing server', host) - const { lockboxes } = this.rotateKeys({ type: KeyType.SERVER, name: host }) + const { lockboxes } = this.rotateKeys({ type: KeyType.SERVER, name: host }, true) this.dispatch({ type: 'REMOVE_SERVER', payload: { host, lockboxes }, @@ -1043,8 +1042,8 @@ export class Team extends EventEmitter { * @param compromised If `compromised` is a keyset, that will become the new keyset for the * compromised scope. If it is just a scope, new keys will be randomly generated for that scope. */ - private readonly rotateKeys = (compromised: KeyScope | KeysetWithSecrets): RotatedLockboxesWithUpdatedUserKeys => { - this.logger.debug('Rotating keys for scope', getScope(compromised)) + private readonly rotateKeys = (compromised: KeyScope | KeysetWithSecrets, removed = false): RotatedLockboxesWithUpdatedUserKeys => { + this.logger.debug('Rotating keys for scope', getScope(compromised), removed) const newKeyset = isKeyset(compromised) ? compromised // We're given a keyset - use it as the new keys : createKeyset(compromised) // We're just given a scope - generate new keys for it @@ -1069,6 +1068,10 @@ export class Team extends EventEmitter { // Check whether we have new keys for the recipient of this lockbox const updatedKeyset = newKeysets.find(k => scopesMatch(k, oldLockbox.recipient)) const updatedRecipientKeys = updatedKeyset ? redactKeys(updatedKeyset) : undefined + // we don't want to write new keys to the compromised scope if that scope was removed (e.g. when removing a device) + if (updatedRecipientKeys != null && removed && updatedRecipientKeys.type === compromised.type && updatedRecipientKeys.name === compromised.name) { + return undefined + } const newLockbox = lockbox.rotate({ oldLockbox, newContents: newKeyset, @@ -1077,7 +1080,7 @@ export class Team extends EventEmitter { }) _addUpdatedUserKeys(updatedRecipientKeys) return newLockbox - }) + }).filter(l => l != null) }) return { diff --git a/packages/auth/src/team/lockboxesRequiredForAction.ts b/packages/auth/src/team/lockboxesRequiredForAction.ts index f382a8db..2c181e59 100644 --- a/packages/auth/src/team/lockboxesRequiredForAction.ts +++ b/packages/auth/src/team/lockboxesRequiredForAction.ts @@ -6,12 +6,12 @@ const NON_LOCKBOX_REQUIRED_ACTIONS: Array = [ 'MESSAGE', 'SET_TEAM_NAME', 'ADMIT_DEVICE', - 'ADMIT_MEMBER', 'REVOKE_INVITATION', 'INVITE_DEVICE', 'INVITE_MEMBER', 'REMOVE_ROLE', 'ROOT', + 'ADD_MEMBER_TEST', ] export const isActionAllowedWithoutLockboxes = (action: TeamAction): boolean => { diff --git a/packages/auth/src/team/reducer.ts b/packages/auth/src/team/reducer.ts index 7f9b0b96..315f7f2f 100644 --- a/packages/auth/src/team/reducer.ts +++ b/packages/auth/src/team/reducer.ts @@ -99,6 +99,7 @@ const getTransforms = (action: TeamAction): Transform[] => { ] } + case 'ADD_MEMBER_TEST': case 'ADD_MEMBER': { const { member, roles } = action.payload return [ diff --git a/packages/auth/src/team/selectors/member.ts b/packages/auth/src/team/selectors/member.ts index 64d5c87a..f47fafd2 100644 --- a/packages/auth/src/team/selectors/member.ts +++ b/packages/auth/src/team/selectors/member.ts @@ -31,3 +31,12 @@ export const members = (state: TeamState, userIds: string[], options = { include return members } + +export const allMembers = (state: TeamState, options = { includeRemoved: false }) => { + const members = [ + ...state.members, + ...(options.includeRemoved ? state.removedMembers : []), + ] + + return members +} diff --git a/packages/auth/src/team/selectors/membersInRole.ts b/packages/auth/src/team/selectors/membersInRole.ts index 309a7f4c..a75323c5 100644 --- a/packages/auth/src/team/selectors/membersInRole.ts +++ b/packages/auth/src/team/selectors/membersInRole.ts @@ -1,4 +1,4 @@ -import { ADMIN } from 'role/index.js' +import { ADMIN, type Role } from 'role/index.js' import { type Member, type TeamState } from 'team/types.js' import { memberHasRole } from './memberHasRole.js' @@ -12,3 +12,11 @@ export const membersWithRoleMarker = (state: TeamState, roleName: string): Membe state.members.filter(member => member.roles?.includes(roleName)) export const admins = (state: TeamState) => membersInRole(state, ADMIN) + +export const rolesMemberIsIn = (state: TeamState, userId: string): Role[] => { + const roles: Role[] = [] + for (const role of state.roles) { + if (memberHasRole(state, userId, role.roleName)) roles.push(role) + } + return roles +} diff --git a/packages/auth/src/team/selectors/server.ts b/packages/auth/src/team/selectors/server.ts index e3759f0c..f7dfb315 100644 --- a/packages/auth/src/team/selectors/server.ts +++ b/packages/auth/src/team/selectors/server.ts @@ -14,3 +14,12 @@ export const server = (state: TeamState, host: Host, options = { includeRemoved: return server } + +export const servers = (state: TeamState, options = { includeRemoved: false }) => { + const servers = [ + ...state.servers, + ...(options.includeRemoved ? state.removedServers : []), + ] + + return servers +} diff --git a/packages/auth/src/team/test/members.test.ts b/packages/auth/src/team/test/members.test.ts index e5a07fdf..6d4bea7a 100644 --- a/packages/auth/src/team/test/members.test.ts +++ b/packages/auth/src/team/test/members.test.ts @@ -173,6 +173,52 @@ describe('Team', () => { expect(tryToRemoveMemberWithoutLockboxesNullish).toThrow() }) + it('allows ADD_MEMBER_TEST when flag is set', () => { + const { alice, bob } = setup('alice', { user: 'bob', addToTeam: false }) + expect(alice.team.members().length).toBe(1) + + const tryToAddMemberTest = () => { + const ogFlag = process.env.ALLOW_ADD_MEMBER_TEST + try { + process.env.ALLOW_ADD_MEMBER_TEST = 'true' + alice.team.dispatch({ + type: 'ADD_MEMBER_TEST', + payload: { + member: redactUser(bob.user), + lockboxes: [], + }, + }) + } finally { + process.env.ALLOW_ADD_MEMBER_TEST = ogFlag + } + } + + expect(tryToAddMemberTest).not.toThrow() + }) + + it('does not allow ADD_MEMBER_TEST when flag is not set', () => { + const { alice, bob } = setup('alice', { user: 'bob', addToTeam: false }) + expect(alice.team.members().length).toBe(1) + + const tryToAddMemberTest = () => { + const ogFlag = process.env.ALLOW_ADD_MEMBER_TEST + try { + process.env.ALLOW_ADD_MEMBER_TEST = undefined + alice.team.dispatch({ + type: 'ADD_MEMBER_TEST', + payload: { + member: redactUser(bob.user), + lockboxes: [], + }, + }) + } finally { + process.env.ALLOW_ADD_MEMBER_TEST = ogFlag + } + } + + expect(tryToAddMemberTest).toThrow() + }) + it("doesn't do anything if asked to remove a nonexistent member", () => { const { alice } = setup('alice') diff --git a/packages/auth/src/team/types.ts b/packages/auth/src/team/types.ts index 1d544e7e..021579b1 100644 --- a/packages/auth/src/team/types.ts +++ b/packages/auth/src/team/types.ts @@ -116,6 +116,14 @@ export type AddMemberAction = { } } +export type AddMemberTestAction = { + type: 'ADD_MEMBER_TEST' + payload: BasePayload & { + member: Member + roles?: string[] + } +} + export type RemoveMemberAction = { type: 'REMOVE_MEMBER' payload: BasePayloadLockboxesRequired & { @@ -189,7 +197,7 @@ export type RevokeInvitationAction = { export type AdmitMemberAction = { type: 'ADMIT_MEMBER' - payload: BasePayload & { + payload: BasePayloadLockboxesRequired & { id: Base58 // Invitation ID userName: string memberKeys: Keyset // Member keys provided by the new member @@ -268,6 +276,7 @@ export type SetMetadataAction = { export type TeamAction = | RootAction | AddMemberAction + | AddMemberTestAction | AddDeviceAction | AddRoleAction | AddMemberRoleAction diff --git a/packages/auth/src/team/validate.ts b/packages/auth/src/team/validate.ts index 08364ed7..db734aaa 100644 --- a/packages/auth/src/team/validate.ts +++ b/packages/auth/src/team/validate.ts @@ -1,17 +1,20 @@ import { Logger, truncateHashes } from '@localfirst/shared' import { ROOT } from '@localfirst/crdx' import { invitationCanBeUsed } from 'invitation/index.js' -import { VALID, ValidationError, actionFingerprint } from 'util/index.js' +import { KeyType, VALID, ValidationError, actionFingerprint } from 'util/index.js' import { isActionAllowedWithMemberRole, isAdminOnlyAction } from './isAdminOnlyAction.js' import * as select from './selectors/index.js' import { + type Member, + type TeamAction, type TeamLink, type TeamState, type TeamStateValidator, type TeamStateValidatorSet, } from './types.js' -import { MEMBER } from '../role/constants.js' +import { ADMIN, MEMBER } from '../role/constants.js' import { isActionAllowedWithoutLockboxes } from './lockboxesRequiredForAction.js' +import type { Lockbox } from '../lockbox/types.js' export const validate: TeamStateValidator = (previousState: TeamState, link: TeamLink, extendableLogger?: Logger) => { const logger = extendableLogger != null ? extendableLogger.extend('validate') : new Logger({ moduleName: 'auth:validate' }) @@ -27,22 +30,65 @@ export const validate: TeamStateValidator = (previousState: TeamState, link: Tea return VALID } -export const canUserAddMemberToRole = (roleName: string, assigningUserId: string, previousState: TeamState): boolean => { - const metadata = select.getMetadata(previousState) - if (select.hasServer(previousState, assigningUserId)) { - return false +const hasTeamLockbox = (userId: string, lockboxes: Lockbox[], checkGenerationNonZero = false): boolean => + lockboxes.find(l => l.contents.type === KeyType.TEAM && l.recipient.name === userId && (!checkGenerationNonZero || l.contents.generation > 0)) != null + +const hasRoleLockbox = (userIdOrRoleName: string, roleName: string, lockboxes: Lockbox[], checkGenerationNonZero = false): boolean => + lockboxes.find(l => l.contents.type === KeyType.ROLE && l.contents.name === roleName && l.recipient.name === userIdOrRoleName && (!checkGenerationNonZero || l.contents.generation > 0)) != null + +const hasDeviceLockbox = (userId: string, deviceId: string, lockboxes: Lockbox[], checkGenerationNonZero = false): boolean => + lockboxes.find(l => l.contents.type === KeyType.USER && l.contents.name === userId && l.recipient.name === deviceId && (!checkGenerationNonZero || l.contents.generation > 0)) != null + +const validateLockboxesOnChangeKeysOrRotateKeys = (actionType: 'CHANGE_MEMBER_KEYS' | 'ROTATE_KEYS', previousState: TeamState, userId: string, lockboxes: Lockbox[], link: TeamLink, logger: Logger) => { + const [member] = select.members(previousState, [userId], { includeRemoved: false, throwOnMissing: false }) + if (member == null) { + return fail(`${actionType} found no member for ID ${userId}`, previousState, link, logger) + } + const rolesForMember = select.rolesMemberIsIn(previousState, member.userId) + for (const device of member.devices ?? []) { + if (!hasDeviceLockbox(member.userId, device.deviceId, lockboxes, true)) { + return fail(`${actionType} requires a device lockbox for all devices for the user`, previousState, link, logger) } - if (!select.hasMember(previousState, assigningUserId)) { - return false + } + const members = select.allMembers(previousState, { includeRemoved: false }) + for (const m of members) { + if (!hasTeamLockbox(m.userId, lockboxes, true)) { + return fail(`${actionType} requires an updated team lockbox for all members`, previousState, link, logger) } - if (metadata.selfAssignableRoles.includes(roleName)) { - return true + for (const role of rolesForMember) { + if (select.memberHasRole(previousState, m.userId, role.roleName) && !hasRoleLockbox(m.userId, role.roleName, lockboxes, true)) { + return fail(`${actionType} requires an updated role lockbox for all roles for each member (offending role = ${role.roleName})`, previousState, link, logger) + } } - if (select.memberIsAdmin(previousState, assigningUserId)) { - return true + } + const servers = select.servers(previousState, { includeRemoved: false, }) + for (const server of servers) { + if (!hasTeamLockbox(server.host, lockboxes, true)) { + return fail(`${actionType} requires an updated team lockbox for all servers`, previousState, link, logger) } + } +} + +export const canUserAddMemberToRole = (roleName: string, assigningUserId: string, previousState: TeamState): boolean => { + const metadata = select.getMetadata(previousState) + if (select.hasServer(previousState, assigningUserId)) { + return false + } + if (!select.hasMember(previousState, assigningUserId)) { return false } + if (metadata.selfAssignableRoles.includes(roleName)) { + return true + } + if (select.memberIsAdmin(previousState, assigningUserId)) { + return true + } + return false +} + +const getCurrentMembersOfRole = (roleName: string, previousState: TeamState): Member[] => { + return select.membersInRole(previousState, roleName) +} const validators: TeamStateValidatorSet = { rootDeviceBelongsToRootUser(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { @@ -172,6 +218,18 @@ const validators: TeamStateValidatorSet = { return VALID }, + /** ADD_MEMBER_TEST is a unit-test only convenience action */ + cantUseAddMemberTestInProduction(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { + const logger = extendableLogger.extend('cantUseAddMemberTestInProduction') + if (link.body.type === 'ADD_MEMBER_TEST') { + const { userId: assigningUserId } = link.body + const { member } = link.body.payload + if (process.env.ALLOW_ADD_MEMBER_TEST === 'true') return VALID + return fail(`User ${assigningUserId} attempted to use the ADD_MEMBER_TEST action to add ${member.userId} in production`, previousState, link, logger) + } + return VALID + }, + /** Validate the presence of lockboxes on an action payload when required */ lockboxesArePresentWhenRequired(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { const logger = extendableLogger.extend('lockboxesArePresentWhenRequired') @@ -188,6 +246,216 @@ const validators: TeamStateValidatorSet = { } return VALID }, + + /** Validate the presence of team and role lockboxes on ADD_MEMBER */ + correctLockboxesPresentOnAddMember(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { + const logger = extendableLogger.extend('correctLockboxesPresentOnAddMember') + if (link.body.type === 'ADD_MEMBER') { + const { lockboxes, roles, member } = link.body.payload + if (!hasTeamLockbox(member.userId, lockboxes)) { + return fail(`ADD_MEMBER requires a team lockbox for the added member`, previousState, link, logger) + } + for (const role of roles ?? []) { + if (!hasRoleLockbox(member.userId, role, lockboxes)) { + return fail(`ADD_MEMBER requires a lockbox for each role for the added member`, previousState, link, logger) + } + } + } + return VALID + }, + + /** Validate the presence of team and role lockboxes on ADMIT_MEMBER */ + correctLockboxesPresentOnAdmitMember(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { + const logger = extendableLogger.extend('correctLockboxesPresentOnAdmitMember') + if (link.body.type === 'ADMIT_MEMBER') { + const { lockboxes, memberKeys } = link.body.payload + if (!hasTeamLockbox(memberKeys.name, lockboxes)) { + return fail(`ADMIT_MEMBER requires a team lockbox for the admitted member`, previousState, link, logger) + } + } + return VALID + }, + + /** Validate the presence of role lockboxes on REMOVE_MEMBER */ + correctLockboxesPresentOnRemoveMember(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { + const logger = extendableLogger.extend('correctLockboxesPresentOnRemoveMember') + if (link.body.type === 'REMOVE_MEMBER') { + const { lockboxes, userId } = link.body.payload + const rolesMemberIsIn = select.rolesMemberIsIn(previousState, userId) + for (const role of rolesMemberIsIn) { + const membersInRole = getCurrentMembersOfRole(role.roleName, previousState) + for (const member of membersInRole) { + if (member.userId !== userId && !hasRoleLockbox(member.userId, role.roleName, lockboxes, true)) { + return fail(`REMOVE_MEMBER requires a role lockbox for each member remaining in the role`, previousState, link, logger) + } + } + if (role.roleName !== ADMIN && !hasRoleLockbox(ADMIN, role.roleName, lockboxes, true)) { + return fail(`REMOVE_MEMBER requires a role lockbox for all roles for the ADMIN role`, previousState, link, logger) + } + } + const allMembers = select.allMembers(previousState, { includeRemoved: false }) + for (const member of allMembers) { + if (!hasTeamLockbox(member.userId, lockboxes, true)) { + return fail(`REMOVE_MEMBER requires an updated team lockbox for all remaining members`, previousState, link, logger) + } + } + } + return VALID + }, + + /** Validate the presence of role lockboxes on ADD_MEMBER_ROLE */ + correctLockboxesPresentOnAddMemberRole(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { + const logger = extendableLogger.extend('correctLockboxesPresentOnAddMemberRole') + if (link.body.type === 'ADD_MEMBER_ROLE') { + const { lockboxes, userId, roleName } = link.body.payload + if (!hasRoleLockbox(userId, roleName, lockboxes)) { + return fail(`ADD_MEMBER_ROLE requires a lockbox for the role being added`, previousState, link, logger) + } + } + return VALID + }, + + /** Validate the presence of role lockboxes on REMOVE_MEMBER_ROLE */ + correctLockboxesPresentOnRemoveMemberRole(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { + const logger = extendableLogger.extend('correctLockboxesPresentOnRemoveMemberRole') + if (link.body.type === 'REMOVE_MEMBER_ROLE') { + const { lockboxes, userId, roleName } = link.body.payload + const membersInRole = getCurrentMembersOfRole(roleName, previousState) + for (const member of membersInRole) { + if (member.userId !== userId && !hasRoleLockbox(member.userId, roleName, lockboxes, true)) { + return fail(`REMOVE_MEMBER_ROLE requires a role lockbox for each member remaining in the role`, previousState, link, logger) + } + } + } + return VALID + }, + + /** Validate the presence of role lockboxes on ADD_ROLE */ + correctLockboxesPresentOnAddRole(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { + const logger = extendableLogger.extend('correctLockboxesPresentOnAddRole') + if (link.body.type === 'ADD_ROLE') { + const { lockboxes, roleName } = link.body.payload + if (!hasRoleLockbox(ADMIN, roleName, lockboxes)) { + return fail(`ADD_ROLE requires a role lockbox for the ${ADMIN} role`, previousState, link, logger) + } + } + return VALID + }, + + /** Validate the presence of role lockboxes on ADD_DEVICE */ + correctLockboxesPresentOnAddDevice(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { + const logger = extendableLogger.extend('correctLockboxesPresentOnAddDevice') + if (link.body.type === 'ADD_DEVICE') { + const { lockboxes, device } = link.body.payload + if (!hasDeviceLockbox(device.userId, device.deviceId, lockboxes)) { + return fail(`ADD_DEVICE requires a device lockbox for the user`, previousState, link, logger) + } + } + return VALID + }, + + /** Validate the presence of role lockboxes on REMOVE_DEVICE */ + correctLockboxesPresentOnRemoveDevice(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { + const logger = extendableLogger.extend('correctLockboxesPresentOnRemoveDevice') + if (link.body.type === 'REMOVE_DEVICE') { + const { lockboxes, deviceId } = link.body.payload + const member = select.memberByDeviceId(previousState, deviceId) + logger.warn('lockboxes', lockboxes.map(l => JSON.stringify({ c: { id: l.contents.name, type: l.contents.type, gen: l.contents.generation }, r: { id: l.recipient.name, type: l.recipient.type, gen: l.recipient.generation }}, null, 2))) + if (!hasTeamLockbox(member.userId, lockboxes, true)) { + return fail(`REMOVE_DEVICE requires a team lockbox for the user`, previousState, link, logger) + } + const { devices, roles } = member + const rolesMemberIsIn = select.rolesMemberIsIn(previousState, member.userId) + for (const device of devices ?? []) { + if (device.deviceId !== deviceId && !hasDeviceLockbox(member.userId, device.deviceId, lockboxes, true)) { + return fail(`REMOVE_DEVICE requires a device lockbox for all remaining devices for the user`, previousState, link, logger) + } + } + for (const role of rolesMemberIsIn) { + if (!hasRoleLockbox(member.userId, role.roleName, lockboxes, true)) { + return fail(`REMOVE_DEVICE requires a role lockbox for all roles for the user`, previousState, link, logger) + } + if (role.roleName !== ADMIN && !hasRoleLockbox(ADMIN, role.roleName, lockboxes, true)) { + return fail(`REMOVE_DEVICE requires a role lockbox for all roles for the ADMIN role`, previousState, link, logger) + } + } + } + return VALID + }, + + /** Validate the presence of team and role lockboxes on ADD_SERVER */ + correctLockboxesPresentOnAddServer(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { + const logger = extendableLogger.extend('correctLockboxesPresentOnAddServer') + if (link.body.type === 'ADD_SERVER') { + const { lockboxes, server } = link.body.payload + if (!hasTeamLockbox(server.host, lockboxes)) { + return fail(`ADD_SERVER requires a team lockbox for the added server`, previousState, link, logger) + } + } + return VALID + }, + + /** Validate the presence of team and role lockboxes on REMOVE_SERVER */ + correctLockboxesPresentOnRemoveServer(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { + const logger = extendableLogger.extend('correctLockboxesPresentOnRemoveServer') + if (link.body.type === 'REMOVE_SERVER') { + const { lockboxes, host } = link.body.payload + const members = select.allMembers(previousState, { includeRemoved: false }) + for (const member of members) { + if (!hasTeamLockbox(member.userId, lockboxes, true)) { + return fail(`REMOVE_SERVER requires an updated team lockbox for all members`, previousState, link, logger) + } + } + const servers = select.servers(previousState, { includeRemoved: false, }) + for (const server of servers) { + if (server.host != host && !hasTeamLockbox(server.host, lockboxes, true)) { + return fail(`REMOVE_SERVER requires an updated team lockbox for all remaining servers`, previousState, link, logger) + } + } + } + return VALID + }, + + /** Validate the presence of team and role lockboxes on CHANGE_MEMBER_KEYS */ + correctLockboxesPresentOnChangeMemberKeys(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { + const logger = extendableLogger.extend('correctLockboxesPresentOnChangeMemberKeys') + if (link.body.type === 'CHANGE_MEMBER_KEYS') { + const { lockboxes, keys } = link.body.payload + validateLockboxesOnChangeKeysOrRotateKeys('CHANGE_MEMBER_KEYS', previousState, keys.name, lockboxes, link, logger) + } + return VALID + }, + + /** Validate the presence of team and role lockboxes on ROTATE_KEYS */ + correctLockboxesPresentOnRotateKeys(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { + const logger = extendableLogger.extend('correctLockboxesPresentOnRotateKeys') + if (link.body.type === 'ROTATE_KEYS') { + const { lockboxes, userId } = link.body.payload + validateLockboxesOnChangeKeysOrRotateKeys('ROTATE_KEYS', previousState, userId, lockboxes, link, logger) + } + return VALID + }, + + /** Validate the presence of team and role lockboxes on CHANGE_SERVER_KEYS */ + correctLockboxesPresentOnChangeServerKeys(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { + const logger = extendableLogger.extend('correctLockboxesPresentOnChangeServerKeys') + if (link.body.type === 'CHANGE_SERVER_KEYS') { + const { lockboxes, keys } = link.body.payload + const members = select.allMembers(previousState, { includeRemoved: false }) + for (const m of members) { + if (!hasTeamLockbox(m.userId, lockboxes, true)) { + return fail(`CHANGE_SERVER_KEYS requires an updated team lockbox for all members`, previousState, link, logger) + } + } + const servers = select.servers(previousState, { includeRemoved: false, }) + for (const server of servers) { + if (!hasTeamLockbox(server.host, lockboxes, true)) { + return fail(`CHANGE_SERVER_KEYS requires an updated team lockbox for all servers`, previousState, link, logger) + } + } + } + return VALID + }, } const fail = (message: string, previousState: TeamState, link: TeamLink, extendableLogger: Logger) => { From 57a0387afbc3e924a4618bf6cce9f60467a844d8 Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 13 Aug 2026 16:25:24 -0400 Subject: [PATCH 05/14] fix typing of lockbox --- packages/auth/src/team/Team.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/auth/src/team/Team.ts b/packages/auth/src/team/Team.ts index 3e42e709..edf34a80 100644 --- a/packages/auth/src/team/Team.ts +++ b/packages/auth/src/team/Team.ts @@ -1080,7 +1080,7 @@ export class Team extends EventEmitter { }) _addUpdatedUserKeys(updatedRecipientKeys) return newLockbox - }).filter(l => l != null) + }).filter((lockbox): lockbox is lockbox.Lockbox => lockbox != null) }) return { From 6d7641a11b06a86602c36cca91aeaf77e9bf9df4 Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 13 Aug 2026 16:48:55 -0400 Subject: [PATCH 06/14] feat(auth): initialize member role for new teams --- .../src/connection/test/authentication.test.ts | 1 - packages/auth/src/team/Team.ts | 17 ++++++++++++++++- packages/auth/src/team/createTeam.ts | 9 ++++++++- packages/auth/src/team/reducer.ts | 9 +++++++-- packages/auth/src/team/test/createTeam.test.ts | 17 +++++++++++++++++ packages/auth/src/team/types.ts | 4 ++++ packages/auth/src/util/testing/setup.ts | 1 - 7 files changed, 52 insertions(+), 6 deletions(-) diff --git a/packages/auth/src/connection/test/authentication.test.ts b/packages/auth/src/connection/test/authentication.test.ts index c75f0ce5..a58fe364 100644 --- a/packages/auth/src/connection/test/authentication.test.ts +++ b/packages/auth/src/connection/test/authentication.test.ts @@ -247,7 +247,6 @@ describe('connection', () => { it('admits a first-use device before continuing authentication', async () => { const { bob } = setup('bob') - bob.team.addRole(MEMBER) const phone = bob.phone! const { userId: _userId } = phone const { seed } = bob.team.inviteDevice() diff --git a/packages/auth/src/team/Team.ts b/packages/auth/src/team/Team.ts index edf34a80..823c9f0e 100644 --- a/packages/auth/src/team/Team.ts +++ b/packages/auth/src/team/Team.ts @@ -111,6 +111,15 @@ export class Team extends EventEmitter { const lockboxTeamKeysForMember = lockbox.create(options.teamKeys, user.keys) const adminKeys = createKeyset(ADMIN_SCOPE, this.seed) const lockboxAdminKeysForMember = lockbox.create(adminKeys, user.keys) + const memberKeys = options.initializeMemberRole + ? createKeyset({ type: KeyType.ROLE, name: MEMBER }, this.seed) + : undefined + const memberRoleLockboxes = memberKeys == null + ? [] + : [ + lockbox.create(memberKeys, adminKeys), + lockbox.create(memberKeys, user.keys), + ] // We also store the founding user's keys in a lockbox for the user's device const lockboxUserKeysForDevice = lockbox.create(user.keys, this.context.device.keys) @@ -120,7 +129,13 @@ export class Team extends EventEmitter { name: options.teamName, rootMember: redactUser(user), rootDevice: devices.redactDevice(device), - lockboxes: [lockboxTeamKeysForMember, lockboxAdminKeysForMember, lockboxUserKeysForDevice], + lockboxes: [ + lockboxTeamKeysForMember, + lockboxAdminKeysForMember, + ...memberRoleLockboxes, + lockboxUserKeysForDevice, + ], + initializeMemberRole: options.initializeMemberRole, } // Create CRDX store diff --git a/packages/auth/src/team/createTeam.ts b/packages/auth/src/team/createTeam.ts index 8324a510..ef3a9763 100644 --- a/packages/auth/src/team/createTeam.ts +++ b/packages/auth/src/team/createTeam.ts @@ -10,5 +10,12 @@ export function createTeam(teamName: string, context: LocalContext, seed?: strin const defaultMetadata: TeamMetadata = { selfAssignableRoles: [] } - return new Team({ teamName, context, teamKeys, metadata: metadata ?? defaultMetadata, sharedLogger }) + return new Team({ + teamName, + context, + teamKeys, + metadata: metadata ?? defaultMetadata, + sharedLogger, + initializeMemberRole: true, + }) } diff --git a/packages/auth/src/team/reducer.ts b/packages/auth/src/team/reducer.ts index 315f7f2f..7538fb89 100644 --- a/packages/auth/src/team/reducer.ts +++ b/packages/auth/src/team/reducer.ts @@ -1,5 +1,5 @@ import { ROOT, type Reducer } from '@localfirst/crdx' -import { ADMIN } from 'role/index.js' +import { ADMIN, MEMBER } from 'role/index.js' import { clone, composeTransforms } from 'util/index.js' import { invalidLinkReducer } from './invalidLinkReducer.js' import { setHead } from './setHead.js' @@ -90,12 +90,17 @@ const getTransforms = (action: TeamAction): Transform[] => { switch (action.type) { case ROOT: { const { name, rootMember, rootDevice } = action.payload + const memberRole = action.payload.initializeMemberRole + ? [addRole({ roleName: MEMBER, createdBy: rootMember.userId })] + : [] + const founderRoles = action.payload.initializeMemberRole ? [ADMIN, MEMBER] : [ADMIN] return [ setTeamName(name), addRole({ roleName: ADMIN, createdBy: action.payload.rootMember.userId }), // Create the admin role + ...memberRole, addMember(rootMember), // Add the founding member addDevice(rootDevice), // Add the founding member's device - ...addMemberRoles(rootMember.userId, [ADMIN]), // Make the founding member an admin + ...addMemberRoles(rootMember.userId, founderRoles), // Make the founding member an admin and member ] } diff --git a/packages/auth/src/team/test/createTeam.test.ts b/packages/auth/src/team/test/createTeam.test.ts index 36eaf9c8..4089a5d7 100644 --- a/packages/auth/src/team/test/createTeam.test.ts +++ b/packages/auth/src/team/test/createTeam.test.ts @@ -4,6 +4,8 @@ import { createTeam } from '../createTeam.js' import { createDevice } from 'device/index.js' import { setup } from 'util/testing/index.js' import { load } from '../load.js' +import { ADMIN, MEMBER } from 'role/index.js' +import 'util/testing/expect/toLookLikeKeyset.js' describe('Team', () => { describe('createTeam', () => { @@ -15,6 +17,21 @@ describe('Team', () => { expect(team.id).toBeDefined() }) + it('initializes the member role for the founding member', () => { + const user = createUser('alice') + const device = createDevice({ userId: user.userId, deviceName: 'laptop' }) + const team = createTeam('Spies ÐŊ Us', { user, device }) + + expect(team.roles().map(role => role.roleName)).toEqual([ADMIN, MEMBER]) + expect(team.memberHasRole(user.userId, MEMBER)).toBe(true) + expect(team.roleKeys(MEMBER)).toLookLikeKeyset() + expect( + team.state.lockboxes.filter( + lockbox => lockbox.contents.type === 'ROLE' && lockbox.contents.name === MEMBER + ) + ).toHaveLength(2) + }) + it(`doesn't allow creating a team where the device's userId doesn't match the user's`, () => { const user = createUser('alice') const device = createDevice({ userId: 'alice', deviceName: 'laptop' }) // <- should be alice.userId instead of 'alice' diff --git a/packages/auth/src/team/types.ts b/packages/auth/src/team/types.ts index 021579b1..870a7e54 100644 --- a/packages/auth/src/team/types.ts +++ b/packages/auth/src/team/types.ts @@ -57,6 +57,9 @@ export type NewTeamOptions = { /** Team metadata (e.g. roles that can be self-assigned by a member on the chain) */ metadata?: TeamMetadata + + /** Initialize the default member role on the root link. */ + initializeMemberRole?: boolean } /** Properties required when rehydrating from an existing graph */ @@ -105,6 +108,7 @@ export type RootAction = { name: string rootMember: Member rootDevice: Device + initializeMemberRole?: boolean } } diff --git a/packages/auth/src/util/testing/setup.ts b/packages/auth/src/util/testing/setup.ts index 5744b7d1..fcaf1686 100644 --- a/packages/auth/src/util/testing/setup.ts +++ b/packages/auth/src/util/testing/setup.ts @@ -79,7 +79,6 @@ export const setup = (..._config: SetupConfig) => { const randomSeed = teamName const team = teams.createTeam(teamName, founderContext, randomSeed, { selfAssignableRoles: [MEMBER] }) const teamKeys = team.teamKeys() - team.addRole({ roleName: MEMBER, permissions: undefined }) // Add members for (const { user: userName, admin = true, addToTeam = true, member = true, rolesWithoutLockboxes = [] } of config) { From 365d22bc47e11142c01534449b3c5c3e8e67bbb5 Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 13 Aug 2026 16:49:53 -0400 Subject: [PATCH 07/14] fix(auth): preserve legacy member key history on rotation --- .../src/team/transforms/changeMemberKeys.ts | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/auth/src/team/transforms/changeMemberKeys.ts b/packages/auth/src/team/transforms/changeMemberKeys.ts index 42f9052c..54a81dc8 100644 --- a/packages/auth/src/team/transforms/changeMemberKeys.ts +++ b/packages/auth/src/team/transforms/changeMemberKeys.ts @@ -5,13 +5,18 @@ export const changeMemberKeys = (keys: Keyset): Transform => state => ({ ...state, - members: state.members.map(member => - member.userId === keys.name - ? { - ...member, - keys, // ðŸĄ replace keys with new ones - keysHistory: !member.keysHistory.find(k => k.generation === keys.generation && k.encryption === keys.encryption) ? [keys, ...member.keysHistory] : member.keysHistory, - } - : member - ), + members: state.members.map(member => { + if (member.userId !== keys.name) return member + + // Graphs persisted before keysHistory was added only contain the current keyset. + const keysHistory = member.keysHistory ?? [member.keys] + + return { + ...member, + keys, // ðŸĄ replace keys with new ones + keysHistory: !keysHistory.find(k => k.generation === keys.generation && k.encryption === keys.encryption) + ? [keys, ...keysHistory] + : keysHistory, + } + }), }) From d31e175c136e349dad2ff46472cc36a2ee4091bb Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 13 Aug 2026 16:50:14 -0400 Subject: [PATCH 08/14] fix(auth): reject invalid key rotation lockboxes --- packages/auth/src/team/test/keys.test.ts | 54 ++++++++++++++++++++++++ packages/auth/src/team/validate.ts | 5 ++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/auth/src/team/test/keys.test.ts b/packages/auth/src/team/test/keys.test.ts index c45df3c0..2c32df96 100644 --- a/packages/auth/src/team/test/keys.test.ts +++ b/packages/auth/src/team/test/keys.test.ts @@ -4,6 +4,9 @@ import { KeyType } from 'util/index.js' import 'util/testing/expect/toLookLikeKeyset.js' import { setup } from 'util/testing/index.js' import { describe, expect, it } from 'vitest' +import { initialState } from '../constants.js' +import { changeMemberKeys } from '../transforms/changeMemberKeys.js' +import type { Member, TeamState } from '../types.js' const { USER, DEVICE } = KeyType @@ -164,6 +167,23 @@ describe('Team', () => { expect(tryToChangeBobsKeysWithoutLockboxesNullish).toThrow() }) + it("Alice can't change Bob's keys with nonempty but stale lockboxes", () => { + const { alice, bob } = setup('alice', 'bob', { user: 'eve', admin: false }) + const newKeys = createKeyset({ type: USER, name: bob.userId }) + + const tryToChangeBobsKeysWithStaleLockboxes = () => { + alice.team.dispatch({ + type: 'CHANGE_MEMBER_KEYS', + payload: { + keys: redactKeys(newKeys), + lockboxes: alice.team.state.lockboxes, + }, + }) + } + + expect(tryToChangeBobsKeysWithStaleLockboxes).toThrow() + }) + it("Alice can't rotate Bob's keys when no lockboxes are provided", () => { // Alice, despite having permissions, tries to rotate Bob's keys without publishing rotated // team/role keys by initiating a key change without lockboxes @@ -192,5 +212,39 @@ describe('Team', () => { expect(tryToRotateBobsKeysWithoutLockboxesEmpty).toThrow() expect(tryToRotateBobsKeysWithoutLockboxesNullish).toThrow() }) + + it("Alice can't rotate Bob's keys with nonempty but stale lockboxes", () => { + const { alice, bob } = setup('alice', 'bob', { user: 'eve', admin: false }) + + const tryToRotateBobsKeysWithStaleLockboxes = () => { + alice.team.dispatch({ + type: 'ROTATE_KEYS', + payload: { + userId: bob.userId, + lockboxes: alice.team.state.lockboxes, + }, + }) + } + + expect(tryToRotateBobsKeysWithStaleLockboxes).toThrow() + }) + + it('updates legacy members without keysHistory', () => { + const oldKeys = redactKeys(createKeyset({ type: USER, name: 'bob' })) + const newKeys = redactKeys(createKeyset({ type: USER, name: 'bob' })) + const legacyMember: Omit = { + userId: 'bob', + userName: 'Bob', + keys: oldKeys, + roles: [], + } + const legacyState = { ...initialState, members: [legacyMember] } as unknown as TeamState + + // A rehydrated graph created before keysHistory existed has this member shape. + const updatedState = changeMemberKeys(newKeys)(legacyState) + + expect(updatedState.members[0]).toMatchObject({ keys: newKeys }) + expect(updatedState.members[0].keysHistory).toEqual([newKeys, oldKeys]) + }) }) }) diff --git a/packages/auth/src/team/validate.ts b/packages/auth/src/team/validate.ts index db734aaa..1a4caa8c 100644 --- a/packages/auth/src/team/validate.ts +++ b/packages/auth/src/team/validate.ts @@ -67,6 +67,7 @@ const validateLockboxesOnChangeKeysOrRotateKeys = (actionType: 'CHANGE_MEMBER_KE return fail(`${actionType} requires an updated team lockbox for all servers`, previousState, link, logger) } } + return VALID } export const canUserAddMemberToRole = (roleName: string, assigningUserId: string, previousState: TeamState): boolean => { @@ -421,7 +422,7 @@ const validators: TeamStateValidatorSet = { const logger = extendableLogger.extend('correctLockboxesPresentOnChangeMemberKeys') if (link.body.type === 'CHANGE_MEMBER_KEYS') { const { lockboxes, keys } = link.body.payload - validateLockboxesOnChangeKeysOrRotateKeys('CHANGE_MEMBER_KEYS', previousState, keys.name, lockboxes, link, logger) + return validateLockboxesOnChangeKeysOrRotateKeys('CHANGE_MEMBER_KEYS', previousState, keys.name, lockboxes, link, logger) } return VALID }, @@ -431,7 +432,7 @@ const validators: TeamStateValidatorSet = { const logger = extendableLogger.extend('correctLockboxesPresentOnRotateKeys') if (link.body.type === 'ROTATE_KEYS') { const { lockboxes, userId } = link.body.payload - validateLockboxesOnChangeKeysOrRotateKeys('ROTATE_KEYS', previousState, userId, lockboxes, link, logger) + return validateLockboxesOnChangeKeysOrRotateKeys('ROTATE_KEYS', previousState, userId, lockboxes, link, logger) } return VALID }, From 6ed13ef2348a4acb78f541369fd418e99185ca3b Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 13 Aug 2026 16:50:23 -0400 Subject: [PATCH 09/14] fix(auth): atomically record member keys with rotations --- packages/auth/src/team/Team.ts | 12 +++++------- packages/auth/src/team/reducer.ts | 8 ++++++-- packages/auth/src/team/types.ts | 4 ++++ packages/auth/src/team/validate.ts | 10 ++++++++-- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/auth/src/team/Team.ts b/packages/auth/src/team/Team.ts index 823c9f0e..f78ccc34 100644 --- a/packages/auth/src/team/Team.ts +++ b/packages/auth/src/team/Team.ts @@ -541,15 +541,13 @@ export class Team extends EventEmitter { // Create new keys & lockboxes for any keys this device had access to const { lockboxes, updatedUserKeys } = this.rotateKeys({ type: DEVICE, name: deviceId }, true) - // update the keys on the member records - this.updateMemberKeysWithLockboxes(updatedUserKeys, lockboxes) - // Post the removal to the graph this.dispatch({ type: 'REMOVE_DEVICE', payload: { deviceId, lockboxes, + updatedUserKeys: [...updatedUserKeys], }, }) } @@ -1031,10 +1029,10 @@ export class Team extends EventEmitter { type: USER, name: this.userId, }) - this.dispatch({ type: 'ROTATE_KEYS', payload: { userId, lockboxes } }) - - // update the keys on the member records - this.updateMemberKeysWithLockboxes(updatedUserKeys, lockboxes) + this.dispatch({ + type: 'ROTATE_KEYS', + payload: { userId, lockboxes, updatedUserKeys: [...updatedUserKeys] }, + }) } } diff --git a/packages/auth/src/team/reducer.ts b/packages/auth/src/team/reducer.ts index 7538fb89..a2980f8f 100644 --- a/packages/auth/src/team/reducer.ts +++ b/packages/auth/src/team/reducer.ts @@ -142,8 +142,10 @@ const getTransforms = (action: TeamAction): Transform[] => { } case 'REMOVE_DEVICE': { - const { deviceId } = action.payload + // Older persisted REMOVE_DEVICE links predate updatedUserKeys. + const { deviceId, updatedUserKeys = [] } = action.payload return [ + ...updatedUserKeys.map(keys => changeMemberKeys(keys)), removeDevice(deviceId), // Remove this device from the member's list of devices ] } @@ -218,8 +220,10 @@ const getTransforms = (action: TeamAction): Transform[] => { } case 'ROTATE_KEYS': { - const { userId } = action.payload + // Older persisted ROTATE_KEYS links predate updatedUserKeys. + const { userId, updatedUserKeys = [] } = action.payload return [ + ...updatedUserKeys.map(keys => changeMemberKeys(keys)), rotateKeys(userId), // Mark this member's keys as having been rotated (the rotated keys themselves are in the lockboxes) ] } diff --git a/packages/auth/src/team/types.ts b/packages/auth/src/team/types.ts index 870a7e54..7b2c500a 100644 --- a/packages/auth/src/team/types.ts +++ b/packages/auth/src/team/types.ts @@ -175,6 +175,8 @@ export type RemoveDeviceAction = { type: 'REMOVE_DEVICE' payload: BasePayloadLockboxesRequired & { deviceId: string + /** Updated owner keys produced while rotating access away from the removed device. */ + updatedUserKeys?: Keyset[] } } @@ -227,6 +229,8 @@ export type RotateKeysAction = { type: 'ROTATE_KEYS' payload: BasePayloadLockboxesRequired & { userId: string + /** Updated member keys produced by the same rotation. */ + updatedUserKeys?: Keyset[] } } diff --git a/packages/auth/src/team/validate.ts b/packages/auth/src/team/validate.ts index 1a4caa8c..c7aac264 100644 --- a/packages/auth/src/team/validate.ts +++ b/packages/auth/src/team/validate.ts @@ -40,7 +40,10 @@ const hasDeviceLockbox = (userId: string, deviceId: string, lockboxes: Lockbox[] lockboxes.find(l => l.contents.type === KeyType.USER && l.contents.name === userId && l.recipient.name === deviceId && (!checkGenerationNonZero || l.contents.generation > 0)) != null const validateLockboxesOnChangeKeysOrRotateKeys = (actionType: 'CHANGE_MEMBER_KEYS' | 'ROTATE_KEYS', previousState: TeamState, userId: string, lockboxes: Lockbox[], link: TeamLink, logger: Logger) => { - const [member] = select.members(previousState, [userId], { includeRemoved: false, throwOnMissing: false }) + // ROTATE_KEYS can clean up access for an admission that conflict resolution already moved to + // removedMembers; CHANGE_MEMBER_KEYS must still target an active member. + const includeRemoved = actionType === 'ROTATE_KEYS' + const [member] = select.members(previousState, [userId], { includeRemoved, throwOnMissing: false }) if (member == null) { return fail(`${actionType} found no member for ID ${userId}`, previousState, link, logger) } @@ -359,8 +362,11 @@ const validators: TeamStateValidatorSet = { correctLockboxesPresentOnRemoveDevice(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { const logger = extendableLogger.extend('correctLockboxesPresentOnRemoveDevice') if (link.body.type === 'REMOVE_DEVICE') { - const { lockboxes, deviceId } = link.body.payload + const { lockboxes, deviceId, updatedUserKeys = [] } = link.body.payload const member = select.memberByDeviceId(previousState, deviceId) + if (updatedUserKeys.some(keys => keys.type !== KeyType.USER || keys.name !== member.userId)) { + return fail(`REMOVE_DEVICE can only update keys for the removed device's owner`, previousState, link, logger) + } logger.warn('lockboxes', lockboxes.map(l => JSON.stringify({ c: { id: l.contents.name, type: l.contents.type, gen: l.contents.generation }, r: { id: l.recipient.name, type: l.recipient.type, gen: l.recipient.generation }}, null, 2))) if (!hasTeamLockbox(member.userId, lockboxes, true)) { return fail(`REMOVE_DEVICE requires a team lockbox for the user`, previousState, link, logger) From ced85ebd1cf0c0ab235284d0df9b2e11f6556b02 Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 13 Aug 2026 16:50:39 -0400 Subject: [PATCH 10/14] fix(auth): use recovered user ID for first-use devices --- packages/auth/src/connection/Connection.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/auth/src/connection/Connection.ts b/packages/auth/src/connection/Connection.ts index 1adc2123..85b71359 100644 --- a/packages/auth/src/connection/Connection.ts +++ b/packages/auth/src/connection/Connection.ts @@ -261,12 +261,17 @@ export class Connection extends EventEmitter { logger: this.logger.extend('getDeviceUser'), }) + // A first-use device does not know its canonical userId until its invitation is accepted. + // Use the user recovered from the team graph before creating the device lockbox and + // recording the device on the team. + const memberDevice = { ...device, userId: user.userId } + // 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 }, + context: { user, device: memberDevice }, teamKeyring, sharedLogger: this.logger.sharedLogger, }) @@ -274,7 +279,7 @@ export class Connection extends EventEmitter { // We join the team, which adds our device to the team graph. team.join(teamKeyring) this.emit('joined', { team, user, teamKeyring }) - return { user, team } + return { user, device: memberDevice, team } }), // AUTHENTICATION From f3a4c4e48977f96c5638dece50574aaa47984c18 Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 13 Aug 2026 16:50:45 -0400 Subject: [PATCH 11/14] fix(auth): avoid assigning member role to server identities --- packages/auth/src/connection/Connection.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/auth/src/connection/Connection.ts b/packages/auth/src/connection/Connection.ts index 85b71359..222361bd 100644 --- a/packages/auth/src/connection/Connection.ts +++ b/packages/auth/src/connection/Connection.ts @@ -330,8 +330,13 @@ export class Connection extends EventEmitter { assert(roles) assert(userId) - if (!roles!.includes(MEMBER) && context.server == null && !team!.hasServer(userId!)) { - team!.addMemberRole(userId!, MEMBER) + if ( + !roles.includes(MEMBER) && + context.server == null && + !team.hasServer(userId) && + !team.hasServer(context.user?.userId!) + ) { + team.addMemberRole(userId, MEMBER) } this.#queueMessage('ACCEPT_IDENTITY') }, From 98c27ff43b82401138d634eb0a0513929f2051fd Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 13 Aug 2026 16:50:52 -0400 Subject: [PATCH 12/14] fix(crdx): re-evaluate links after resolver context changes --- packages/auth/src/team/isAdminOnlyAction.ts | 16 +++++++++++----- packages/crdx/src/graph/getSequence.ts | 4 +++- packages/crdx/src/graph/test/getSequence.test.ts | 13 +++++++++++++ 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/packages/auth/src/team/isAdminOnlyAction.ts b/packages/auth/src/team/isAdminOnlyAction.ts index f4492390..a51e7953 100644 --- a/packages/auth/src/team/isAdminOnlyAction.ts +++ b/packages/auth/src/team/isAdminOnlyAction.ts @@ -1,7 +1,8 @@ +import { ADMIN } from 'role/index.js' import { type TeamAction, type TeamLinkBody } from './types.js' // Anyone with team key can perform these actions -const NON_MEMBER_NON_ADMIN_ACTIONS: Array = [ +const TEAM_KEY_ACTIONS: Array = [ 'ADMIT_MEMBER', 'ADD_DEVICE', 'ADD_MEMBER_ROLE', @@ -10,13 +11,16 @@ const NON_MEMBER_NON_ADMIN_ACTIONS: Array = [ ] // Anyone with MEMBER role can perform these actions + those in NON_MEMBER_NON_ADMIN_ACTIONS -const MEMBER_NON_ADMIN_ACTIONS: Array = [ +const MEMBER_ROLE_ACTIONS: Array = [ 'INVITE_DEVICE', 'CHANGE_MEMBER_KEYS', 'REMOVE_DEVICE', ] export const isAdminOnlyAction = (action: TeamLinkBody) => { + // ADD_MEMBER_ROLE is generally permitted with the team key so invitation handshakes can assign + // MEMBER, but promoting someone to ADMIN still depends on the author's admin privileges. + if (action.type === 'ADD_MEMBER_ROLE' && action.payload.roleName === ADMIN) return true return isAdminOnlyActionType(action.type) } @@ -29,13 +33,15 @@ export const isActionAllowedWithMemberRole = (action: TeamLinkBody): boolean => } export const isAdminOnlyActionType = (actionType: TeamAction['type']): boolean => { - return !isActionTypeAllowedWithTeamKey(actionType) && !isActionTypeAllowedWithMemberRole(actionType) + return ( + !isActionTypeAllowedWithTeamKey(actionType) && !isActionTypeAllowedWithMemberRole(actionType) + ) } export const isActionTypeAllowedWithTeamKey = (actionType: TeamAction['type']): boolean => { - return NON_MEMBER_NON_ADMIN_ACTIONS.includes(actionType) + return TEAM_KEY_ACTIONS.includes(actionType) } export const isActionTypeAllowedWithMemberRole = (actionType: TeamAction['type']): boolean => { - return MEMBER_NON_ADMIN_ACTIONS.includes(actionType) + return MEMBER_ROLE_ACTIONS.includes(actionType) } diff --git a/packages/crdx/src/graph/getSequence.ts b/packages/crdx/src/graph/getSequence.ts index ad4a6172..2fe2c94f 100644 --- a/packages/crdx/src/graph/getSequence.ts +++ b/packages/crdx/src/graph/getSequence.ts @@ -31,7 +31,9 @@ export const getSequence = ( // Rather than apply the filter directly, we mark links that would be filtered out as invalid. return sorted.map(link => { - const isInvalid = link.isInvalid ?? !filter(link) + // A link that was valid in an earlier, incomplete graph can become invalid after a concurrent + // branch is merged and the resolver has enough context to apply its conflict rules. + const isInvalid = link.isInvalid === true || !filter(link) return { ...link, isInvalid } }) } diff --git a/packages/crdx/src/graph/test/getSequence.test.ts b/packages/crdx/src/graph/test/getSequence.test.ts index a843a681..eaac3d68 100644 --- a/packages/crdx/src/graph/test/getSequence.test.ts +++ b/packages/crdx/src/graph/test/getSequence.test.ts @@ -75,6 +75,19 @@ describe('graphs', () => { expect(payloads).toEqual('abc') }) + test('re-evaluates links previously marked valid when resolver context changes', () => { + const graph = buildGraph('a ─ b ─ c') + const b = Object.values(graph.links).find(link => link.body.payload === 'b')! + b.isInvalid = false + const updatedResolver: Resolver = () => ({ + filter: link => link.body.payload !== 'b', + }) + + const sequence = getSequence(graph, updatedResolver) + + expect(sequence.find(link => link.body.payload === 'b')?.isInvalid).toBe(true) + }) + test('complex graph', () => { const graph = buildGraph(` ┌─ e ─ g ─┐ From 1a13a1ed64e74587482a1fcaeaf234012df5c5db Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 13 Aug 2026 16:50:59 -0400 Subject: [PATCH 13/14] test: re-enable auth provider and sync server suites --- .../src/test/AuthProvider.test.ts | 10 +++++----- .../src/test/buildServerUrl.test.ts | 2 +- packages/auth-syncserver/src/test/SyncServer.test.ts | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/auth-provider-automerge-repo/src/test/AuthProvider.test.ts b/packages/auth-provider-automerge-repo/src/test/AuthProvider.test.ts index e2603e9d..18ccffcc 100644 --- a/packages/auth-provider-automerge-repo/src/test/AuthProvider.test.ts +++ b/packages/auth-provider-automerge-repo/src/test/AuthProvider.test.ts @@ -11,7 +11,7 @@ import { authenticated, authenticatedInTime } from './helpers/authenticated.js' import { getStorageDirectory, setup, type UserStuff } from './helpers/setup.js' import { synced } from './helpers/synced.js' -describe.skip('auth provider for automerge-repo', () => { +describe('auth provider for automerge-repo', () => { it('does not authenticate users that do not belong to any teams', async () => { const { users: { alice, bob }, @@ -184,15 +184,15 @@ describe.skip('auth provider for automerge-repo', () => { const bobTeam = putUserOnTeam(aliceTeam, bob) await bob.authProvider.addTeam(bobTeam) - // there's only one role on the team by default (ADMIN) - expect(bobTeam.roles()).toHaveLength(1) + // Teams start with ADMIN and MEMBER roles. + expect(bobTeam.roles()).toHaveLength(2) // Alice adds a role aliceTeam.addRole('MANAGERS') // Bob sees the change await eventPromise(bobTeam, 'updated') - expect(bobTeam.roles()).toHaveLength(2) // ✅ + expect(bobTeam.roles()).toHaveLength(3) // ✅ teardown() }) @@ -408,7 +408,7 @@ describe.skip('auth provider for automerge-repo', () => { // HELPERS const putUserOnTeam = (team: Auth.Team, b: UserStuff) => { - team.addForTesting(b.user, [], Auth.redactDevice(b.device)) + team.addForTesting(b.user, [Auth.MEMBER], [], Auth.redactDevice(b.device)) const serializedTeam = team.save() const keys = team.teamKeys() return Auth.loadTeam(serializedTeam, b.context, keys) diff --git a/packages/auth-provider-automerge-repo/src/test/buildServerUrl.test.ts b/packages/auth-provider-automerge-repo/src/test/buildServerUrl.test.ts index dae08b53..6c4e5690 100644 --- a/packages/auth-provider-automerge-repo/src/test/buildServerUrl.test.ts +++ b/packages/auth-provider-automerge-repo/src/test/buildServerUrl.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest' import { buildServerUrl } from '../buildServerUrl.js' -describe.skip('buildServerUrl', () => { +describe('buildServerUrl', () => { it('should prepend http:// when no protocol is provided', () => { const { protocol, hostname } = buildServerUrl('example.com') expect(protocol).toBe('http:') diff --git a/packages/auth-syncserver/src/test/SyncServer.test.ts b/packages/auth-syncserver/src/test/SyncServer.test.ts index c2194e59..9447a840 100644 --- a/packages/auth-syncserver/src/test/SyncServer.test.ts +++ b/packages/auth-syncserver/src/test/SyncServer.test.ts @@ -1,10 +1,10 @@ -import { createTeam, device, loadTeam } from '@localfirst/auth' +import { createTeam, device, loadTeam, MEMBER } from '@localfirst/auth' import { type ShareId } from '@localfirst/auth-provider-automerge-repo' import { eventPromise } from '@localfirst/shared' import { expect, it, describe } from 'vitest' import { host, setup } from './helpers/setup.js' -describe.skip('SyncServer', () => { +describe('SyncServer', () => { it('should start a server', async () => { const { url } = await setup() const response = await fetch(`http://${url}`) @@ -161,7 +161,7 @@ describe.skip('SyncServer', () => { const aliceTeam = await alice.authProvider.createTeam('team A') // Alice puts Bob on her team - aliceTeam.addForTesting(bob.user, [], device.redactDevice(bob.device)) + aliceTeam.addForTesting(bob.user, [MEMBER], [], device.redactDevice(bob.device)) // Alice authenticates await eventPromise(alice.repo.networkSubsystem, 'peer') From 711b750ddf8a084ad8653c53bb581b067b266955 Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 13 Aug 2026 17:05:13 -0400 Subject: [PATCH 14/14] always create member role --- packages/auth/src/team/Team.ts | 15 +++++---------- packages/auth/src/team/createTeam.ts | 1 - packages/auth/src/team/reducer.ts | 8 ++------ packages/auth/src/team/types.ts | 3 --- pnpm-lock.yaml | 3 --- 5 files changed, 7 insertions(+), 23 deletions(-) diff --git a/packages/auth/src/team/Team.ts b/packages/auth/src/team/Team.ts index f78ccc34..b5ad77d6 100644 --- a/packages/auth/src/team/Team.ts +++ b/packages/auth/src/team/Team.ts @@ -111,15 +111,11 @@ export class Team extends EventEmitter { const lockboxTeamKeysForMember = lockbox.create(options.teamKeys, user.keys) const adminKeys = createKeyset(ADMIN_SCOPE, this.seed) const lockboxAdminKeysForMember = lockbox.create(adminKeys, user.keys) - const memberKeys = options.initializeMemberRole - ? createKeyset({ type: KeyType.ROLE, name: MEMBER }, this.seed) - : undefined - const memberRoleLockboxes = memberKeys == null - ? [] - : [ - lockbox.create(memberKeys, adminKeys), - lockbox.create(memberKeys, user.keys), - ] + const memberKeys = createKeyset({ type: KeyType.ROLE, name: MEMBER }, this.seed) + const memberRoleLockboxes = [ + lockbox.create(memberKeys, adminKeys), + lockbox.create(memberKeys, user.keys), + ] // We also store the founding user's keys in a lockbox for the user's device const lockboxUserKeysForDevice = lockbox.create(user.keys, this.context.device.keys) @@ -135,7 +131,6 @@ export class Team extends EventEmitter { ...memberRoleLockboxes, lockboxUserKeysForDevice, ], - initializeMemberRole: options.initializeMemberRole, } // Create CRDX store diff --git a/packages/auth/src/team/createTeam.ts b/packages/auth/src/team/createTeam.ts index ef3a9763..fab916c9 100644 --- a/packages/auth/src/team/createTeam.ts +++ b/packages/auth/src/team/createTeam.ts @@ -16,6 +16,5 @@ export function createTeam(teamName: string, context: LocalContext, seed?: strin teamKeys, metadata: metadata ?? defaultMetadata, sharedLogger, - initializeMemberRole: true, }) } diff --git a/packages/auth/src/team/reducer.ts b/packages/auth/src/team/reducer.ts index a2980f8f..d44c34f9 100644 --- a/packages/auth/src/team/reducer.ts +++ b/packages/auth/src/team/reducer.ts @@ -90,17 +90,13 @@ const getTransforms = (action: TeamAction): Transform[] => { switch (action.type) { case ROOT: { const { name, rootMember, rootDevice } = action.payload - const memberRole = action.payload.initializeMemberRole - ? [addRole({ roleName: MEMBER, createdBy: rootMember.userId })] - : [] - const founderRoles = action.payload.initializeMemberRole ? [ADMIN, MEMBER] : [ADMIN] return [ setTeamName(name), addRole({ roleName: ADMIN, createdBy: action.payload.rootMember.userId }), // Create the admin role - ...memberRole, + addRole({ roleName: MEMBER, createdBy: rootMember.userId }), addMember(rootMember), // Add the founding member addDevice(rootDevice), // Add the founding member's device - ...addMemberRoles(rootMember.userId, founderRoles), // Make the founding member an admin and member + ...addMemberRoles(rootMember.userId, [ADMIN, MEMBER]), // Make the founding member an admin and member ] } diff --git a/packages/auth/src/team/types.ts b/packages/auth/src/team/types.ts index 7b2c500a..36295c2b 100644 --- a/packages/auth/src/team/types.ts +++ b/packages/auth/src/team/types.ts @@ -58,8 +58,6 @@ export type NewTeamOptions = { /** Team metadata (e.g. roles that can be self-assigned by a member on the chain) */ metadata?: TeamMetadata - /** Initialize the default member role on the root link. */ - initializeMemberRole?: boolean } /** Properties required when rehydrating from an existing graph */ @@ -108,7 +106,6 @@ export type RootAction = { name: string rootMember: Member rootDevice: Device - initializeMemberRole?: boolean } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f32c6820..24a58569 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -309,9 +309,6 @@ importers: pad: specifier: ^3.2.0 version: 3.2.0 - quiet-sandbox: - specifier: 'link:' - version: 'link:' type-fest: specifier: ^4.21.0 version: 4.23.0