From 556cdb0a3b1d1eb4f140cfbdb96323e890fc7b17 Mon Sep 17 00:00:00 2001 From: islathehut Date: Tue, 13 Jan 2026 09:05:37 -0500 Subject: [PATCH 1/9] Allow overriding decryption keys on keys methods --- packages/auth/src/team/Team.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/auth/src/team/Team.ts b/packages/auth/src/team/Team.ts index 1351c6fe..ca3b269a 100644 --- a/packages/auth/src/team/Team.ts +++ b/packages/auth/src/team/Team.ts @@ -796,22 +796,22 @@ export class Team extends EventEmitter { * get other members' public keys, look up the member - the `keys` property contains their public * keys. */ - public keys = (scope: KeyMetadata | KeyScope) => - select.keys(this.state, this.context.device.keys, scope) + public keys = (scope: KeyMetadata | KeyScope, decryptionKeys: KeysetWithSecrets = this.context.device.keys) => + select.keys(this.state, decryptionKeys, scope) - public keysAllGenerations = (scope: KeyMetadata | KeyScope) => - select.keysAllGen(this.state, this.context.device.keys, scope) + public keysAllGenerations = (scope: KeyMetadata | KeyScope, decryptionKeys: KeysetWithSecrets = this.context.device.keys) => + select.keysAllGen(this.state, decryptionKeys, scope) - public allKeys = () => - select.allKeys(this.state, this.context.device.keys) + public allKeys = (decryptionKeys: KeysetWithSecrets = this.context.device.keys) => + select.allKeys(this.state, decryptionKeys) /** Returns the keys for the given role. */ - public roleKeys = (roleName: string, generation?: number) => - this.keys({ type: KeyType.ROLE, name: roleName, generation }) + public roleKeys = (roleName: string, generation?: number, decryptionKeys: KeysetWithSecrets = this.context.device.keys) => + this.keys({ type: KeyType.ROLE, name: roleName, generation }, decryptionKeys) /** Returns the keys for the given role. */ - public roleKeysAllGenerations = (roleName: string) => - this.keysAllGenerations({ type: KeyType.ROLE, name: roleName }) + public roleKeysAllGenerations = (roleName: string, decryptionKeys: KeysetWithSecrets = this.context.device.keys) => + this.keysAllGenerations({ type: KeyType.ROLE, name: roleName }, decryptionKeys) /** Returns the current team keys or a specific generation of team keys */ public teamKeys = (generation?: number) => this.keys({ ...TEAM_SCOPE, generation }) From e98d59ead90f75cc3a829e66ae9c524f5701e030 Mon Sep 17 00:00:00 2001 From: islathehut Date: Tue, 13 Jan 2026 09:11:52 -0500 Subject: [PATCH 2/9] Small fix --- packages/auth/src/team/Team.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/auth/src/team/Team.ts b/packages/auth/src/team/Team.ts index ca3b269a..6f7fd435 100644 --- a/packages/auth/src/team/Team.ts +++ b/packages/auth/src/team/Team.ts @@ -806,11 +806,11 @@ export class Team extends EventEmitter { select.allKeys(this.state, decryptionKeys) /** Returns the keys for the given role. */ - public roleKeys = (roleName: string, generation?: number, decryptionKeys: KeysetWithSecrets = this.context.device.keys) => + public roleKeys = (roleName: string, generation?: number, decryptionKeys?: KeysetWithSecrets) => this.keys({ type: KeyType.ROLE, name: roleName, generation }, decryptionKeys) /** Returns the keys for the given role. */ - public roleKeysAllGenerations = (roleName: string, decryptionKeys: KeysetWithSecrets = this.context.device.keys) => + public roleKeysAllGenerations = (roleName: string, decryptionKeys?: KeysetWithSecrets) => this.keysAllGenerations({ type: KeyType.ROLE, name: roleName }, decryptionKeys) /** Returns the current team keys or a specific generation of team keys */ @@ -868,7 +868,7 @@ export class Team extends EventEmitter { } private readonly createMemberLockboxes = (member: Member) => { - const roleKeys = member.roles.map(this.roleKeys) + const roleKeys = member.roles.map((roleName: string) => this.roleKeys(roleName)) const createLockboxRoleKeysForMember = (keys: KeysetWithSecrets) => { return lockbox.create(keys, member.keys) } From 9ea85c242ca1306a1bae496523e291c71e5b069d Mon Sep 17 00:00:00 2001 From: islathehut Date: Tue, 13 Jan 2026 09:26:54 -0500 Subject: [PATCH 3/9] Make creating a lockbox a team method --- packages/auth/src/team/Team.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/auth/src/team/Team.ts b/packages/auth/src/team/Team.ts index 6f7fd435..1addfe46 100644 --- a/packages/auth/src/team/Team.ts +++ b/packages/auth/src/team/Team.ts @@ -850,6 +850,11 @@ export class Team extends EventEmitter { if (isForServer) device.keys = newKeys // (a server plays the role of both a user and a device) } + public createLockbox = (roleName: string, encryptionKeys: KeysetWithSecrets): lockbox.Lockbox[] => { + const roleKeys = this.roleKeysAllGenerations(roleName) + return roleKeys.map(keys => lockbox.create(keys, encryptionKeys)) + } + private checkForPendingKeyRotations() { // Only admins can rotate keys if (!this.memberIsAdmin(this.userId)) { From 39cdf3022bd9c33bc306a5c4f62f50efb8310efa Mon Sep 17 00:00:00 2001 From: islathehut Date: Tue, 13 Jan 2026 09:43:20 -0500 Subject: [PATCH 4/9] Don't need all generations --- packages/auth/src/team/Team.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/auth/src/team/Team.ts b/packages/auth/src/team/Team.ts index 1addfe46..58c0d1b8 100644 --- a/packages/auth/src/team/Team.ts +++ b/packages/auth/src/team/Team.ts @@ -850,9 +850,9 @@ export class Team extends EventEmitter { if (isForServer) device.keys = newKeys // (a server plays the role of both a user and a device) } - public createLockbox = (roleName: string, encryptionKeys: KeysetWithSecrets): lockbox.Lockbox[] => { - const roleKeys = this.roleKeysAllGenerations(roleName) - return roleKeys.map(keys => lockbox.create(keys, encryptionKeys)) + public createLockbox = (roleName: string, encryptionKeys: KeysetWithSecrets): lockbox.Lockbox => { + const roleKeys = this.roleKeys(roleName) + return lockbox.create(roleKeys, encryptionKeys) } private checkForPendingKeyRotations() { From 77fe6e7054987e0b0de4f95191fa05ca4f52936e Mon Sep 17 00:00:00 2001 From: islathehut Date: Tue, 13 Jan 2026 11:44:51 -0500 Subject: [PATCH 5/9] Add comment --- packages/auth/src/team/Team.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/auth/src/team/Team.ts b/packages/auth/src/team/Team.ts index 58c0d1b8..1537a000 100644 --- a/packages/auth/src/team/Team.ts +++ b/packages/auth/src/team/Team.ts @@ -850,6 +850,13 @@ export class Team extends EventEmitter { if (isForServer) device.keys = newKeys // (a server plays the role of both a user and a device) } + /** + * Create a new lockbox containing a role's current generation keys encrypted to an arbitrary keyset + * + * @param roleName Role whose keys we want to encapsulate in the lockbox (must be a role the user has!) + * @param encryptionKeys Keys to encrypt the lockbox to + * @returns Generated lockbox + */ public createLockbox = (roleName: string, encryptionKeys: KeysetWithSecrets): lockbox.Lockbox => { const roleKeys = this.roleKeys(roleName) return lockbox.create(roleKeys, encryptionKeys) From abb74d4ec620af1ec887535b478775daadcde0f6 Mon Sep 17 00:00:00 2001 From: islathehut Date: Wed, 14 Jan 2026 10:06:05 -0500 Subject: [PATCH 6/9] Actually add all gens to lockboxes and add self-assign method --- packages/auth/src/team/Team.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/auth/src/team/Team.ts b/packages/auth/src/team/Team.ts index 1537a000..ceeff517 100644 --- a/packages/auth/src/team/Team.ts +++ b/packages/auth/src/team/Team.ts @@ -348,10 +348,10 @@ export class Team extends EventEmitter { } /** Give a member a role */ - public addMemberRole = (userId: string, roleName: string) => { + public addMemberRole = (userId: string, roleName: string, decryptionKeys?: KeysetWithSecrets) => { // Make a lockbox for the role const member = this.members(userId) - const allGenKeys = this.roleKeysAllGenerations(roleName) + const allGenKeys = this.roleKeysAllGenerations(roleName, decryptionKeys) const lockboxRoleKeysForMember = allGenKeys.map(roleKeys => lockbox.create(roleKeys, member.keys)) // Post the member role to the graph @@ -361,6 +361,11 @@ export class Team extends EventEmitter { }) } + /** Give yourself a role */ + public addMemberRoleToSelf = (roleName: string, decryptionKeys: KeysetWithSecrets) => { + this.addMemberRole(this.userId, roleName, decryptionKeys) + } + /** Remove a role from a member */ public removeMemberRole = (userId: string, roleName: string) => { if (roleName === ADMIN) { @@ -857,9 +862,9 @@ export class Team extends EventEmitter { * @param encryptionKeys Keys to encrypt the lockbox to * @returns Generated lockbox */ - public createLockbox = (roleName: string, encryptionKeys: KeysetWithSecrets): lockbox.Lockbox => { - const roleKeys = this.roleKeys(roleName) - return lockbox.create(roleKeys, encryptionKeys) + public createLockbox = (roleName: string, encryptionKeys: KeysetWithSecrets): lockbox.Lockbox[] => { + const roleKeys = this.roleKeysAllGenerations(roleName) + return roleKeys.map((keys) => lockbox.create(keys, encryptionKeys)) } private checkForPendingKeyRotations() { From 6b38a0af0543fc7a647a3211cf8cf5800b2f0b75 Mon Sep 17 00:00:00 2001 From: islathehut Date: Thu, 15 Jan 2026 13:31:26 -0500 Subject: [PATCH 7/9] Add tests and self-assign role list --- packages/auth/src/team/Team.ts | 3 + packages/auth/src/team/createTeam.ts | 4 +- packages/auth/src/team/test/roles.test.ts | 69 ++++++++++++++++++++++- packages/auth/src/team/types.ts | 3 + packages/auth/src/util/testing/setup.ts | 2 +- 5 files changed, 77 insertions(+), 4 deletions(-) diff --git a/packages/auth/src/team/Team.ts b/packages/auth/src/team/Team.ts index ceeff517..575a24ce 100644 --- a/packages/auth/src/team/Team.ts +++ b/packages/auth/src/team/Team.ts @@ -66,6 +66,7 @@ export class Team extends EventEmitter { private readonly context: LocalUserContext private readonly log: (o: any, ...args: any[]) => void private readonly seed: string + private readonly selfAssignRoles: string[] /** * We can make a team instance either by creating a brand-new team, or restoring one from a stored graph. @@ -139,6 +140,7 @@ export class Team extends EventEmitter { } this.state = this.store.getState() + this.selfAssignRoles = options.selfAssignRoles ?? [] // Wire up event listeners this.on('updated', () => { @@ -363,6 +365,7 @@ export class Team extends EventEmitter { /** Give yourself a role */ public addMemberRoleToSelf = (roleName: string, decryptionKeys: KeysetWithSecrets) => { + assert(this.selfAssignRoles.includes(roleName), `Cannot self-assign role ${roleName}`) this.addMemberRole(this.userId, roleName, decryptionKeys) } diff --git a/packages/auth/src/team/createTeam.ts b/packages/auth/src/team/createTeam.ts index b596a238..755cd3f8 100644 --- a/packages/auth/src/team/createTeam.ts +++ b/packages/auth/src/team/createTeam.ts @@ -3,8 +3,8 @@ import { TEAM_SCOPE } from './constants.js' import { type LocalContext } from 'team/context.js' import { Team } from 'team/Team.js' -export function createTeam(teamName: string, context: LocalContext, seed?: string) { +export function createTeam(teamName: string, context: LocalContext, seed?: string, selfAssignRoles?: string[]) { const teamKeys = createKeyset(TEAM_SCOPE, seed) - return new Team({ teamName, context, teamKeys }) + return new Team({ teamName, context, teamKeys, selfAssignRoles }) } diff --git a/packages/auth/src/team/test/roles.test.ts b/packages/auth/src/team/test/roles.test.ts index 2f89b33e..ac21cfbb 100644 --- a/packages/auth/src/team/test/roles.test.ts +++ b/packages/auth/src/team/test/roles.test.ts @@ -2,8 +2,10 @@ import { ADMIN } from 'role/index.js' import * as teams from 'team/index.js' import { setup } from 'util/testing/index.js' import 'util/testing/expect/toLookLikeKeyset.js' -import { symmetric } from '@localfirst/crypto' +import { randomBytes, symmetric } from '@localfirst/crypto' import { describe, expect, it } from 'vitest' +import { randomUUID } from 'crypto' +import { createKeyset, KeyScope } from '@localfirst/crdx' const MANAGERS = 'managers' const managers = { roleName: MANAGERS } @@ -112,6 +114,71 @@ describe('Team', () => { expect(bobLooksForAdminKeys).toThrow() }) + it('self-assigns a role', () => { + const { alice, bob } = setup('alice', 'bob') + + // đŸ‘©đŸŸ Alice creates MEMBER role + alice.team.addRole('MEMBER') + + // đŸ‘©đŸŸ Alice is a MEMBER + expect(alice.team.hasRole('MEMBER')).toBe(true) + expect(alice.team.memberHasRole(alice.userId, 'MEMBER')).toBe(true) + + // đŸ‘©đŸŸ Alice creates a lockbox for MEMBER keys under arbitrary keys + const randomSeed = randomUUID() + const arbitraryScope: KeyScope = { type: 'TESTING', name: 'TESTING' } + const keySet = createKeyset(arbitraryScope, randomSeed) + alice.team.createLockbox('MEMBER', keySet) + + // đŸ‘©đŸŸ Alice persists the team + const savedTeam = alice.team.save() + + // đŸ‘šđŸ»â€đŸŠČ Bob loads the team + bob.team = teams.load(savedTeam, bob.localContext, alice.team.teamKeys()) + + // đŸ‘šđŸ»â€đŸŠČ Bob doesn't have the MEMBER role + expect(bob.team.memberHasRole(bob.userId, 'MEMBER')).toBe(false) + + // đŸ‘šđŸ»â€đŸŠČ Bob self-assigns the MEMBER role + bob.team.addMemberRoleToSelf('MEMBER', keySet) + + // đŸ‘šđŸ»â€đŸŠČ Bob has the MEMBER role keys + const bobsMemberKeys = bob.team.roleKeys('MEMBER') + expect(bobsMemberKeys).toLookLikeKeyset() + }) + + it(`attempts to self-assign a role that can't be self-assigned`, () => { + const { alice, bob } = setup('alice', 'bob') + + // đŸ‘©đŸŸ Alice creates FOOBAR role + alice.team.addRole('FOOBAR') + + // đŸ‘©đŸŸ Alice is a FOOBAR + expect(alice.team.hasRole('FOOBAR')).toBe(true) + expect(alice.team.memberHasRole(alice.userId, 'FOOBAR')).toBe(true) + + // đŸ‘©đŸŸ Alice creates a lockbox for FOOBAR keys under arbitrary keys + const randomSeed = randomUUID() + const arbitraryScope: KeyScope = { type: 'TESTING', name: 'TESTING' } + const keySet = createKeyset(arbitraryScope, randomSeed) + alice.team.createLockbox('FOOBAR', keySet) + + // đŸ‘©đŸŸ Alice persists the team + const savedTeam = alice.team.save() + + // đŸ‘šđŸ»â€đŸŠČ Bob loads the team + bob.team = teams.load(savedTeam, bob.localContext, alice.team.teamKeys()) + + // đŸ‘šđŸ»â€đŸŠČ Bob doesn't have the FOOBAR role + expect(bob.team.memberHasRole(bob.userId, 'FOOBAR')).toBe(false) + + // đŸ‘šđŸ»â€đŸŠČ Bob attempts to self-assign the FOOBAR role + const attemptToSelfAssignRole = () => { + bob.team.addMemberRoleToSelf('FOOBAR', keySet) + } + expect(attemptToSelfAssignRole()).toThrow() + }) + it('removes a role', () => { const { alice } = setup('alice') diff --git a/packages/auth/src/team/types.ts b/packages/auth/src/team/types.ts index 6e8f765c..5cfa969d 100644 --- a/packages/auth/src/team/types.ts +++ b/packages/auth/src/team/types.ts @@ -71,6 +71,9 @@ export type TeamOptions = NewOrExisting & { /** Object containing the current user and device (and optionally information about the client & version). */ context: LocalContext + + /** Roles that can be self-assigned by a member on the chain */ + selfAssignRoles?: string[] } /** Type guard for NewTeamOptions vs ExistingTeamOptions */ diff --git a/packages/auth/src/util/testing/setup.ts b/packages/auth/src/util/testing/setup.ts index 22918f11..074f56f5 100644 --- a/packages/auth/src/util/testing/setup.ts +++ b/packages/auth/src/util/testing/setup.ts @@ -77,7 +77,7 @@ export const setup = (..._config: SetupConfig) => { const founderContext = { user: testUsers[founder], device: laptops[founder] } const teamName = 'Spies ĐŻ Us' const randomSeed = teamName - const team = teams.createTeam(teamName, founderContext, randomSeed) + const team = teams.createTeam(teamName, founderContext, randomSeed, ['MEMBER']) const teamKeys = team.teamKeys() // Add members From 9997e868fa0c0bfd54b2c00cc768ad35eb725887 Mon Sep 17 00:00:00 2001 From: islathehut Date: Mon, 19 Jan 2026 09:53:51 -0500 Subject: [PATCH 8/9] Add lockbox reducer action --- packages/auth/src/team/Team.ts | 6 ++++-- packages/auth/src/team/reducer.ts | 5 +++++ packages/auth/src/team/types.ts | 8 ++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/auth/src/team/Team.ts b/packages/auth/src/team/Team.ts index 575a24ce..23425756 100644 --- a/packages/auth/src/team/Team.ts +++ b/packages/auth/src/team/Team.ts @@ -365,7 +365,7 @@ export class Team extends EventEmitter { /** Give yourself a role */ public addMemberRoleToSelf = (roleName: string, decryptionKeys: KeysetWithSecrets) => { - assert(this.selfAssignRoles.includes(roleName), `Cannot self-assign role ${roleName}`) + // assert(this.selfAssignRoles.includes(roleName), `Cannot self-assign role ${roleName}`) this.addMemberRole(this.userId, roleName, decryptionKeys) } @@ -867,7 +867,9 @@ export class Team extends EventEmitter { */ public createLockbox = (roleName: string, encryptionKeys: KeysetWithSecrets): lockbox.Lockbox[] => { const roleKeys = this.roleKeysAllGenerations(roleName) - return roleKeys.map((keys) => lockbox.create(keys, encryptionKeys)) + const lockboxes = roleKeys.map((keys) => lockbox.create(keys, encryptionKeys)) + this.dispatch({ type: 'ADD_LOCKBOXES', payload: { lockboxes }}) + return lockboxes } private checkForPendingKeyRotations() { diff --git a/packages/auth/src/team/reducer.ts b/packages/auth/src/team/reducer.ts index 4527e4fe..d17cc4bf 100644 --- a/packages/auth/src/team/reducer.ts +++ b/packages/auth/src/team/reducer.ts @@ -248,6 +248,11 @@ const getTransforms = (action: TeamAction): Transform[] => { ] } + case 'ADD_LOCKBOXES': { + const { lockboxes } = action.payload + return [(state) => state] + } + default: { // ignore coverage throw unrecognizedLinkType(action) diff --git a/packages/auth/src/team/types.ts b/packages/auth/src/team/types.ts index 5cfa969d..9c0e281f 100644 --- a/packages/auth/src/team/types.ts +++ b/packages/auth/src/team/types.ts @@ -241,6 +241,13 @@ export type SetTeamNameAction = { } } +export type AddLockboxesAction = { + type: 'ADD_LOCKBOXES' + payload: BasePayload & { + lockboxes: Lockbox[] + } +} + export type TeamAction = | RootAction | AddMemberAction @@ -263,6 +270,7 @@ export type TeamAction = | ChangeServerKeysAction | MessageAction | SetTeamNameAction + | AddLockboxesAction export type TeamContext = { deviceId: string From 282dfc1fe8b11f819c05c99c7648ac3174c3b5e5 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Mon, 19 Jan 2026 13:03:52 -0500 Subject: [PATCH 9/9] Fix tests --- packages/auth/src/team/test/roles.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/auth/src/team/test/roles.test.ts b/packages/auth/src/team/test/roles.test.ts index ac21cfbb..c043d266 100644 --- a/packages/auth/src/team/test/roles.test.ts +++ b/packages/auth/src/team/test/roles.test.ts @@ -119,6 +119,7 @@ describe('Team', () => { // đŸ‘©đŸŸ Alice creates MEMBER role alice.team.addRole('MEMBER') + alice.team.addMemberRole(alice.userId, 'MEMBER') // đŸ‘©đŸŸ Alice is a MEMBER expect(alice.team.hasRole('MEMBER')).toBe(true) @@ -152,6 +153,7 @@ describe('Team', () => { // đŸ‘©đŸŸ Alice creates FOOBAR role alice.team.addRole('FOOBAR') + alice.team.addMemberRole(alice.userId, 'FOOBAR') // đŸ‘©đŸŸ Alice is a FOOBAR expect(alice.team.hasRole('FOOBAR')).toBe(true) @@ -176,7 +178,7 @@ describe('Team', () => { const attemptToSelfAssignRole = () => { bob.team.addMemberRoleToSelf('FOOBAR', keySet) } - expect(attemptToSelfAssignRole()).toThrow() + expect(attemptToSelfAssignRole).toThrow() }) it('removes a role', () => {