diff --git a/demos/automerge-repo-todos/src/components/JoinTeam.tsx b/demos/automerge-repo-todos/src/components/JoinTeam.tsx index 0255bad67..30ee5db20 100644 --- a/demos/automerge-repo-todos/src/components/JoinTeam.tsx +++ b/demos/automerge-repo-todos/src/components/JoinTeam.tsx @@ -25,8 +25,8 @@ export const JoinTeam = ({ joinAs, userName, onSetup }: Props) => { const { user, device } = getUserAndDevice() const { auth, repo } = await initializeAuthRepo({ user, device }) - const { shareId, invitationSeed } = parseInvitationCode(invitationCode) - auth.addInvitation({ shareId, invitationSeed, userName }) + const { expectedTeamId, shareId, invitationSeed } = parseInvitationCode(invitationCode) + auth.addInvitation({ expectedTeamId, shareId, invitationSeed, userName }) // Once we're admitted, we'll get the Team data and our User object auth.once('joined', ({ team, user }) => { diff --git a/demos/automerge-repo-todos/src/components/TeamAdmin.tsx b/demos/automerge-repo-todos/src/components/TeamAdmin.tsx index 94bd64aba..ad1c9baf3 100644 --- a/demos/automerge-repo-todos/src/components/TeamAdmin.tsx +++ b/demos/automerge-repo-todos/src/components/TeamAdmin.tsx @@ -1,5 +1,4 @@ import { UnixTimestamp } from '@localfirst/auth' -import { getShareId } from '@localfirst/auth-provider-automerge-repo' import ClipboardJS from 'clipboard' import { MutableRefObject, useEffect, useRef, useState } from 'react' import { useAuth } from '../hooks/useAuth' @@ -29,8 +28,7 @@ export const TeamAdmin = () => { }, [copyInvitationCodeButton, invitationCode]) const createInvitationCode = (seed: string) => { - const shareId = getShareId(team) - setInvitationCode(`${shareId}${seed}`) + setInvitationCode(`${team.id}:${seed}`) } const inviteMembers = () => { diff --git a/demos/automerge-repo-todos/src/util/parseInvitationCode.ts b/demos/automerge-repo-todos/src/util/parseInvitationCode.ts index c79bbadd0..fa6256b67 100644 --- a/demos/automerge-repo-todos/src/util/parseInvitationCode.ts +++ b/demos/automerge-repo-todos/src/util/parseInvitationCode.ts @@ -2,7 +2,10 @@ import * as Auth from '@localfirst/auth' import type { ShareId } from '@localfirst/auth-provider-automerge-repo' export const parseInvitationCode = (invitationCode: string) => { - const shareId = invitationCode.slice(0, 12) as ShareId // because a ShareId is 12 characters long - see getShareId - const invitationSeed = invitationCode.slice(12) as Auth.Base58 // the rest of the code is the invitation seed - return { shareId, invitationSeed } + const [expectedTeamId, invitationSeed] = invitationCode.split(':') as [ + Auth.Base58, + Auth.Base58, + ] + const shareId = expectedTeamId.slice(0, 12) as ShareId + return { expectedTeamId, shareId, invitationSeed } } diff --git a/demos/quiet-sandbox/src/auth/services/invites/inviteService.ts b/demos/quiet-sandbox/src/auth/services/invites/inviteService.ts index 50a4a11b9..48c633a24 100644 --- a/demos/quiet-sandbox/src/auth/services/invites/inviteService.ts +++ b/demos/quiet-sandbox/src/auth/services/invites/inviteService.ts @@ -2,11 +2,20 @@ * Handles invite-related chain operations */ -import { BaseChainService } from "../baseService.js" -import { ValidationResult } from "../../../../../../packages/crdx/dist/validator/types.js" -import { Base58, InvitationMap, InvitationState, InviteResult, Keyset, ProofOfInvitation, UnixTimestamp } from "@localfirst/auth" -import { SigChain } from "../../chain.js" -import { RoleName } from "../roles/roles.js" +import { BaseChainService } from '../baseService.js' +import { ValidationResult } from '../../../../../../packages/crdx/dist/validator/types.js' +import { + Base58, + Device, + InvitationClaim, + InvitationState, + InviteResult, + Keyset, + ProofOfInvitation, + UnixTimestamp, +} from '@localfirst/auth' +import { SigChain } from '../../chain.js' +import { RoleName } from '../roles/roles.js' export const DEFAULT_MAX_USES = 1 export const DEFAULT_INVITATION_VALID_FOR_MS = 604_800_000 // 1 week @@ -16,11 +25,14 @@ class InviteService extends BaseChainService { return new InviteService(sigChain) } - public create(validForMs: number = DEFAULT_INVITATION_VALID_FOR_MS, maxUses: number = DEFAULT_MAX_USES) { + public create( + validForMs: number = DEFAULT_INVITATION_VALID_FOR_MS, + maxUses: number = DEFAULT_MAX_USES + ) { const expiration = (Date.now() + validForMs) as UnixTimestamp const invitation: InviteResult = this.sigChain.team.inviteMember({ expiration, - maxUses + maxUses, }) // this.activeSigChain.persist() return invitation @@ -35,27 +47,75 @@ class InviteService extends BaseChainService { return this.sigChain.team.getInvitation(id) } - public static generateProof(seed: string): ProofOfInvitation { - return SigChain.lfa.invitation.generateProof(seed) + /** + * Creates a version-2 proof binding the invitation seed to the exact identity claim and both + * handshake nonces. + */ + public static generateProof( + seed: string, + claim: InvitationClaim, + acceptorNonce: Base58, + inviteeNonce: Base58 + ): ProofOfInvitation { + return SigChain.lfa.invitation.generateProof({ + seed, + claim, + acceptorNonce, + inviteeNonce, + }) } - public validateProof(proof: ProofOfInvitation): boolean { - const validationResult = this.sigChain.team.validateInvitation(proof) as ValidationResult + /** + * Validates the exact invitation claim and requires the proof's acceptor nonce to match the + * current handshake. + */ + public validateProof( + proof: ProofOfInvitation, + claim: InvitationClaim, + expectedAcceptorNonce: Base58 + ): boolean { + const validationResult = this.sigChain.team.validateInvitation( + proof, + claim.invitationKind, + claim, + expectedAcceptorNonce + ) as ValidationResult if (!validationResult.isValid) { console.error(`Proof was invalid or was on an invalid invitation`, validationResult.error) - return true + return false } return true } - public acceptProof(proof: ProofOfInvitation, username: string, publicKeys: Keyset) { - this.sigChain.team.admitMember(proof, publicKeys, username) + /** + * Admits a member only when its version-2 proof matches the supplied identity, device, and + * expected acceptor nonce. The accepted proof and claim are recorded in the team action. + */ + public acceptProof( + proof: ProofOfInvitation, + username: string, + publicKeys: Keyset, + device: Device, + expectedAcceptorNonce: Base58 + ) { + this.sigChain.team.admitMember(proof, publicKeys, username, device, expectedAcceptorNonce) // this.activeSigChain.persist() } - public admitMemberFromInvite(proof: ProofOfInvitation, username: string, userId: string, publicKeys: Keyset): string { - this.sigChain.team.admitMember(proof, publicKeys, username) + /** + * Admits a proof-bound member, then assigns the admitted user to the standard member role. + * Returns the admitted username. + */ + public admitMemberFromInvite( + proof: ProofOfInvitation, + username: string, + userId: string, + publicKeys: Keyset, + device: Device, + expectedAcceptorNonce: Base58 + ): string { + this.sigChain.team.admitMember(proof, publicKeys, username, device, expectedAcceptorNonce) this.sigChain.roles.addMember(userId, RoleName.MEMBER) // this.activeSigChain.persist() return username @@ -71,6 +131,4 @@ class InviteService extends BaseChainService { } } -export { - InviteService -} \ No newline at end of file +export { InviteService } diff --git a/demos/quiet-sandbox/src/auth/services/members/types.ts b/demos/quiet-sandbox/src/auth/services/members/types.ts index 353a3a4a9..4d7c3dc68 100644 --- a/demos/quiet-sandbox/src/auth/services/members/types.ts +++ b/demos/quiet-sandbox/src/auth/services/members/types.ts @@ -1,4 +1,4 @@ -import { Keyset, LocalUserContext, ProofOfInvitation } from "@localfirst/auth" +import { Base58, Keyset, LocalUserContext, ProofOfInvitation } from "@localfirst/auth" export type MemberSearchOptions = { includeRemoved: boolean @@ -9,6 +9,7 @@ export type ProspectiveUser = { context: LocalUserContext inviteProof: ProofOfInvitation publicKeys: Keyset + acceptorNonce: Base58 } export const DEFAULT_SEARCH_OPTIONS: MemberSearchOptions = { includeRemoved: false, throwOnMissing: true } diff --git a/demos/quiet-sandbox/src/auth/services/members/userService.ts b/demos/quiet-sandbox/src/auth/services/members/userService.ts index 2b0b3b28a..2c3264315 100644 --- a/demos/quiet-sandbox/src/auth/services/members/userService.ts +++ b/demos/quiet-sandbox/src/auth/services/members/userService.ts @@ -5,7 +5,13 @@ //import { KeyMap } from '../../../../../../packages/auth/dist/team/selectors/keyMap.js' import { BaseChainService } from '../baseService.js' import { ProspectiveUser, MemberSearchOptions, DEFAULT_SEARCH_OPTIONS } from './types.js' -import { DeviceWithSecrets, LocalUserContext, Member, User, UserWithSecrets } from '@localfirst/auth' +import { + DeviceWithSecrets, + LocalUserContext, + Member, + User, + UserWithSecrets, +} from '@localfirst/auth' import { SigChain } from '../../chain.js' import { DeviceService } from './deviceService.js' import { InviteService } from '../invites/inviteService.js' @@ -18,7 +24,7 @@ class UserService extends BaseChainService { /** * Generates a brand new QuietUser instance with an initial device from a given username - * + * * @param name The username * @param id Optionally specify the user's ID (otherwise autogenerate) * @returns New QuietUser instance with an initial device @@ -29,19 +35,36 @@ class UserService extends BaseChainService { return { user, - device + device, } } + /** + * Creates a prospective member and a version-2 invitation proof bound to that member's public + * identity, initial device, and fresh handshake nonces. + * + * @param name Username claimed by the prospective member. + * @param seed Invitation seed shared by an existing team member. + * @returns The local context, public keys, proof, and acceptor nonce needed to join the team. + */ public static createFromInviteSeed(name: string, seed: string): ProspectiveUser { const context = this.create(name) - const inviteProof = InviteService.generateProof(seed) const publicKeys = UserService.redactUser(context.user).keys + const claim = { + invitationKind: 'member', + userName: context.user.userName, + userKeys: publicKeys, + device: SigChain.lfa.redactDevice(context.device), + } as const + const acceptorNonce = SigChain.lfa.invitation.randomSeed() + const inviteeNonce = SigChain.lfa.invitation.randomSeed() + const inviteProof = InviteService.generateProof(seed, claim, acceptorNonce, inviteeNonce) return { context, inviteProof, - publicKeys + publicKeys, + acceptorNonce, } } @@ -53,7 +76,10 @@ class UserService extends BaseChainService { return this.sigChain.team.members() } - public getMembersById(memberIds: string[], options: MemberSearchOptions = DEFAULT_SEARCH_OPTIONS): Member[] { + public getMembersById( + memberIds: string[], + options: MemberSearchOptions = DEFAULT_SEARCH_OPTIONS + ): Member[] { if (memberIds.length === 0) { return [] } @@ -62,7 +88,7 @@ class UserService extends BaseChainService { } public getMemberByName(memberName: string): Member | undefined { - return this.getAllMembers().find((member) => member.userName === memberName) + return this.getAllMembers().find(member => member.userName === memberName) } public static redactUser(user: UserWithSecrets): User { @@ -70,6 +96,4 @@ class UserService extends BaseChainService { } } -export { - UserService -} +export { UserService } diff --git a/demos/quiet-sandbox/src/network.ts b/demos/quiet-sandbox/src/network.ts index 6d2bc3769..51c582724 100644 --- a/demos/quiet-sandbox/src/network.ts +++ b/demos/quiet-sandbox/src/network.ts @@ -609,7 +609,8 @@ const main = async () => { storage2.setContext(prospectiveUser.context) storage2.setAuthContext({ ...prospectiveUser.context, - invitationSeed: seed + invitationSeed: seed, + expectedTeamId: sigChain.team.id }) const peer2 = new Libp2pService(peerId2, storage2); await peer2.init(); diff --git a/demos/quiet-sandbox/src/scripts/test_auth.ts b/demos/quiet-sandbox/src/scripts/test_auth.ts index be0f59214..8a7c23449 100644 --- a/demos/quiet-sandbox/src/scripts/test_auth.ts +++ b/demos/quiet-sandbox/src/scripts/test_auth.ts @@ -66,7 +66,9 @@ sigChain.invites.admitMemberFromInvite( prospectiveMember.inviteProof, prospectiveMember.context.user.userName, prospectiveMember.context.user.userId, - prospectiveMember.publicKeys + prospectiveMember.publicKeys, + SigChain.lfa.redactDevice(prospectiveMember.context.device), + prospectiveMember.acceptorNonce ) const { @@ -84,4 +86,4 @@ console.log(`Members: ${JSON.stringify(newUsersChain.users.getAllMembers(), null const encryptedAndSigned = newUsersChain.crypto.encryptAndSign('foobar', { type: EncryptionScopeType.ROLE, name: RoleName.MEMBER }, newUsersContext) console.log(`Encrypted and signed: ${JSON.stringify(encryptedAndSigned, null, 2)}`) -console.log(`Decrypted: ${newUsersChain.crypto.decryptAndVerify(encryptedAndSigned.encrypted, encryptedAndSigned.signature, newUsersContext)}`) \ No newline at end of file +console.log(`Decrypted: ${newUsersChain.crypto.decryptAndVerify(encryptedAndSigned.encrypted, encryptedAndSigned.signature, newUsersContext)}`) diff --git a/packages/auth-provider-automerge-repo/src/AuthProvider.ts b/packages/auth-provider-automerge-repo/src/AuthProvider.ts index 011c16dcf..8d43d5330 100644 --- a/packages/auth-provider-automerge-repo/src/AuthProvider.ts +++ b/packages/auth-provider-automerge-repo/src/AuthProvider.ts @@ -298,8 +298,14 @@ export class AuthProvider extends EventEmitter { } /** - * Creates a share for a team we've been invited to, either as a new member or as a new device for - * an existing member. + * Registers an invitation for joining a team as a new member or device, then attempts connections + * to peers advertising the truncated `shareId`. + * + * The invitation must also carry the full `expectedTeamId`; acceptance fails if the authenticated + * graph root differs from that independently obtained trust anchor. + * + * @param invitation Discovery metadata, full expected team root, secret seed, and optional device + * owner name. */ public async addInvitation(invitation: Invitation) { const { shareId } = invitation 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 b380460cf..978250923 100644 --- a/packages/auth-provider-automerge-repo/src/test/AuthProvider.test.ts +++ b/packages/auth-provider-automerge-repo/src/test/AuthProvider.test.ts @@ -76,6 +76,7 @@ describe('auth provider for automerge-repo', () => { // Bob uses the invitation to join void bob.authProvider.addInvitation({ + expectedTeamId: aliceTeam.id, shareId: getShareId(aliceTeam), invitationSeed: bobInviteCode, }) @@ -121,12 +122,14 @@ describe('auth provider for automerge-repo', () => { // Alice creates team A on her laptop const team = Auth.createTeam('team A', laptopContext) + team.addRole('member') await laptopAuth.addTeam(team) // She creates an invitation code for her phone const { seed: phoneInviteCode } = team.inviteDevice() await phoneAuth.addInvitation({ + expectedTeamId: team.id, shareId: getShareId(team), userName: alice.userName, invitationSeed: phoneInviteCode, @@ -156,6 +159,7 @@ describe('auth provider for automerge-repo', () => { // Eve knows Bob has been invited but doesn't know the code await eve.authProvider.addInvitation({ + expectedTeamId: aliceTeam.id, shareId: getShareId(aliceTeam), invitationSeed: 'passw0rd', }) @@ -274,6 +278,7 @@ describe('auth provider for automerge-repo', () => { // Charlie uses the invitation to join await charlie.authProvider.addInvitation({ + expectedTeamId: aliceTeam.id, shareId: getShareId(aliceTeam), invitationSeed: charlieInvite, }) @@ -290,6 +295,7 @@ describe('auth provider for automerge-repo', () => { // Bob uses the invitation to join await bob.authProvider.addInvitation({ + expectedTeamId: aliceTeam.id, shareId: getShareId(aliceTeam), invitationSeed: bobInvite, }) @@ -326,6 +332,7 @@ describe('auth provider for automerge-repo', () => { await alice.authProvider.addTeam(aliceTeam) const { seed: bobInvite } = aliceTeam.inviteMember() await bob.authProvider.addInvitation({ + expectedTeamId: aliceTeam.id, shareId: getShareId(aliceTeam), invitationSeed: bobInvite, }) diff --git a/packages/auth-provider-automerge-repo/src/types.ts b/packages/auth-provider-automerge-repo/src/types.ts index c00387eb0..c0db88ea7 100644 --- a/packages/auth-provider-automerge-repo/src/types.ts +++ b/packages/auth-provider-automerge-repo/src/types.ts @@ -60,15 +60,22 @@ export type SerializedPrivateShare = SerializedPublicShare & { /** * There are two ways for a device to join a team with an invitation: * - * - If we're a new member joining a team for the first time, we just provide the share ID (which is - * the team ID) and the secret invitation code we were given. - * - If we're a new device being added by an existing member, we also provide the user's name and - * ID. + * - A new member provides the truncated share ID used for peer discovery, the full immutable team + * root used as the trust anchor, and the secret invitation seed. + * - A new device provides those values plus the existing user's name. + * + * `shareId` is not security-sensitive and must never be substituted for `expectedTeamId`. */ export type Invitation = DeviceInvitation | MemberInvitation export type MemberInvitation = { + /** Full immutable team root; unlike shareId, this value is security-sensitive and untruncated. */ + expectedTeamId: Auth.Base58 + + /** Truncated team-root prefix used only to discover peers that may serve the share. */ shareId: ShareId + + /** Secret seed used to prove possession of the invitation and decrypt its acceptance. */ invitationSeed: string } diff --git a/packages/auth/src/connection/Connection.ts b/packages/auth/src/connection/Connection.ts index 867195cd5..0d69b49dc 100644 --- a/packages/auth/src/connection/Connection.ts +++ b/packages/auth/src/connection/Connection.ts @@ -8,7 +8,14 @@ import { receiveMessage, redactKeys, } from '@localfirst/crdx' -import { asymmetric, base58, randomKeyBytes, symmetric, type Hash } from '@localfirst/crypto' +import { + asymmetric, + base58, + randomKey, + randomKeyBytes, + symmetric, + type Hash, +} from '@localfirst/crypto' import { assert, debug, Logger, SharedLogger } from '@localfirst/shared' import { deriveSharedKey } from 'connection/deriveSharedKey.js' import { @@ -20,6 +27,7 @@ import { JOINED_WRONG_TEAM, MEMBER_REMOVED, NEITHER_IS_MEMBER, + PROTOCOL_VERSION_UNSUPPORTED, SERVER_REMOVED, TIMEOUT, createErrorMessage, @@ -27,28 +35,29 @@ import { UNHANDLED, ADMIT_MEMBER_LINK_MISSING, } from 'connection/errors.js' -import { getDeviceUserFromGraph } from 'connection/getDeviceUserFromGraph.js' +import { getDeviceUserFromState } from 'connection/getDeviceUserFromGraph.js' import * as identity from 'connection/identity.js' -import type { ConnectionMessage, DisconnectMessage } from 'connection/message.js' +import { + isReadyMessage, + type ConnectionMessage, + type DisconnectMessage, +} from 'connection/message.js' import { redactDevice } from 'device/index.js' import * as invitations from 'invitation/index.js' import { pack, unpack } from 'msgpackr' -import { getTeamState } from 'team/getTeamState.js' -import { Team, decryptTeamGraph, type TeamAction, type TeamContext } from 'team/index.js' -import * as select from 'team/selectors/index.js' +import { Team, type TeamAction, type TeamContext } from 'team/index.js' +import { markTeamGraphAuthenticated } from 'team/authenticatedTeamGraph.js' +import { decryptTrustedTeamGraph } from 'team/decryptTrustedTeamGraph.js' +import { withEvaluatedTeamGraph } from 'team/evaluatedTeamGraph.js' import { arraysAreEqual } from 'util/arraysAreEqual.js' import { KeyType } from 'util/index.js' import { syncMessageSummary } from 'util/testing/messageSummary.js' import { and, assertEvent, assign, createActor, setup } from 'xstate' import { MessageQueue, type NumberedMessage } from './MessageQueue.js' import { extendServerContext, getUserName, messageSummary, stateSummary } from './helpers.js' -import type { - ConnectionContext, - ConnectionEvents, - Context, - IdentityClaim, - InviteeMemberIdentityClaim, -} from './types.js' +import { createInvitationAcceptance } from './invitationAcceptance.js' +import type { ConnectionContext, ConnectionEvents, Context, IdentityClaim } from './types.js' +import { processInvitationAcceptance } from './validateInvitationAcceptance.js' import { isInviteeClaim, isInviteeContext, @@ -107,6 +116,10 @@ export class Connection extends EventEmitter { constructor({ sendMessage, context, createLogger }: ConnectionParams) { super() + assert( + !isInviteeContext(context) || context.expectedTeamId !== undefined, + 'Invitee connections require an expected team ID' + ) const username = getUserName(context) const loggerModuleName = `auth:connection:${username}` @@ -123,8 +136,12 @@ export class Connection extends EventEmitter { this.#messageQueue = this.#initializeMessageQueue(sendMessage, this.logger, username) // On sync server, the server keys act as both user keys and device keys - const initialContext = isServerContext(context) ? extendServerContext(context) : context - + const baseContext = isServerContext(context) ? extendServerContext(context) : context + const initialContext = { + ...baseContext, + acceptorNonce: randomKey(), + inviteeNonce: randomKey(), + } const machine = setup({ types: { context: {} as ConnectionContext, @@ -137,12 +154,15 @@ export class Connection extends EventEmitter { actions: { // IDENTITY CLAIMS - requestIdentityClaim: () => { + requestIdentityClaim: ({ context }) => { this.logger.debug('requesting identity claim') - this.#queueMessage('REQUEST_IDENTITY') + this.#queueMessage('REQUEST_IDENTITY', { + acceptorNonce: context.acceptorNonce, + }) }, - sendIdentityClaim: assign(({ context }) => { + sendIdentityClaim: assign(({ context, event }) => { + assertEvent(event, 'REQUEST_IDENTITY') this.logger.debug('sending identity claim') const createIdentityClaim = (context: ConnectionContext): IdentityClaim => { if (isMemberContext(context)) { @@ -155,21 +175,44 @@ export class Connection extends EventEmitter { // I'm a new user and I have an invitation assert(context.invitationSeed) const { userName, keys } = context.user - return { - proofOfInvitation: invitations.generateProof(context.invitationSeed), + const claim = { + invitationKind: 'member', userName, userKeys: redactKeys(keys), device: redactDevice(context.device), + } as const + return { + ...claim, + proofOfInvitation: invitations.generateProof({ + seed: context.invitationSeed, + claim, + acceptorNonce: event.payload.acceptorNonce, + inviteeNonce: context.inviteeNonce, + }), } } if (isInviteeDeviceContext(context)) { // I'm a new device for an existing user and I have an invitation assert(context.invitationSeed) const { userName, device } = context - return { - proofOfInvitation: invitations.generateProof(context.invitationSeed), + const { + userId: _untrustedUserId, + keys, + ...deviceInfo + } = device as typeof device & { userId?: string } + const claim = { + invitationKind: 'device', userName, - device: redactDevice(device), + device: { ...deviceInfo, keys: redactKeys(keys) }, + } as const + return { + ...claim, + proofOfInvitation: invitations.generateProof({ + seed: context.invitationSeed, + claim, + acceptorNonce: event.payload.acceptorNonce, + inviteeNonce: context.inviteeNonce, + }), } } // ignore coverage - that should have been exhaustive @@ -201,16 +244,25 @@ export class Connection extends EventEmitter { assert(theirIdentityClaim) assert(isInviteeClaim(theirIdentityClaim)) - const { proofOfInvitation } = theirIdentityClaim + const { proofOfInvitation, ...invitationClaim } = theirIdentityClaim + const invitation = team.getInvitation(proofOfInvitation.id) + assert(invitation.version === 2, 'Legacy invitations cannot admit identities') const admit = () => { if (isInviteeMemberClaim(theirIdentityClaim)) { this.logger.debug('handling member invite action') // New member const { userName, userKeys } = theirIdentityClaim - team.admitMember(proofOfInvitation, userKeys, userName) + team.admitMember( + proofOfInvitation, + userKeys, + userName, + theirIdentityClaim.device, + context.acceptorNonce + ) const userId = userKeys.name if ( + team.hasRole('member') && context.server == null && !team.hasServer(userId) && !team.hasServer(context.user?.userId!) @@ -223,7 +275,12 @@ export class Connection extends EventEmitter { this.logger.debug('handling device invite action') // New device for existing member const { device } = theirIdentityClaim - team.admitDevice(proofOfInvitation, device) + team.admitDevice( + proofOfInvitation, + device, + theirIdentityClaim.userName, + context.acceptorNonce + ) const { deviceId } = device const { userId } = team.memberByDeviceId(deviceId) return team.members(userId) @@ -232,18 +289,44 @@ export class Connection extends EventEmitter { const peer = admit() // Welcome them by sending the team's graph, so they can reconstruct team membership state - this.#queueMessage('ACCEPT_INVITATION', { - serializedGraph: team.save(), - teamKeyring: team.teamKeyring(), - }) + this.#queueMessage( + 'ACCEPT_INVITATION', + createInvitationAcceptance({ + invitation, + proof: proofOfInvitation, + claim: invitationClaim, + senderDevice: context.device, + serializedGraph: team.save(), + teamKeyring: team.teamKeyring(), + }) + ) return { peer } }), - joinTeam: assign(({ context, event }) => { + receiveInvitationAcceptance: assign(({ context, event }) => { assertEvent(event, 'ACCEPT_INVITATION') - this.logger.debug('joining team post invitation acceptance', event) - const { serializedGraph, teamKeyring } = event.payload + assert(context.invitationSeed) + assert(context.expectedTeamId) + assert(isInviteeClaim(context.ourIdentityClaim!)) + const { proofOfInvitation, ...claim } = context.ourIdentityClaim + const invitationAcceptanceResult = processInvitationAcceptance({ + payload: event.payload, + invitationSeed: context.invitationSeed, + expectedTeamId: context.expectedTeamId, + proof: proofOfInvitation, + claim, + logger: this.logger, + }) + return { invitationAcceptanceResult } + }), + + joinTeam: assign(({ context }) => { + this.logger.debug('joining team post invitation acceptance') + const validation = context.invitationAcceptanceResult + assert(validation) + assert(validation.isValid) + const { teamKeyring } = validation.value.acceptance const { device, invitationSeed } = context assert(invitationSeed) @@ -253,27 +336,32 @@ export class Connection extends EventEmitter { // yet, so we need to get those from the graph. We use the invitation seed to generate // the starter keys for the new device. We can use these to unlock a lockbox on the team // graph that contains our user keys. - getDeviceUserFromGraph({ - serializedGraph, - teamKeyring, + getDeviceUserFromState({ + state: validation.value.state, invitationSeed, - logger: this.logger.extend('getDeviceUser'), }) // When admitting us, our peer added our user to the team graph. We've been given the // serialized and encrypted graph, and the team keyring. We can now decrypt the graph and // reconstruct the team in order to join it. - const team = new Team({ - source: serializedGraph, - context: { user, device }, - teamKeyring, - sharedLogger: this.logger.sharedLogger, - }) + const team = new Team( + withEvaluatedTeamGraph( + { + source: validation.value.graph, + context: { user, device }, + teamKeyring, + sharedLogger: this.logger.sharedLogger, + }, + validation.value.machineResult + ) + ) // 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, + team, + } }), // AUTHENTICATION @@ -324,7 +412,12 @@ export class Connection extends EventEmitter { assert(roles) assert(userId) - if (!roles!.includes('member') && context.server == null && !team!.hasServer(userId!)) { + if ( + team.hasRole('member') && + !roles!.includes('member') && + context.server == null && + !team!.hasServer(userId!) + ) { team!.addMemberRole(userId!, 'member') } this.#queueMessage('ACCEPT_IDENTITY') @@ -368,18 +461,28 @@ export class Connection extends EventEmitter { const { syncState: prevSyncState = initSyncState(), team, device } = context assert(team) - const teamKeys = team.teamKeys() + const teamKeyring = team.teamKeyring() const deviceKeys = device.keys // handle errors here - const decrypt = ({ encryptedGraph, keys }: DecryptFnParams) => - decryptTeamGraph({ encryptedGraph, teamKeys: keys, deviceKeys }) + const decrypt = ({ + encryptedGraph, + keys, + maxTraversalSteps, + }: DecryptFnParams) => + decryptTrustedTeamGraph({ + encryptedGraph, + teamKeys: keys, + deviceKeys, + trustedGraph: team.graph, + maxTraversalSteps, + }) const [newChain, syncState] = receiveMessage( team.graph, prevSyncState, syncMessage, - teamKeys, + teamKeyring, decrypt, this.logger ) @@ -390,8 +493,12 @@ export class Connection extends EventEmitter { return { syncState } } else { // console.log(`${context!.user!.userName}: Sync message received and merging`) + const mergedTeam = team.merge(markTeamGraphAuthenticated(newChain)) this.emit('updated', newChain.head) - return { team: team.merge(newChain), syncState } + return { + team: mergedTeam, + syncState, + } } }), @@ -431,14 +538,11 @@ export class Connection extends EventEmitter { senderPublicKey, recipientSecretKey, }) - this.emit('connectionSecured') - // With the two keys, we derive a shared key - return { sessionKey: deriveSharedKey(seed, theirSeed) } + const sessionKey = deriveSharedKey(seed, theirSeed) + return { sessionKey } } catch (error) { - if (String(error).includes('incorrect key pair')) { - this.logger.error(`failed to decrypt seed using public key ${senderPublicKey}`, error) - return this.#fail(ENCRYPTION_FAILURE) - } else throw error + this.logger.error(`failed to decrypt seed using public key ${senderPublicKey}`, error) + return this.#fail(ENCRYPTION_FAILURE) } }), @@ -454,13 +558,11 @@ export class Connection extends EventEmitter { const decryptedMessage = symmetric.decryptBytes(encryptedMessage, sessionKey) this.emit('message', decryptedMessage) } catch (error) { - if (String(error).includes('wrong secret key')) { - this.logger.error( - `failed to decrypt message using session key ${base58.encode(sessionKey)}`, - error - ) - return this.#fail(ENCRYPTION_FAILURE) - } else throw error + this.logger.error( + `failed to decrypt message using session key ${base58.encode(sessionKey)}`, + error + ) + return this.#fail(ENCRYPTION_FAILURE) } }, @@ -475,6 +577,7 @@ export class Connection extends EventEmitter { assertEvent(event, 'ERROR') const error = event.payload this.logger.error('receiveError', error) + this.emit('remoteError', error) return { error } }), @@ -535,41 +638,46 @@ export class Connection extends EventEmitter { this.logger.debug('GUARD: validating invitation') const { team, theirIdentityClaim } = context assert(isInviteeClaim(theirIdentityClaim!)) - const result = team!.validateInvitation(theirIdentityClaim.proofOfInvitation).isValid + const expectedKind = isInviteeMemberClaim(theirIdentityClaim) ? 'member' : 'device' + const { proofOfInvitation, ...claim } = theirIdentityClaim + const result = team!.validateInvitation( + proofOfInvitation, + expectedKind, + claim, + context.acceptorNonce + ).isValid this.logger.debug('GUARD: is invitation valid?', result) return result }, - joinedTheWrongTeam: ({ context, event }) => { - assertEvent(event, 'ACCEPT_INVITATION') - this.logger.debug('GUARD: validating invitation against team') - const invitationSeed = context.invitationSeed! - const { serializedGraph, teamKeyring } = event.payload - - // Make sure my invitation exists on the graph of the team I'm about to join. This check - // prevents an attack in which a fake team pretends to accept my invitation. - const state = getTeamState(serializedGraph, teamKeyring, this.logger) - const { id } = invitations.generateProof(invitationSeed) - const result = select.hasInvitation(state, id) - this.logger.debug('GUARD: does invitation match team?', result) - return !result + invitationAcceptanceIsInvalid: ({ context }) => { + const validation = context.invitationAcceptanceResult + return !validation?.isValid && validation?.reason === 'ACCEPTANCE_INVALID' }, - admitMemberLinkExistsOnJoin: ({ context, event }) => { - assertEvent(event, 'ACCEPT_INVITATION') - this.logger.debug('checking for ADMIT_MEMBER link on chain') - const { serializedGraph, teamKeyring } = event.payload + joinedTheWrongTeam: ({ context }) => { + this.logger.debug('GUARD: validating invitation against team') + const validation = context.invitationAcceptanceResult + const result = + validation !== undefined && !validation.isValid && validation.reason === 'WRONG_TEAM' + this.logger.debug('GUARD: did invitation match the team?', !result) + return result + }, - // Make sure we have been added as a member on the chain before joining and adding our device - const state = getTeamState(serializedGraph, teamKeyring, this.logger) + invitationAcceptanceSenderIsUnknown: ({ context }) => { + const validation = context.invitationAcceptanceResult const result = - state.members.filter(member => { - return ( - member.userId === - (context.ourIdentityClaim as InviteeMemberIdentityClaim)?.userKeys.name - ) - }).length === 1 - this.logger.debug('GUARD: does ADMIT_MEMBER link exist on chain for our user?', result) + validation !== undefined && + !validation.isValid && + validation.reason === 'SENDER_UNKNOWN' + this.logger.debug('GUARD: is invitation acceptance sender unknown?', result) + return result + }, + + admissionLinkExistsOnJoin: ({ context }) => { + this.logger.debug('checking for exact invitation admission link on chain') + const result = context.invitationAcceptanceResult?.isValid === true + this.logger.debug('GUARD: is the exact admission effective on chain?', result) return result }, @@ -626,6 +734,8 @@ export class Connection extends EventEmitter { this.logger.debug('GUARD: are heads equal?', result) return result }, + + requestIdentityIsValid: ({ event }) => isReadyMessage(event), }, }).createMachine({ context: initialContext as ConnectionContext, @@ -636,7 +746,14 @@ export class Connection extends EventEmitter { entry: 'requestIdentityClaim', initial: 'awaitingIdentityClaim', on: { - REQUEST_IDENTITY: { actions: 'sendIdentityClaim', target: '.awaitingIdentityClaim' }, + REQUEST_IDENTITY: [ + { + guard: 'requestIdentityIsValid', + actions: 'sendIdentityClaim', + target: '.awaitingIdentityClaim', + }, + fail(PROTOCOL_VERSION_UNSUPPORTED), + ], // Remote error (sent by peer) ERROR: { actions: 'receiveError', target: '#disconnected' }, // Local error (detected by us, sent to peer) @@ -677,20 +794,35 @@ export class Connection extends EventEmitter { awaitingInvitationAcceptance: { // Wait for them to validate the invitation we included in our identity claim on: { - ACCEPT_INVITATION: [ - // Make sure the team I'm joining is actually the one that invited me - { guard: 'joinedTheWrongTeam', ...fail(JOINED_WRONG_TEAM) }, - { - guard: 'admitMemberLinkExistsOnJoin', - actions: 'joinTeam', - target: '#checkingIdentity', - }, - fail(ADMIT_MEMBER_LINK_MISSING), - ], + ACCEPT_INVITATION: { + actions: 'receiveInvitationAcceptance', + target: 'checkingInvitationAcceptance', + }, }, ...timeout, }, + checkingInvitationAcceptance: { + always: [ + { + guard: 'invitationAcceptanceIsInvalid', + ...fail(INVITATION_PROOF_INVALID), + }, + // Make sure the team I'm joining is actually the one that invited me + { guard: 'joinedTheWrongTeam', ...fail(JOINED_WRONG_TEAM) }, + { + guard: 'invitationAcceptanceSenderIsUnknown', + ...fail(INVITATION_PROOF_INVALID), + }, + { + guard: 'admissionLinkExistsOnJoin', + actions: 'joinTeam', + target: '#checkingIdentity', + }, + fail(ADMIT_MEMBER_LINK_MISSING), + ], + }, + validatingInvitation: { always: [ // If the proof succeeds, add them to the team and send an acceptance message, @@ -853,6 +985,7 @@ export class Connection extends EventEmitter { // Instantiate the state machine this.#machine = createActor(machine) + let sessionEventsEmitted = false // emit and log all transitions this.#machine.subscribe({ @@ -860,6 +993,22 @@ export class Connection extends EventEmitter { const summary = stateSummary(state.value as string) this.emit('change', summary) this.logger.debug(`⏩ ${JSON.stringify(state.value, null, 2)} `) + + // XState commits assigned context before notifying subscribers. Emitting here guarantees + // listeners can immediately use the public encrypted-channel API. + if (!sessionEventsEmitted && state.context.sessionKey !== undefined) { + sessionEventsEmitted = true + this.emit('connectionSecured') + if (state.context.invitationAcceptanceResult?.isValid) { + assert(state.context.team) + assert(state.context.user) + this.emit('joined', { + team: state.context.team, + user: state.context.user, + teamKeyring: state.context.invitationAcceptanceResult.value.acceptance.teamKeyring, + }) + } + } }, error: error => { this.logger.error('Connection encountered an unhandled error', error) diff --git a/packages/auth/src/connection/errors.ts b/packages/auth/src/connection/errors.ts index 2761abae5..6f5db5e58 100644 --- a/packages/auth/src/connection/errors.ts +++ b/packages/auth/src/connection/errors.ts @@ -7,6 +7,7 @@ export const JOINED_WRONG_TEAM = 'JOINED_WRONG_TEAM' as const export const ADMIT_MEMBER_LINK_MISSING = 'ADMIT_MEMBER_LINK_MISSING' as const export const MEMBER_REMOVED = 'MEMBER_REMOVED' as const export const NEITHER_IS_MEMBER = 'NEITHER_IS_MEMBER' as const +export const PROTOCOL_VERSION_UNSUPPORTED = 'PROTOCOL_VERSION_UNSUPPORTED' as const export const SERVER_REMOVED = 'SERVER_REMOVED' as const export const TIMEOUT = 'TIMEOUT' as const export const UNHANDLED = 'UNHANDLED' as const @@ -36,8 +37,8 @@ export const connectionErrors: Record = { remoteMessage: "This isn't the team the peer was invited to", }, [ADMIT_MEMBER_LINK_MISSING]: { - localMessage: "Invite was accepted but member admission link was missing", - remoteMessage: "Peer received invite acceptance but found no member admission link", + localMessage: 'Invite was accepted but the expected admission link was missing', + remoteMessage: 'Peer received invite acceptance but found no matching admission link', }, [MEMBER_REMOVED]: { localMessage: 'The peer was removed from this team', @@ -46,6 +47,10 @@ export const connectionErrors: Record = { [NEITHER_IS_MEMBER]: { localMessage: 'The peer is also holding an invitation and cannot admit you to the team', }, + [PROTOCOL_VERSION_UNSUPPORTED]: { + localMessage: 'The peer sent an unsupported connection protocol message', + remoteMessage: 'Your connection protocol version is not supported', + }, [SERVER_REMOVED]: { localMessage: 'The server was removed from this team', remoteMessage: 'You (a server) were removed from this team', diff --git a/packages/auth/src/connection/getDeviceUserFromGraph.ts b/packages/auth/src/connection/getDeviceUserFromGraph.ts index 704ab8cde..3ac7205a2 100644 --- a/packages/auth/src/connection/getDeviceUserFromGraph.ts +++ b/packages/auth/src/connection/getDeviceUserFromGraph.ts @@ -1,7 +1,8 @@ import type { Keyring, UserWithSecrets } from '@localfirst/crdx' -import { assert, Logger } from '@localfirst/shared' -import { generateProof } from 'invitation/generateProof.js' +import { assert, type Logger } from '@localfirst/shared' +import { deriveId } from 'invitation/deriveId.js' import { generateStarterKeys } from 'invitation/generateStarterKeys.js' +import type { TeamState } from 'team/index.js' import { KeyType } from 'util/index.js' import { getTeamState } from '../team/getTeamState.js' import * as select from '../team/selectors/index.js' @@ -10,8 +11,8 @@ const { USER } = KeyType /** * If we're joining as a new device for an existing member, we don't have a user object yet, so we - * need to get those from the graph. We use the invitation seed to generate the starter keys for the - * new device. We can use these to unlock a lockbox on the team graph that contains our user keys. + * derive validated team state from the serialized graph, then recover that user from the invitation + * lockbox. */ export const getDeviceUserFromGraph = ({ serializedGraph, @@ -24,10 +25,23 @@ export const getDeviceUserFromGraph = ({ invitationSeed: string logger: Logger }): UserWithSecrets => { - const starterKeys = generateStarterKeys(invitationSeed) - const invitationId = generateProof(invitationSeed).id const state = getTeamState(serializedGraph, teamKeyring, logger) + return getDeviceUserFromState({ state, invitationSeed }) +} +/** + * Recovers an invited device's existing user from already validated team state. The normalized + * invitation seed derives both the invitation ID and starter keys used to open the user-key lockbox. + */ +export const getDeviceUserFromState = ({ + state, + invitationSeed, +}: { + state: TeamState + invitationSeed: string +}): UserWithSecrets => { + const starterKeys = generateStarterKeys(invitationSeed) + const invitationId = deriveId(invitationSeed) const { userId } = select.getInvitation(state, invitationId) assert(userId) // since this is a device invitation the invitation info includes the userId that created it diff --git a/packages/auth/src/connection/invitationAcceptance.ts b/packages/auth/src/connection/invitationAcceptance.ts new file mode 100644 index 000000000..174dfef8d --- /dev/null +++ b/packages/auth/src/connection/invitationAcceptance.ts @@ -0,0 +1,207 @@ +import type { Keyring } from '@localfirst/crdx' +import { asymmetric } from '@localfirst/crypto' +import { assert } from '@localfirst/shared' +import type { DeviceWithSecrets, FirstUseDeviceWithSecrets } from 'device/index.js' +import { + generateStarterKeys, + invitationClaimDigest, + type InvitationClaim, + type InvitationV2, + type ProofOfInvitationV2, +} from 'invitation/index.js' +import type { TeamState } from 'team/index.js' +import * as select from 'team/selectors/index.js' +import type { AcceptInvitationPayload, InvitationAcceptance } from './message.js' + +export const INVITATION_ACCEPTANCE_DOMAIN = 'localfirst-auth/invitation-acceptance' as const +export const INVITATION_ACCEPTANCE_VERSION = 2 as const + +type CreateInvitationAcceptanceOptions = { + invitation: InvitationV2 + proof: ProofOfInvitationV2 + claim: InvitationClaim + senderDevice: DeviceWithSecrets | FirstUseDeviceWithSecrets + serializedGraph: Uint8Array + teamKeyring: Keyring +} + +/** + * Creates the authenticated-encrypted acceptance sent after admitting an invitee. + * + * The encrypted body binds the exact proof and claim digest, both handshake nonces, accepting + * device, serialized graph, and retained team keyring. It is decryptable only with starter keys + * derived from the invitation seed. + */ +export const createInvitationAcceptance = ({ + invitation, + proof, + claim, + senderDevice, + serializedGraph, + teamKeyring, +}: CreateInvitationAcceptanceOptions): AcceptInvitationPayload => { + assert(invitation.id === proof.id, 'Invitation and proof IDs do not match') + + const acceptance: InvitationAcceptance = { + domain: INVITATION_ACCEPTANCE_DOMAIN, + version: INVITATION_ACCEPTANCE_VERSION, + invitationId: proof.id, + invitationKind: claim.invitationKind, + claimDigest: invitationClaimDigest(proof, claim), + acceptorNonce: proof.acceptorNonce, + inviteeNonce: proof.inviteeNonce, + acceptorDeviceId: senderDevice.deviceId, + serializedGraph, + teamKeyring, + } + + return { + version: INVITATION_ACCEPTANCE_VERSION, + senderDeviceId: senderDevice.deviceId, + senderPublicKey: senderDevice.keys.encryption.publicKey, + encryptedAcceptance: asymmetric.encryptBytes({ + secret: acceptance, + recipientPublicKey: invitation.encryptionPublicKey, + senderSecretKey: senderDevice.keys.encryption.secretKey, + }), + } +} + +type OpenInvitationAcceptanceOptions = { + payload: AcceptInvitationPayload + invitationSeed: string + proof: ProofOfInvitationV2 + claim: InvitationClaim +} + +/** + * Opens and strictly validates an invitation acceptance against the invitee's seed, exact proof, + * and identity claim. Rejects unknown versions, extra or missing fields, decryption failures, and + * any transcript or sender-metadata mismatch. + */ +export const openInvitationAcceptance = ({ + payload, + invitationSeed, + proof, + claim, +}: OpenInvitationAcceptanceOptions): InvitationAcceptance => { + assertAcceptInvitationPayload(payload) + + const starterKeys = generateStarterKeys(invitationSeed) + const decrypted = asymmetric.decryptBytes({ + cipher: payload.encryptedAcceptance, + recipientSecretKey: starterKeys.encryption.secretKey, + senderPublicKey: payload.senderPublicKey, + }) + assertInvitationAcceptance(decrypted) + + assert(decrypted.invitationId === proof.id, 'Invitation acceptance ID does not match proof') + assert( + decrypted.invitationKind === claim.invitationKind, + 'Invitation acceptance kind does not match claim' + ) + assert( + decrypted.claimDigest === invitationClaimDigest(proof, claim), + 'Invitation acceptance claim digest does not match' + ) + assert( + decrypted.acceptorNonce === proof.acceptorNonce, + 'Invitation acceptance acceptor nonce does not match' + ) + assert( + decrypted.inviteeNonce === proof.inviteeNonce, + 'Invitation acceptance invitee nonce does not match' + ) + assert( + decrypted.acceptorDeviceId === payload.senderDeviceId, + 'Invitation acceptance sender ID does not match' + ) + + return decrypted +} + +/** + * Returns whether the acceptance sender is a unique active device in the derived team state and + * authenticated with that device's current encryption key. + */ +export const invitationAcceptanceSenderIsActive = ( + state: TeamState, + payload: AcceptInvitationPayload, + acceptance: InvitationAcceptance +): boolean => { + if (acceptance.acceptorDeviceId !== payload.senderDeviceId) { + return false + } + + try { + const sender = select.device(state, payload.senderDeviceId) + return sender.keys.encryption === payload.senderPublicKey + } catch { + return false + } +} + +const ACCEPT_INVITATION_PAYLOAD_KEYS = [ + 'encryptedAcceptance', + 'senderDeviceId', + 'senderPublicKey', + 'version', +] as const + +const INVITATION_ACCEPTANCE_KEYS = [ + 'acceptorDeviceId', + 'acceptorNonce', + 'claimDigest', + 'domain', + 'invitationId', + 'invitationKind', + 'inviteeNonce', + 'serializedGraph', + 'teamKeyring', + 'version', +] as const + +function assertAcceptInvitationPayload(value: unknown): asserts value is AcceptInvitationPayload { + assertExactKeys(value, ACCEPT_INVITATION_PAYLOAD_KEYS) + assert(value.version === INVITATION_ACCEPTANCE_VERSION, 'Unsupported invitation acceptance') + assert(typeof value.senderDeviceId === 'string', 'Invalid invitation acceptance sender ID') + assert(typeof value.senderPublicKey === 'string', 'Invalid invitation acceptance sender key') + assert(value.encryptedAcceptance instanceof Uint8Array, 'Invalid encrypted invitation acceptance') +} + +function assertInvitationAcceptance(value: unknown): asserts value is InvitationAcceptance { + assertExactKeys(value, INVITATION_ACCEPTANCE_KEYS) + assert(value.domain === INVITATION_ACCEPTANCE_DOMAIN, 'Invalid invitation acceptance domain') + assert(value.version === INVITATION_ACCEPTANCE_VERSION, 'Unsupported invitation acceptance') + assert(typeof value.invitationId === 'string', 'Invalid invitation acceptance ID') + assert( + value.invitationKind === 'member' || value.invitationKind === 'device', + 'Invalid invitation acceptance kind' + ) + assert(typeof value.claimDigest === 'string', 'Invalid invitation acceptance claim digest') + assert(typeof value.acceptorNonce === 'string', 'Invalid invitation acceptance acceptor nonce') + assert(typeof value.inviteeNonce === 'string', 'Invalid invitation acceptance invitee nonce') + assert( + typeof value.acceptorDeviceId === 'string', + 'Invalid invitation acceptance acceptor device ID' + ) + assert(value.serializedGraph instanceof Uint8Array, 'Invalid invitation acceptance graph') + assert(isRecord(value.teamKeyring), 'Invalid invitation acceptance keyring') +} + +function assertExactKeys( + value: unknown, + expectedKeys: Keys +): asserts value is Record { + assert(isRecord(value), 'Invitation acceptance must be an object') + const actualKeys = Object.keys(value).sort() + const expected = [...expectedKeys].sort() + assert( + actualKeys.length === expected.length && + actualKeys.every((key, index) => key === expected[index]), + 'Invitation acceptance has unexpected fields' + ) +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) diff --git a/packages/auth/src/connection/message.ts b/packages/auth/src/connection/message.ts index d9ab62ebc..fb4b7fe67 100644 --- a/packages/auth/src/connection/message.ts +++ b/packages/auth/src/connection/message.ts @@ -1,9 +1,23 @@ +import { base58 } from '@localfirst/crypto' import type { Base58, Hash, Keyring, SyncMessage as SyncPayload } from '@localfirst/crdx' import type { Challenge, IdentityClaim } from 'connection/types.js' +import type { InvitationKind } from 'invitation/index.js' import type { ErrorMessage, LocalErrorMessage } from './errors.js' export type ReadyMessage = { type: 'REQUEST_IDENTITY' + payload: { + acceptorNonce: Base58 + } +} + +/** Runtime validation for the first protocol message, which is received from untyped wire data. */ +export const isReadyMessage = (message: unknown): message is ReadyMessage => { + if (!isRecord(message) || message.type !== 'REQUEST_IDENTITY' || !isRecord(message.payload)) { + return false + } + const { acceptorNonce } = message.payload + return typeof acceptorNonce === 'string' && base58.detect(acceptorNonce) } export type DisconnectMessage = { @@ -53,12 +67,29 @@ export type RejectIdentityMessage = { } } +export type InvitationAcceptance = { + domain: 'localfirst-auth/invitation-acceptance' + version: 2 + invitationId: Base58 + invitationKind: InvitationKind + claimDigest: Base58 + acceptorNonce: Base58 + inviteeNonce: Base58 + acceptorDeviceId: string + serializedGraph: Uint8Array + teamKeyring: Keyring +} + +export type AcceptInvitationPayload = { + version: 2 + senderDeviceId: string + senderPublicKey: Base58 + encryptedAcceptance: Uint8Array +} + export type AcceptInvitationMessage = { type: 'ACCEPT_INVITATION' - payload: { - serializedGraph: Uint8Array - teamKeyring: Keyring - } + payload: AcceptInvitationPayload } // Synchronization @@ -104,3 +135,6 @@ export type ConnectionMessage = | SeedMessage | SyncMessage | RequestResendMessage + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) diff --git a/packages/auth/src/connection/test/authentication.test.ts b/packages/auth/src/connection/test/authentication.test.ts index d796a5535..37e2f1e8b 100644 --- a/packages/auth/src/connection/test/authentication.test.ts +++ b/packages/auth/src/connection/test/authentication.test.ts @@ -5,6 +5,7 @@ import * as teams from 'team/index.js' import { TestChannel, all, + asFirstUseDevice, anyDisconnected, anyUpdated, connect, @@ -198,6 +199,7 @@ describe('connection', () => { charlie.connectionContext = { ...charlie.connectionContext, invitationSeed: charlieSeed, + expectedTeamId: alice.team.id, } // 👩🏾 Alice invites 👴 Dwight @@ -205,6 +207,7 @@ describe('connection', () => { dwight.connectionContext = { ...dwight.connectionContext, invitationSeed: dwightSeed, + expectedTeamId: alice.team.id, } expect(await connect(charlie, dwight)).toEqual(false) @@ -212,6 +215,8 @@ describe('connection', () => { it('lets a member use an invitation to add a device', async () => { const { alice, bob } = setup('alice', 'bob') + alice.team.addRole('member') + bob.team.addRole('member') await connect(alice, bob) @@ -223,8 +228,9 @@ describe('connection', () => { // 💻<->📱📧 Bob's phone and laptop connect and the phone joins const phoneContext: InviteeDeviceContext = { userName: bob.userName, - device: bob.phone!, - invitationSeed: seed, + device: asFirstUseDevice(bob.phone!), + invitationSeed: `${seed.slice(0, 4)}+${seed.slice(4, 8)}-${seed.slice(8, 12)}_${seed.slice(12)}`, + expectedTeamId: bob.team.id, } const join = joinTestChannel(new TestChannel()) @@ -244,11 +250,13 @@ describe('connection', () => { it('lets a member invite a device, remove it, and then add it back', async () => { const { alice, bob } = setup('alice', 'bob') + alice.team.addRole('member') + bob.team.addRole('member') await connect(alice, bob) // Bob invites and admits his phone - const phone = bob.phone! + const phone = asFirstUseDevice(bob.phone!) { const { seed } = bob.team.inviteDevice() @@ -256,6 +264,7 @@ describe('connection', () => { userName: bob.userName, device: phone, invitationSeed: seed, + expectedTeamId: bob.team.id, } const join = joinTestChannel(new TestChannel()) const laptopConnection = join(bob.connectionContext).start() @@ -284,6 +293,7 @@ describe('connection', () => { userName: bob.userName, device: phone, invitationSeed: seed, + expectedTeamId: bob.team.id, } const join = joinTestChannel(new TestChannel()) const laptopConnection = join(bob.connectionContext).start() @@ -299,6 +309,8 @@ describe('connection', () => { it('lets a different member admit an invited device', async () => { const { alice, bob } = setup('alice', 'bob') + alice.team.addRole('member') + bob.team.addRole('member') await connect(alice, bob) @@ -310,8 +322,9 @@ describe('connection', () => { // 💻<->📱📧 Bob's phone and Alice's laptop connect and the phone joins const phoneContext: InviteeDeviceContext = { userName: bob.userName, - device: bob.phone!, + device: asFirstUseDevice(bob.phone!), invitationSeed: seed, + expectedTeamId: bob.team.id, } const join = joinTestChannel(new TestChannel()) const aliceConnection = join(alice.connectionContext).start() @@ -339,6 +352,7 @@ describe('connection', () => { bob.connectionContext = { ...bob.connectionContext, invitationSeed: 'password', + expectedTeamId: alice.team.id, } void connect(bob, alice) @@ -357,6 +371,7 @@ describe('connection', () => { bob.connectionContext = { ...bob.connectionContext, invitationSeed: 'password', + expectedTeamId: alice.team.id, } { @@ -369,6 +384,7 @@ describe('connection', () => { bob.connectionContext = { ...bob.connectionContext, invitationSeed: 'passw0rd', + expectedTeamId: alice.team.id, } { diff --git a/packages/auth/src/connection/test/concurrentAdmitMember.test.ts b/packages/auth/src/connection/test/concurrentAdmitMember.test.ts new file mode 100644 index 000000000..c433f324d --- /dev/null +++ b/packages/auth/src/connection/test/concurrentAdmitMember.test.ts @@ -0,0 +1,138 @@ +import { eventPromise, pause } from '@localfirst/shared' +import type { Connection } from 'connection/index.js' +import type { InviteeMemberContext, MemberContext } from 'connection/types.js' +import type { TeamGraph } from 'team/types.js' +import { + all, + connect, + disconnect, + joinTestChannel, + setup, + TestChannel, +} from 'util/testing/index.js' +import { afterEach, describe, expect, it, vi } from 'vitest' + +type MergeAttempt = { + peer: 'peer 1' | 'peer 2' + result: 'merged' | 'rejected' + error?: string +} + +describe('concurrent ADMIT_MEMBER', () => { + const activeConnections: Connection[] = [] + + afterEach(() => { + for (const connection of activeConnections.splice(0)) { + connection.stop(false) + } + vi.restoreAllMocks() + }) + + it('rejects the merge after the same member is admitted on two disconnected branches', async () => { + const { + alice: peer1, + bob: peer2, + charlie: peer3, + } = setup('alice', 'bob', { user: 'charlie', member: false }) + + // Peer 1 creates the invitation and shares its unused state with peer 2 before they disconnect. + const memberInvitation = peer1.team.inviteMember() + await connect(peer1, peer2) + await disconnect(peer1, peer2) + + const peer3InviteeContext: InviteeMemberContext = { + user: peer3.user, + device: peer3.device, + invitationSeed: memberInvitation.seed, + expectedTeamId: memberInvitation.teamId, + } + + // Peer 3 presents the same identity and invitation independently to each disconnected branch. + const peer1Admission = await admitMember( + { user: peer1.user, device: peer1.device, team: peer1.team }, + peer3InviteeContext + ) + const peer2Admission = await admitMember( + { user: peer2.user, device: peer2.device, team: peer2.team }, + peer3InviteeContext + ) + + expect(peer1Admission.team.has(peer3.userId)).toBe(true) + expect(peer2Admission.team.has(peer3.userId)).toBe(true) + expect(peer1Admission.team.members(peer3.userId).keys).toEqual( + peer2Admission.team.members(peer3.userId).keys + ) + expect(peer1.team.graph.head).not.toEqual(peer2.team.graph.head) + + peer1.connectionContext = { user: peer1.user, device: peer1.device, team: peer1.team } + peer2.connectionContext = { user: peer2.user, device: peer2.device, team: peer2.team } + const mergeAttempts: MergeAttempt[] = [] + recordMergeAttempt('peer 1', peer1.team.merge.bind(peer1.team), mergeAttempts, peer1.team) + recordMergeAttempt('peer 2', peer2.team.merge.bind(peer2.team), mergeAttempts, peer2.team) + + // Peer 3 is now disconnected from both branches. The original peers cannot reconcile their + // independently valid ADMIT_MEMBER links because both introduce the same active member ID. + const connected = await connect(peer1, peer2) + await pause(50) + const mergeResult = { + connected, + mergeAttempts, + peer1Head: peer1.team.graph.head, + peer2Head: peer2.team.graph.head, + } + console.info('Concurrent ADMIT_MEMBER merge result:', mergeResult) + + expect(connected).toBe(false) + expect(mergeAttempts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + result: 'rejected', + error: expect.stringMatching(/active member id .* is already in use/i), + }), + ]) + ) + expect(peer1.team.graph.head).not.toEqual(peer2.team.graph.head) + }) + + const admitMember = async ( + memberContext: MemberContext, + inviteeContext: InviteeMemberContext + ) => { + const join = joinTestChannel(new TestChannel()) + const memberConnection = join(memberContext) + const inviteeConnection = join(inviteeContext) + activeConnections.push(memberConnection, inviteeConnection) + const joined = eventPromise(inviteeConnection, 'joined') + const connected = all([memberConnection, inviteeConnection], 'connected') + + memberConnection.start() + inviteeConnection.start() + + const [admission] = await Promise.all([joined, connected]) + memberConnection.stop(false) + inviteeConnection.stop(false) + return admission + } + + const recordMergeAttempt = ( + peer: MergeAttempt['peer'], + originalMerge: (graph: TeamGraph) => unknown, + attempts: MergeAttempt[], + team: { merge: (graph: TeamGraph) => unknown } + ) => { + vi.spyOn(team, 'merge').mockImplementation(graph => { + try { + const result = originalMerge(graph) + attempts.push({ peer, result: 'merged' }) + return result + } catch (error) { + attempts.push({ + peer, + result: 'rejected', + error: error instanceof Error ? error.message : String(error), + }) + throw error + } + }) + } +}) diff --git a/packages/auth/src/connection/test/concurrentDeviceAdmission.test.ts b/packages/auth/src/connection/test/concurrentDeviceAdmission.test.ts new file mode 100644 index 000000000..a46e699ed --- /dev/null +++ b/packages/auth/src/connection/test/concurrentDeviceAdmission.test.ts @@ -0,0 +1,143 @@ +import { eventPromise, pause } from '@localfirst/shared' +import type { Connection } from 'connection/index.js' +import type { InviteeDeviceContext, MemberContext } from 'connection/types.js' +import type { TeamGraph } from 'team/types.js' +import { + all, + asFirstUseDevice, + connect, + connectWithInvitation, + disconnect, + joinTestChannel, + setup, + TestChannel, +} from 'util/testing/index.js' +import { afterEach, describe, expect, it, vi } from 'vitest' + +type MergeAttempt = { + peer: 'peer 1' | 'peer 2' + result: 'merged' | 'rejected' + error?: string +} + +describe('concurrent device admission', () => { + const activeConnections: Connection[] = [] + + afterEach(() => { + for (const connection of activeConnections.splice(0)) { + connection.stop(false) + } + vi.restoreAllMocks() + }) + + it('rejects the merge after the same device invitation is admitted on two disconnected branches', async () => { + const { alice: peer1, bob: peer2 } = setup('alice', { + user: 'bob', + member: false, + }) + + // Peer 1 creates both invitations before peer 2 joins, so the admitted member learns about the + // still-unused device invitation as part of the team graph. + const memberInvitation = peer1.team.inviteMember() + const deviceInvitation = peer1.team.inviteDevice() + + await connectWithInvitation(peer1, peer2, memberInvitation.seed) + await disconnect(peer1, peer2) + + const peer3Device = asFirstUseDevice(peer1.phone!) + const peer3InviteeContext: InviteeDeviceContext = { + userName: peer1.userName, + device: peer3Device, + invitationSeed: deviceInvitation.seed, + expectedTeamId: deviceInvitation.teamId, + } + + // Peer 3 completes the same device admission independently against each disconnected branch. + const peer1Admission = await admitDevice( + { user: peer1.user, device: peer1.device, team: peer1.team }, + peer3InviteeContext + ) + const peer2Admission = await admitDevice( + { user: peer2.user, device: peer2.device, team: peer2.team }, + peer3InviteeContext + ) + + expect(peer1Admission.team.hasDevice(peer3Device.deviceId)).toBe(true) + expect(peer2Admission.team.hasDevice(peer3Device.deviceId)).toBe(true) + expect(peer1Admission.team.device(peer3Device.deviceId).keys).toEqual( + peer2Admission.team.device(peer3Device.deviceId).keys + ) + expect(peer1.team.graph.head).not.toEqual(peer2.team.graph.head) + + peer1.connectionContext = { user: peer1.user, device: peer1.device, team: peer1.team } + peer2.connectionContext = { user: peer2.user, device: peer2.device, team: peer2.team } + const mergeAttempts: MergeAttempt[] = [] + recordMergeAttempt('peer 1', peer1.team.merge.bind(peer1.team), mergeAttempts, peer1.team) + recordMergeAttempt('peer 2', peer2.team.merge.bind(peer2.team), mergeAttempts, peer2.team) + + // Peer 3 is disconnected from both branches. Reconnecting peers 1 and 2 now attempts to merge + // the two independently valid ADMIT_DEVICE links. + const connected = await connect(peer1, peer2) + await pause(50) + const mergeResult = { + connected, + mergeAttempts, + peer1Head: peer1.team.graph.head, + peer2Head: peer2.team.graph.head, + } + console.info('Concurrent device admission merge result:', mergeResult) + + expect(connected).toBe(false) + expect(mergeAttempts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + result: 'rejected', + error: expect.stringMatching(/active device id .* is already in use/i), + }), + ]) + ) + expect(peer1.team.graph.head).not.toEqual(peer2.team.graph.head) + }) + + const admitDevice = async ( + memberContext: MemberContext, + inviteeContext: InviteeDeviceContext + ) => { + const join = joinTestChannel(new TestChannel()) + const memberConnection = join(memberContext) + const inviteeConnection = join(inviteeContext) + activeConnections.push(memberConnection, inviteeConnection) + const joined = eventPromise(inviteeConnection, 'joined') + const connected = all([memberConnection, inviteeConnection], 'connected') + + memberConnection.start() + inviteeConnection.start() + + const [admission] = await Promise.all([joined, connected]) + memberConnection.stop(false) + inviteeConnection.stop(false) + return admission + } + + const recordMergeAttempt = ( + peer: MergeAttempt['peer'], + originalMerge: (graph: TeamGraph) => unknown, + attempts: MergeAttempt[], + team: { merge: (graph: TeamGraph) => unknown } + ) => { + vi.spyOn(team, 'merge').mockImplementation(graph => { + try { + const result = originalMerge(graph) + attempts.push({ peer, result: 'merged' }) + return result + } catch (error) { + attempts.push({ + peer, + result: 'rejected', + error: error instanceof Error ? error.message : String(error), + }) + throw error + } + }) + } +}) diff --git a/packages/auth/src/connection/test/concurrentMemberAdmission.test.ts b/packages/auth/src/connection/test/concurrentMemberAdmission.test.ts new file mode 100644 index 000000000..4c2d6ebd8 --- /dev/null +++ b/packages/auth/src/connection/test/concurrentMemberAdmission.test.ts @@ -0,0 +1,134 @@ +import { createKeyset, type KeysetWithSecrets } from '@localfirst/crdx' +import { redactDevice } from 'device/index.js' +import * as teams from 'team/index.js' +import { + connect, + disconnect, + memberInvitationProof, + setup, + type UserStuff, +} from 'util/testing/index.js' +import { describe, expect, it } from 'vitest' + +const MEMBER = 'MEMBER' + +type Admitter = 'admin' | 'self' | 'peer' + +type AdmissionRace = { + admin: UserStuff + peer: UserStuff + self: UserStuff + roleDecryptionKeys: KeysetWithSecrets +} + +describe('concurrent member admission', () => { + it('merges an admin admission with a self admission from a disparate peer', async () => { + await expectAdmissionsToMerge('admin', 'self') + }) + + it('merges a self admission with a peer admission from a disparate peer', async () => { + await expectAdmissionsToMerge('self', 'peer') + }) + + it('merges a peer admission with an admin admission from a disparate peer', async () => { + await expectAdmissionsToMerge('peer', 'admin') + }) + + const expectAdmissionsToMerge = async (leftAdmitter: Admitter, rightAdmitter: Admitter) => { + const race = createAdmissionRace() + const left = admitMember(race, leftAdmitter) + const right = admitMember(race, rightAdmitter) + const disparateHeads = { + [leftAdmitter]: left.team.graph.head, + [rightAdmitter]: right.team.graph.head, + } + + expect(left.team.graph.head).not.toEqual(right.team.graph.head) + expectMemberRoleOnce(left, race.self.userId) + expectMemberRoleOnce(right, race.self.userId) + + const connected = await connect(left, right) + const mergeResult = { + connected, + admitters: [leftAdmitter, rightAdmitter], + disparateHeads, + mergedHead: left.team.graph.head, + } + console.info('Concurrent member admission merge result:', mergeResult) + + expect(connected).toBe(true) + expect(left.team.graph.head).toEqual(right.team.graph.head) + expectMemberRoleOnce(left, race.self.userId) + expectMemberRoleOnce(right, race.self.userId) + + await disconnect(left, right) + } + + const createAdmissionRace = (): AdmissionRace => { + const { + alice: admin, + bob: peer, + charlie: self, + } = setup('alice', { user: 'bob', admin: false }, { user: 'charlie', member: false }) + + // Establish the shared graph up to identity admission. Charlie exists as a member identity but + // does not become a full community member until one of the peers grants the MEMBER role. + admin.team.addRole(MEMBER) + const roleDecryptionKeys = createKeyset( + { type: 'MEMBER_ADMISSION_TEST', name: MEMBER }, + 'member-admission-test' + ) + admin.team.createLockbox(MEMBER, roleDecryptionKeys) + const invitation = admin.team.inviteMember() + const proof = memberInvitationProof(invitation.seed, self.user, self.device) + admin.team.admitMember(proof, self.user.keys, self.userName, redactDevice(self.device)) + + const teamKeyring = admin.team.teamKeyring() + self.team = teams.load(admin.team.save(), self.localContext, teamKeyring) + self.team.join(teamKeyring) + const baselineGraph = self.team.save() + admin.team = teams.load(baselineGraph, admin.localContext, teamKeyring) + peer.team = teams.load(baselineGraph, peer.localContext, teamKeyring) + self.team = teams.load(baselineGraph, self.localContext, teamKeyring) + for (const participant of [admin, peer, self]) { + participant.connectionContext = { + user: participant.user, + device: participant.device, + team: participant.team, + } + } + + expect(admin.team.memberIsAdmin(admin.userId)).toBe(true) + expect(peer.team.memberIsAdmin(peer.userId)).toBe(false) + expect(admin.team.has(self.userId)).toBe(true) + expect(admin.team.memberHasRole(self.userId, MEMBER)).toBe(false) + + return { admin, peer, self, roleDecryptionKeys } + } + + const admitMember = (race: AdmissionRace, admitter: Admitter): UserStuff => { + const { admin, peer, self, roleDecryptionKeys } = race + switch (admitter) { + case 'admin': { + admin.team.addMemberRole(self.userId, MEMBER) + return admin + } + + case 'self': { + self.team.addMemberRoleToSelf(MEMBER, roleDecryptionKeys) + return self + } + + case 'peer': { + peer.team.addMemberRole(self.userId, MEMBER, roleDecryptionKeys) + return peer + } + } + } + + const expectMemberRoleOnce = (participant: UserStuff, memberId: string) => { + expect(participant.team.members(memberId).roles.filter(role => role === MEMBER)).toEqual([ + MEMBER, + ]) + } +}) diff --git a/packages/auth/src/connection/test/encryption.test.ts b/packages/auth/src/connection/test/encryption.test.ts index 5af8437e1..767c79e8c 100644 --- a/packages/auth/src/connection/test/encryption.test.ts +++ b/packages/auth/src/connection/test/encryption.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { connect, setup } from 'util/testing/index.js' +import { connect, joinTestChannel, setup, TestChannel } from 'util/testing/index.js' import { eventPromise } from '@localfirst/shared' import { randomKeyBytes } from '@localfirst/crypto' @@ -21,6 +21,34 @@ describe('connection', () => { const d = await messagePromise expect(d).toEqual('hello') }) + + it('commits the session key before emitting connectionSecured', async () => { + const { alice, bob } = setup('alice', 'bob') + const join = joinTestChannel(new TestChannel()) + const aliceConnection = join(alice.connectionContext) + const bobConnection = join(bob.connectionContext) + let sessionKeyAtEvent: Uint8Array | undefined + let sendError: unknown + aliceConnection.on('connectionSecured', () => { + sessionKeyAtEvent = aliceConnection._sessionKey + try { + aliceConnection.send('sent immediately') + } catch (error) { + sendError = error + } + }) + + const connected = Promise.all([ + eventPromise(aliceConnection, 'connected'), + eventPromise(bobConnection, 'connected'), + ]) + aliceConnection.start() + bobConnection.start() + await connected + + expect(sessionKeyAtEvent).toBeInstanceOf(Uint8Array) + expect(sendError).toBeUndefined() + }) }) it('fails if one person has the wrong session key', async () => { diff --git a/packages/auth/src/connection/test/invitationAcceptance.test.ts b/packages/auth/src/connection/test/invitationAcceptance.test.ts new file mode 100644 index 000000000..4f07d0c63 --- /dev/null +++ b/packages/auth/src/connection/test/invitationAcceptance.test.ts @@ -0,0 +1,183 @@ +import { randomKey } from '@localfirst/crypto' +import { redactKeys } from '@localfirst/crdx' +import { redactDevice } from 'device/index.js' +import { generateProof, type InvitationClaim, type InvitationV2 } from 'invitation/index.js' +import { setup } from 'util/testing/index.js' +import { describe, expect, it } from 'vitest' +import { + createInvitationAcceptance, + invitationAcceptanceSenderIsActive, + openInvitationAcceptance, +} from '../invitationAcceptance.js' +import type { AcceptInvitationPayload } from '../message.js' + +describe('encrypted invitation acceptance', () => { + it('keeps the graph and team keyring out of the outer wire payload', () => { + const fixture = memberAcceptanceFixture() + + expect(Object.keys(fixture.payload).sort()).toEqual([ + 'encryptedAcceptance', + 'senderDeviceId', + 'senderPublicKey', + 'version', + ]) + expect(fixture.payload).not.toHaveProperty('serializedGraph') + expect(fixture.payload).not.toHaveProperty('teamKeyring') + expect(fixture.payload.encryptedAcceptance).toBeInstanceOf(Uint8Array) + }) + + it('opens only with the invitation seed and the exact proof transcript', () => { + const fixture = memberAcceptanceFixture() + + const acceptance = openInvitationAcceptance(fixture) + expect(acceptance.serializedGraph).toEqual(fixture.serializedGraph) + expect(acceptance.teamKeyring).toEqual(fixture.teamKeyring) + + expect(() => + openInvitationAcceptance({ ...fixture, invitationSeed: 'not the invitation seed' }) + ).toThrow() + expect(() => + openInvitationAcceptance({ + ...fixture, + proof: { ...fixture.proof, acceptorNonce: randomKey() }, + }) + ).toThrow() + expect(() => + openInvitationAcceptance({ + ...fixture, + proof: { ...fixture.proof, inviteeNonce: randomKey() }, + }) + ).toThrow() + expect(() => + openInvitationAcceptance({ + ...fixture, + claim: { ...fixture.claim, userName: 'mallory' }, + }) + ).toThrow() + }) + + it('rejects ciphertext tampering and non-exact outer schemas', () => { + const fixture = memberAcceptanceFixture() + const encryptedAcceptance = fixture.payload.encryptedAcceptance.slice() + encryptedAcceptance[Math.floor(encryptedAcceptance.length / 2)] ^= 1 + + expect(() => + openInvitationAcceptance({ + ...fixture, + payload: { ...fixture.payload, encryptedAcceptance }, + }) + ).toThrow() + expect(() => + openInvitationAcceptance({ + ...fixture, + payload: { ...fixture.payload, extra: true } as AcceptInvitationPayload, + }) + ).toThrow() + expect(() => + openInvitationAcceptance({ + ...fixture, + payload: { ...fixture.payload, version: 3 } as unknown as AcceptInvitationPayload, + }) + ).toThrow() + const { senderPublicKey: _, ...missingSenderKey } = fixture.payload + expect(() => + openInvitationAcceptance({ + ...fixture, + payload: missingSenderKey as AcceptInvitationPayload, + }) + ).toThrow() + }) + + it('rejects replay against a fresh proof transcript', () => { + const fixture = memberAcceptanceFixture() + const freshProof = generateProof({ + seed: fixture.invitationSeed, + claim: fixture.claim, + acceptorNonce: randomKey(), + inviteeNonce: randomKey(), + }) + + expect(() => + openInvitationAcceptance({ + ...fixture, + proof: freshProof, + }) + ).toThrow() + }) + + it('requires the authenticated graph to register the acceptance sender and key', () => { + const fixture = memberAcceptanceFixture() + const acceptance = openInvitationAcceptance(fixture) + + expect( + invitationAcceptanceSenderIsActive(fixture.alice.team.state, fixture.payload, acceptance) + ).toBe(true) + + const unregisteredPayload = createInvitationAcceptance({ + invitation: fixture.invitation, + proof: fixture.proof, + claim: fixture.claim, + senderDevice: fixture.eve.device, + serializedGraph: fixture.serializedGraph, + teamKeyring: fixture.teamKeyring, + }) + const unregisteredAcceptance = openInvitationAcceptance({ + ...fixture, + payload: unregisteredPayload, + }) + expect( + invitationAcceptanceSenderIsActive( + fixture.alice.team.state, + unregisteredPayload, + unregisteredAcceptance + ) + ).toBe(false) + }) +}) + +const memberAcceptanceFixture = () => { + const { alice, bob, eve } = setup( + 'alice', + { user: 'bob', member: false }, + { user: 'eve', member: false } + ) + const { seed: invitationSeed } = alice.team.inviteMember() + const claim: InvitationClaim = { + invitationKind: 'member', + userName: bob.userName, + userKeys: redactKeys(bob.user.keys), + device: redactDevice(bob.device), + } + const proof = generateProof({ + seed: invitationSeed, + claim, + acceptorNonce: randomKey(), + inviteeNonce: randomKey(), + }) + const invitation = alice.team.getInvitation(proof.id) + if (invitation.version !== 2) { + throw new Error('Expected a version 2 invitation') + } + const serializedGraph = alice.team.save() + const teamKeyring = alice.team.teamKeyring() + const payload = createInvitationAcceptance({ + invitation, + proof, + claim, + senderDevice: alice.device, + serializedGraph, + teamKeyring, + }) + + return { + alice, + eve, + invitation: invitation as InvitationV2, + invitationSeed, + proof, + claim, + serializedGraph, + teamKeyring, + payload, + } +} diff --git a/packages/auth/src/connection/test/invitationAdmission.test.ts b/packages/auth/src/connection/test/invitationAdmission.test.ts new file mode 100644 index 000000000..ed9b9cf81 --- /dev/null +++ b/packages/auth/src/connection/test/invitationAdmission.test.ts @@ -0,0 +1,278 @@ +import { createUser } from '@localfirst/crdx' +import { eventPromise } from '@localfirst/shared' +import { createDevice } from 'device/index.js' +import { pack, unpack } from 'msgpackr' +import * as teams from 'team/index.js' +import { all, asFirstUseDevice, joinTestChannel, setup, TestChannel } from 'util/testing/index.js' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Connection } from '../Connection.js' +import { ADMIT_MEMBER_LINK_MISSING, ENCRYPTION_FAILURE } from '../errors.js' +import type { ConnectionMessage } from '../message.js' +import type { NumberedMessage } from '../MessageQueue.js' +import type { InviteeDeviceContext, InviteeMemberContext, MemberContext } from '../types.js' + +describe('connection invitation admission', () => { + const activeConnections: Connection[] = [] + + afterEach(() => { + for (const connection of activeConnections.splice(0)) { + connection.stop(false) + } + vi.restoreAllMocks() + }) + + it('requires an independently supplied team ID for invitees', () => { + const { alice, bob } = setup('alice', { user: 'bob', member: false }) + const { seed } = alice.team.inviteMember() + const unboundContext = { + user: bob.user, + device: bob.device, + invitationSeed: seed, + } as InviteeMemberContext + + expect(() => new Connection({ context: unboundContext, sendMessage: vi.fn() })).toThrow( + /expected team ID/ + ) + }) + + it('accepts the exact invited member when another member has the same username', async () => { + const { alice, bob } = setup('alice', 'bob') + alice.team.addRole('member') + + const invitedUser = createUser(bob.userName) + const invitedDevice = createDevice({ + userId: invitedUser.userId, + deviceName: 'new-bob-laptop', + }) + const { seed } = alice.team.inviteMember() + const inviteeContext: InviteeMemberContext = { + user: invitedUser, + device: invitedDevice, + invitationSeed: seed, + expectedTeamId: alice.team.id, + } + const connections = createConnectionPair(memberContext(alice), inviteeContext) + const joined = eventPromise(connections.invitee, 'joined') + + await connect(connections) + const admission = await joined + + expect(admission.user.userId).toBe(invitedUser.userId) + expect(admission.team.has(invitedUser.userId)).toBe(true) + expect(admission.team.hasDevice(invitedDevice.deviceId)).toBe(true) + expect( + admission.team.members().filter(member => member.userName === bob.userName) + ).toHaveLength(2) + }) + + it('accepts a first-use device that does not know its user ID yet', async () => { + const { bob } = setup('bob') + bob.team.addRole('member') + + const phone = asFirstUseDevice(bob.phone!) + const { seed } = bob.team.inviteDevice() + const inviteeContext: InviteeDeviceContext = { + userName: bob.userName, + device: phone, + invitationSeed: seed, + expectedTeamId: bob.team.id, + } + const connections = createConnectionPair(memberContext(bob), inviteeContext) + const joined = eventPromise(connections.invitee, 'joined') + + await connect(connections) + const admission = await joined + + expect('userId' in phone).toBe(false) + expect(admission.user.userId).toBe(bob.userId) + expect(admission.team.hasDevice(phone.deviceId)).toBe(true) + expect(admission.team.members(bob.userId).devices).toHaveLength(2) + }) + + it('accepts an invited device when a different member admits it', async () => { + const { alice, bob } = setup('alice', 'bob') + alice.team.addRole('member') + alice.team.addMemberRole(bob.userId, 'member') + bob.team = teams.load(alice.team.save(), bob.localContext, alice.team.teamKeyring()) + + const phone = asFirstUseDevice(bob.phone!) + const { seed } = bob.team.inviteDevice() + alice.team = teams.load(bob.team.save(), alice.localContext, bob.team.teamKeyring()) + const inviteeContext: InviteeDeviceContext = { + userName: bob.userName, + device: phone, + invitationSeed: seed, + expectedTeamId: bob.team.id, + } + const connections = createConnectionPair(memberContext(alice), inviteeContext) + const joined = eventPromise(connections.invitee, 'joined') + + await connect(connections) + const admission = await joined + + expect(admission.user.userId).toBe(bob.userId) + expect(admission.team.hasDevice(phone.deviceId)).toBe(true) + expect(admission.team.members(bob.userId).devices).toHaveLength(2) + }) + + it('emits joined only after the invitation connection is secured', async () => { + const { alice, bob } = setup('alice', { user: 'bob', member: false }) + alice.team.addRole('member') + const { seed } = alice.team.inviteMember() + const inviteeContext: InviteeMemberContext = { + user: bob.user, + device: bob.device, + invitationSeed: seed, + expectedTeamId: alice.team.id, + } + const connections = createConnectionPair(memberContext(alice), inviteeContext) + const events: string[] = [] + let sessionKeyAtJoined: Uint8Array | undefined + connections.invitee.on('connectionSecured', () => events.push('connectionSecured')) + connections.invitee.on('joined', () => { + sessionKeyAtJoined = connections.invitee._sessionKey + events.push('joined') + }) + + await connect(connections) + + expect(events).toEqual(['connectionSecured', 'joined']) + expect(sessionKeyAtJoined).toBeInstanceOf(Uint8Array) + }) + + it('does not emit joined when session negotiation fails after admission', async () => { + const { alice, bob } = setup('alice', { user: 'bob', member: false }) + alice.team.addRole('member') + const { seed } = alice.team.inviteMember() + const inviteeContext: InviteeMemberContext = { + user: bob.user, + device: bob.device, + invitationSeed: seed, + expectedTeamId: alice.team.id, + } + const channel = new TamperedSeedChannel(alice.device.deviceId) + const connections = createConnectionPair(memberContext(alice), inviteeContext, channel) + const joined = vi.fn() + connections.invitee.on('joined', joined) + const error = eventPromise(connections.invitee, 'localError') + + start(connections) + + await expect(error).resolves.toMatchObject({ type: ENCRYPTION_FAILURE }) + expect(joined).not.toHaveBeenCalled() + }) + + it('rejects a graph that contains the invitation but omits the invited device', async () => { + const { bob } = setup('bob') + bob.team.addRole('member') + + const phone = asFirstUseDevice(bob.phone!) + const { seed } = bob.team.inviteDevice() + const graphBeforeAdmission = bob.team.save() + vi.spyOn(bob.team, 'save').mockReturnValue(graphBeforeAdmission) + const inviteeContext: InviteeDeviceContext = { + userName: bob.userName, + device: phone, + invitationSeed: seed, + expectedTeamId: bob.team.id, + } + const connections = createConnectionPair(memberContext(bob), inviteeContext) + + await expectAdmissionRejection(connections) + + expect(connections.invitee.team).toBeUndefined() + }) + + it('rejects a graph that contains a same-named member but omits the exact invitee', async () => { + const { alice, bob } = setup('alice', 'bob') + alice.team.addRole('member') + + const invitedUser = createUser(bob.userName) + const invitedDevice = createDevice({ + userId: invitedUser.userId, + deviceName: 'new-bob-laptop', + }) + const { seed } = alice.team.inviteMember() + const graphBeforeAdmission = alice.team.save() + vi.spyOn(alice.team, 'save').mockReturnValue(graphBeforeAdmission) + const inviteeContext: InviteeMemberContext = { + user: invitedUser, + device: invitedDevice, + invitationSeed: seed, + expectedTeamId: alice.team.id, + } + const connections = createConnectionPair(memberContext(alice), inviteeContext) + + await expectAdmissionRejection(connections) + + expect(connections.invitee.team).toBeUndefined() + }) + + const memberContext = ({ + user, + device, + team, + }: { + user: MemberContext['user'] + device: MemberContext['device'] + team: MemberContext['team'] + }): MemberContext => ({ user, device, team }) + + const createConnectionPair = ( + existingMember: MemberContext, + invitee: InviteeDeviceContext | InviteeMemberContext, + channel = new TestChannel() + ) => { + const join = joinTestChannel(channel) + const connections = { + member: join(existingMember), + invitee: join(invitee), + } + activeConnections.push(connections.member, connections.invitee) + return connections + } + + const start = ({ member, invitee }: ReturnType) => { + member.start() + invitee.start() + } + + const connect = async (connections: ReturnType) => { + const connected = all([connections.member, connections.invitee], 'connected') + start(connections) + await connected + } + + const expectAdmissionRejection = async (connections: ReturnType) => { + const error = eventPromise(connections.invitee, 'localError') + const disconnected = eventPromise(connections.invitee, 'disconnected') + start(connections) + + await expect(error).resolves.toMatchObject({ type: ADMIT_MEMBER_LINK_MISSING }) + await disconnected + } +}) + +class TamperedSeedChannel extends TestChannel { + constructor(private readonly senderToTamper: string) { + super() + } + + override write(senderId: string, message: Uint8Array) { + const numberedMessage = unpack(message) as NumberedMessage + if (senderId === this.senderToTamper && numberedMessage.type === 'SEED') { + const encryptedSeed = numberedMessage.payload.encryptedSeed.slice() + encryptedSeed[Math.floor(encryptedSeed.length / 2)] ^= 1 + const tampered = pack({ + ...numberedMessage, + payload: { encryptedSeed }, + }) + super.write( + senderId, + new Uint8Array(tampered.buffer, tampered.byteOffset, tampered.byteLength) + ) + return + } + super.write(senderId, message) + } +} diff --git a/packages/auth/src/connection/test/protocolMessages.test.ts b/packages/auth/src/connection/test/protocolMessages.test.ts new file mode 100644 index 000000000..c2e3f03e0 --- /dev/null +++ b/packages/auth/src/connection/test/protocolMessages.test.ts @@ -0,0 +1,42 @@ +import { randomKey } from '@localfirst/crypto' +import { Connection } from 'connection/Connection.js' +import { PROTOCOL_VERSION_UNSUPPORTED } from 'connection/errors.js' +import { isReadyMessage } from 'connection/message.js' +import { pack, unpack } from 'msgpackr' +import { setup } from 'util/testing/index.js' +import { describe, expect, it, vi } from 'vitest' + +describe('connection protocol messages', () => { + it('validates REQUEST_IDENTITY payloads at runtime', () => { + expect( + isReadyMessage({ type: 'REQUEST_IDENTITY', payload: { acceptorNonce: randomKey() } }) + ).toBe(true) + expect(isReadyMessage({ type: 'REQUEST_IDENTITY' })).toBe(false) + expect(isReadyMessage({ type: 'REQUEST_IDENTITY', payload: {} })).toBe(false) + expect( + isReadyMessage({ type: 'REQUEST_IDENTITY', payload: { acceptorNonce: 'not base58!' } }) + ).toBe(false) + }) + + it('rejects a payload-less legacy REQUEST_IDENTITY as a protocol error', () => { + const { alice } = setup('alice') + const sent: Uint8Array[] = [] + const localError = vi.fn() + const connection = new Connection({ + context: alice.connectionContext, + sendMessage: message => sent.push(message), + }).on('localError', localError) + + connection.start() + connection.deliver(pack({ index: 0, type: 'REQUEST_IDENTITY' })) + + expect(connection.state).toBe('disconnected') + expect(localError).toHaveBeenCalledWith( + expect.objectContaining({ type: PROTOCOL_VERSION_UNSUPPORTED }) + ) + expect(unpack(sent.at(-1)!)).toMatchObject({ + type: 'ERROR', + payload: { type: PROTOCOL_VERSION_UNSUPPORTED }, + }) + }) +}) diff --git a/packages/auth/src/connection/test/sync.test.ts b/packages/auth/src/connection/test/sync.test.ts index 6f010ac0f..f2f9e4d75 100644 --- a/packages/auth/src/connection/test/sync.test.ts +++ b/packages/auth/src/connection/test/sync.test.ts @@ -1,4 +1,6 @@ import { ADMIN } from 'role/index.js' +import * as teams from 'team/index.js' +import { asymmetric } from '@localfirst/crypto' import { TestChannel, any, @@ -15,7 +17,7 @@ import { updated, } from 'util/testing/index.js' import { pause } from '@localfirst/shared' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { type MemberContext } from '../types.js' describe('connection', () => { @@ -85,6 +87,29 @@ describe('connection', () => { expect(bob.team.hasRole('managers')).toBe(true) }) + it('decrypts only the new graph link during an incremental sync', async () => { + const { alice, bob } = setup('alice', 'bob') + await connect(alice, bob) + + const existingCiphertexts = Object.values(bob.team.graph.encryptedLinks).map( + link => link.encryptedBody + ) + const decrypt = vi.spyOn(asymmetric, 'decryptBytes') + + try { + alice.team.addRole('operators') + const newHead = alice.team.graph.head[0] + const newCiphertext = alice.team.graph.encryptedLinks[newHead].encryptedBody + + await vi.waitFor(() => expect(bob.team.hasRole('operators')).toBe(true)) + + expect(ciphertextDecryptions(decrypt.mock.calls, existingCiphertexts)).toBe(0) + expect(ciphertextDecryptions(decrypt.mock.calls, [newCiphertext])).toBe(1) + } finally { + decrypt.mockRestore() + } + }) + it('updates local user while connected', async () => { const { alice, bob } = setup('alice', 'bob') @@ -501,6 +526,10 @@ describe('connection', () => { it('when a member is demoted and concurrently adds a device, the new device is kept', async () => { const { alice, bob } = setup('alice', 'bob') + alice.team.addRole('member') + alice.team.addMemberRole(bob.userId, 'member') + bob.team = teams.load(alice.team.save(), bob.localContext, alice.team.teamKeyring()) + bob.connectionContext = { user: bob.user, device: bob.device, team: bob.team } // 👩🏾 Alice removes 👨🏻‍🦲 Bob from admin role alice.team.removeMemberRole(bob.userId, ADMIN) @@ -759,6 +788,9 @@ describe('connection', () => { describe('post-compromise recovery', () => { it("Eve steals Bob's phone; Bob heals the team", async () => { const { alice, bob, charlie } = setup('alice', 'bob', 'charlie') + alice.team.addRole('member') + bob.team.addRole('member') + charlie.team.addRole('member') await connect(alice, bob) await connect(bob, charlie) @@ -800,3 +832,13 @@ describe('connection', () => { }) }) }) + +type DecryptCall = Parameters + +const ciphertextDecryptions = (calls: DecryptCall[], ciphertexts: Uint8Array[]): number => + calls.filter(([options]) => + ciphertexts.some(ciphertext => bytesAreEqual(options.cipher, ciphertext)) + ).length + +const bytesAreEqual = (left: Uint8Array, right: Uint8Array): boolean => + left.length === right.length && left.every((byte, index) => byte === right[index]) diff --git a/packages/auth/src/connection/test/validateInvitationAcceptance.test.ts b/packages/auth/src/connection/test/validateInvitationAcceptance.test.ts new file mode 100644 index 000000000..065d53d3e --- /dev/null +++ b/packages/auth/src/connection/test/validateInvitationAcceptance.test.ts @@ -0,0 +1,425 @@ +import { + getSequence, + merge, + redactKeys, + type Base58, + type UserWithSecrets, +} from '@localfirst/crdx' +import { asymmetric, randomKey } from '@localfirst/crypto' +import { redactDevice, type DeviceWithSecrets } from 'device/index.js' +import { + generateProof, + type InvitationClaim, + type InvitationV2, + type ProofOfInvitationV2, +} from 'invitation/index.js' +import { ADMIN } from 'role/index.js' +import { membershipResolver } from 'team/membershipResolver.js' +import { serializeTeamGraph } from 'team/serialize.js' +import type { Team } from 'team/Team.js' +import type { TeamGraph } from 'team/types.js' +import { redactFirstUseDevice, setup } from 'util/testing/index.js' +import { describe, expect, it, vi } from 'vitest' +import { createInvitationAcceptance } from '../invitationAcceptance.js' +import { processInvitationAcceptance } from '../validateInvitationAcceptance.js' + +describe('exact effective invitation admission validation', () => { + it('accepts exact member and device admissions', () => { + const member = admittedMemberFixture() + expect(validateFixture(member).isValid).toBe(true) + + const { bob } = setup('bob') + const { seed } = bob.team.inviteDevice() + const claim: InvitationClaim = { + invitationKind: 'device', + userName: bob.userName, + device: redactFirstUseDevice(bob.phone!), + } + const proof = proofFor(seed, claim) + const invitation = v2Invitation(bob.team, proof) + bob.team.admitDevice(proof, claim.device, bob.userName, proof.acceptorNonce) + + expect( + validateFixture({ + team: bob.team, + senderDevice: bob.device, + seed, + invitation, + proof, + claim, + }).isValid + ).toBe(true) + }) + + it('opens and validates an acceptance payload exactly once', () => { + const fixture = admittedMemberFixture() + const payload = createInvitationAcceptance({ + invitation: fixture.invitation, + proof: fixture.proof, + claim: fixture.claim, + senderDevice: fixture.senderDevice, + serializedGraph: fixture.team.save(), + teamKeyring: fixture.team.teamKeyring(), + }) + const decrypt = vi.spyOn(asymmetric, 'decryptBytes') + + try { + const result = processInvitationAcceptance({ + payload, + invitationSeed: fixture.seed, + expectedTeamId: fixture.team.id, + proof: fixture.proof, + claim: fixture.claim, + }) + expect(result.isValid).toBe(true) + const acceptanceDecryptions = decrypt.mock.calls.filter( + ([options]) => options.cipher === payload.encryptedAcceptance + ) + expect(acceptanceDecryptions).toHaveLength(1) + } finally { + decrypt.mockRestore() + } + }) + + it('classifies an unauthenticated acceptance before graph validation', () => { + const fixture = admittedMemberFixture() + const payload = createInvitationAcceptance({ + invitation: fixture.invitation, + proof: fixture.proof, + claim: fixture.claim, + senderDevice: fixture.senderDevice, + serializedGraph: fixture.team.save(), + teamKeyring: fixture.team.teamKeyring(), + }) + const encryptedAcceptance = payload.encryptedAcceptance.slice() + encryptedAcceptance[0] = encryptedAcceptance[0] === 0 ? 1 : 0 + + const result = processInvitationAcceptance({ + payload: { ...payload, encryptedAcceptance }, + invitationSeed: fixture.seed, + expectedTeamId: fixture.team.id, + proof: fixture.proof, + claim: fixture.claim, + }) + + expect(result).toMatchObject({ isValid: false, reason: 'ACCEPTANCE_INVALID' }) + }) + + it('rejects an identity that exists without an ADMIT action', () => { + const { alice, bob } = setup('alice', { user: 'bob', member: false }) + const { seed } = alice.team.inviteMember() + const claim = memberClaim(bob) + const proof = proofFor(seed, claim) + const invitation = v2Invitation(alice.team, proof) + alice.team.addForTesting(bob.user, [], redactDevice(bob.device)) + + expect( + validateFixture({ + team: alice.team, + senderDevice: alice.device, + seed, + invitation, + proof, + claim, + }).isValid + ).toBe(false) + }) + + it('rejects a self-consistent replacement team with the wrong root', () => { + const { alice, bob } = setup('alice', { user: 'bob', member: false }) + const { mallory } = setup('mallory') + const { seed } = alice.team.inviteMember() + const claim = memberClaim(bob) + const proof = proofFor(seed, claim) + const invitation = v2Invitation(alice.team, proof) + + mallory.team.dispatch({ + type: 'INVITE_MEMBER', + payload: { invitation }, + }) + mallory.team.admitMember( + proof, + claim.userKeys, + claim.userName, + claim.device, + proof.acceptorNonce + ) + + expect( + validateFixture({ + team: mallory.team, + senderDevice: mallory.device, + seed, + invitation, + proof, + claim, + expectedTeamId: alice.team.id, + }) + ).toMatchObject({ isValid: false, reason: 'WRONG_TEAM' }) + }) + + it('rejects an identity admitted by another invitation ID', () => { + const { alice, bob } = setup('alice', { user: 'bob', member: false }) + const first = alice.team.inviteMember() + const second = alice.team.inviteMember() + const claim = memberClaim(bob) + const firstProof = proofFor(first.seed, claim) + const secondProof = proofFor(second.seed, claim) + const secondInvitation = v2Invitation(alice.team, secondProof) + alice.team.admitMember( + firstProof, + claim.userKeys, + claim.userName, + claim.device, + firstProof.acceptorNonce + ) + + expect( + validateFixture({ + team: alice.team, + senderDevice: alice.device, + seed: second.seed, + invitation: secondInvitation, + proof: secondProof, + claim, + }).isValid + ).toBe(false) + }) + + it('rejects an admission made with a different handshake proof', () => { + const fixture = admittedMemberFixture() + const differentHandshakeProof = proofFor(fixture.seed, fixture.claim) + + expect( + validateFixture({ ...fixture, proof: differentHandshakeProof }).isValid + ).toBe(false) + }) + + it('rejects a matching invitation ID with different public keys', () => { + const fixture = admittedMemberFixture() + const changedClaim: InvitationClaim = { + ...fixture.claim, + userKeys: { + ...fixture.claim.userKeys, + signature: randomKey(), + }, + } + + expect(validateFixture({ ...fixture, claim: changedClaim }).isValid).toBe(false) + }) + + it('rejects a matching device found under the wrong member', () => { + const { alice, bob } = setup('alice', 'bob') + const { seed } = bob.team.inviteDevice() + const claim: InvitationClaim = { + invitationKind: 'device', + userName: bob.userName, + device: redactFirstUseDevice(bob.phone!), + } + const proof = proofFor(seed, claim) + const invitation = v2Invitation(bob.team, proof) + const wrongOwnerDevice = { + ...redactDevice(bob.phone!), + userId: alice.userId, + } + bob.team.addForTesting(alice.user, [], wrongOwnerDevice) + + expect( + validateFixture({ + team: bob.team, + senderDevice: bob.device, + seed, + invitation, + proof, + claim, + }).isValid + ).toBe(false) + }) + + it('rejects a raw matching admission invalidated by the membership resolver', () => { + const { alice, bob, charlie } = setup('alice', 'bob', { + user: 'charlie', + member: false, + }) + alice.team.removeMemberRole(bob.userId, ADMIN) + + const { seed } = bob.team.inviteMember() + const claim = memberClaim(charlie) + const proof = proofFor(seed, claim) + const invitation = v2Invitation(bob.team, proof) + bob.team.admitMember(proof, claim.userKeys, claim.userName, claim.device, proof.acceptorNonce) + + const graph = merge(alice.team.graph, bob.team.graph) as TeamGraph + const sequence = getSequence(graph, membershipResolver) + expect( + sequence.some( + link => + link.body.type === 'ADMIT_MEMBER' && link.body.payload.id === proof.id && link.isInvalid + ) + ).toBe(true) + + expect( + validateFixture({ + team: bob.team, + senderDevice: bob.device, + seed, + invitation, + proof, + claim, + serializedGraph: serializeTeamGraph(graph), + teamKeyring: { + ...alice.team.teamKeyring(), + ...bob.team.teamKeyring(), + }, + }).isValid + ).toBe(false) + }) + + it('rejects an admission followed by removal', () => { + const fixture = admittedMemberFixture() + fixture.team.remove(fixture.claim.userKeys.name) + + expect(validateFixture(fixture).isValid).toBe(false) + }) + + it('accepts only the matching admission from a multi-use member invitation', () => { + const { alice, bob, eve } = setup( + 'alice', + { user: 'bob', member: false }, + { user: 'eve', member: false } + ) + const { seed } = alice.team.inviteMember({ maxUses: 0 }) + const invitationId = alice.team.getInvitation(proofFor(seed, memberClaim(bob)).id) + if (invitationId.version !== 2) { + throw new Error('Expected a version 2 invitation') + } + + const bobClaim = memberClaim(bob) + const bobProof = proofFor(seed, bobClaim) + alice.team.admitMember( + bobProof, + bobClaim.userKeys, + bobClaim.userName, + bobClaim.device, + bobProof.acceptorNonce + ) + const eveClaim = memberClaim(eve) + const eveProof = proofFor(seed, eveClaim) + alice.team.admitMember( + eveProof, + eveClaim.userKeys, + eveClaim.userName, + eveClaim.device, + eveProof.acceptorNonce + ) + + for (const fixture of [ + { + team: alice.team, + senderDevice: alice.device, + seed, + invitation: invitationId, + proof: bobProof, + claim: bobClaim, + }, + { + team: alice.team, + senderDevice: alice.device, + seed, + invitation: invitationId, + proof: eveProof, + claim: eveClaim, + }, + ]) { + expect(validateFixture(fixture).isValid).toBe(true) + } + }) +}) + +type Fixture = { + team: Team + senderDevice: DeviceWithSecrets + seed: string + invitation: InvitationV2 + proof: ProofOfInvitationV2 + claim: InvitationClaim + expectedTeamId?: Base58 + serializedGraph?: Uint8Array + teamKeyring?: ReturnType +} + +const validateFixture = ({ + team, + senderDevice, + seed, + invitation, + proof, + claim, + expectedTeamId = team.id, + serializedGraph = team.save(), + teamKeyring = team.teamKeyring(), +}: Fixture) => { + const payload = createInvitationAcceptance({ + invitation, + proof, + claim, + senderDevice, + serializedGraph, + teamKeyring, + }) + return processInvitationAcceptance({ + payload, + invitationSeed: seed, + expectedTeamId, + proof, + claim, + }) +} + +const admittedMemberFixture = (): Fixture & { + claim: Extract +} => { + const { alice, bob } = setup('alice', { user: 'bob', member: false }) + const { seed } = alice.team.inviteMember() + const claim = memberClaim(bob) + const proof = proofFor(seed, claim) + const invitation = v2Invitation(alice.team, proof) + alice.team.admitMember(proof, claim.userKeys, claim.userName, claim.device, proof.acceptorNonce) + return { + team: alice.team, + senderDevice: alice.device, + seed, + invitation, + proof, + claim, + } +} + +const memberClaim = ({ + user, + device, +}: { + user: Pick + device: DeviceWithSecrets +}): Extract => ({ + invitationKind: 'member', + userName: user.userName, + userKeys: redactKeys(user.keys), + device: redactDevice(device), +}) + +const proofFor = (seed: string, claim: InvitationClaim) => + generateProof({ + seed, + claim, + acceptorNonce: randomKey(), + inviteeNonce: randomKey(), + }) + +const v2Invitation = (team: Team, proof: ProofOfInvitationV2) => { + const invitation = team.getInvitation(proof.id) + if (invitation.version !== 2) { + throw new Error('Expected a version 2 invitation') + } + return invitation +} diff --git a/packages/auth/src/connection/types.ts b/packages/auth/src/connection/types.ts index 424464e31..6de852516 100644 --- a/packages/auth/src/connection/types.ts +++ b/packages/auth/src/connection/types.ts @@ -5,7 +5,6 @@ import type { Hash, KeyScope, Keyring, - Keyset, SyncState, UnixTimestamp, UserWithSecrets, @@ -16,11 +15,16 @@ import type { FirstUseDevice, FirstUseDeviceWithSecrets, } from 'device/index.js' -import type { ProofOfInvitation } from 'invitation/index.js' +import type { + DeviceInvitationClaim, + MemberInvitationClaim, + ProofOfInvitationV2, +} from 'invitation/index.js' import type { ServerWithSecrets } from 'server/index.js' import type { Member, Team } from 'team/index.js' import type { ConnectionErrorPayload } from './errors.js' import type { ConnectionMessage } from './message.js' +import type { InvitationAcceptanceValidationResult } from './validateInvitationAcceptance.js' export type ConnectionEvents = { /** state change in the connection */ @@ -42,7 +46,8 @@ export type ConnectionEvents = { * We've successfully joined a team using an invitation. This event provides the team graph and * the user's info (including keys). (When we're joining as a new device for an existing user, * this is how we get the user's keys.) This event gives the application a chance to persist the - * team graph and the user's info. + * team graph and the user's info. The session key has already been committed, so listeners may + * immediately call `Connection.send`. This event follows `connectionSecured`. */ joined: ({ team, user }: { team: Team; user: UserWithSecrets; teamKeyring: Keyring }) => void @@ -54,7 +59,10 @@ export type ConnectionEvents = { sync: ({ team, user }: { team: Team; user: UserWithSecrets }) => void - /** Identities have been validated on both ends of the connection and the connection is encrypted */ + /** + * Identities have been validated and the session key is committed. Listeners may immediately use + * the encrypted-channel API. + */ connectionSecured: () => void } @@ -65,25 +73,19 @@ export type MemberIdentityClaim = { deviceId: string } -export type InviteeMemberIdentityClaim = { +export type InviteeMemberIdentityClaim = MemberInvitationClaim & { // I'm a new user and I have an invitation - proofOfInvitation: ProofOfInvitation - userName: string - userKeys: Keyset - device: Device + proofOfInvitation: ProofOfInvitationV2 } -export type InviteeDeviceIdentityClaim = { +export type InviteeDeviceIdentityClaim = DeviceInvitationClaim & { // I'm a new device for an existing user and I have an invitation - proofOfInvitation: ProofOfInvitation - userName: string - device: FirstUseDevice + proofOfInvitation: ProofOfInvitationV2 } -export type IdentityClaim = - | MemberIdentityClaim - | InviteeMemberIdentityClaim - | InviteeDeviceIdentityClaim +export type InviteeIdentityClaim = InviteeMemberIdentityClaim | InviteeDeviceIdentityClaim + +export type IdentityClaim = MemberIdentityClaim | InviteeIdentityClaim // CONTEXT @@ -97,12 +99,18 @@ export type InviteeMemberContext = { user: UserWithSecrets device: DeviceWithSecrets invitationSeed: string + + /** Full immutable team root obtained with the invitation; never a truncated discovery ID. */ + expectedTeamId: Base58 } export type InviteeDeviceContext = { userName: string device: FirstUseDeviceWithSecrets invitationSeed: string + + /** Full immutable team root obtained with the invitation; never a truncated discovery ID. */ + expectedTeamId: Base58 } export type InviteeContext = InviteeMemberContext | InviteeDeviceContext @@ -122,8 +130,15 @@ export type Challenge = KeyScope & { export type ConnectionContext = { device: DeviceWithSecrets | FirstUseDeviceWithSecrets + /** Nonce we sent with REQUEST_IDENTITY, to which an invitee proof must be bound. */ + acceptorNonce: Base58 + + /** Nonce generated by this peer for any invitation claim it sends. */ + inviteeNonce: Base58 + ourIdentityClaim?: IdentityClaim theirIdentityClaim?: IdentityClaim + invitationAcceptanceResult?: InvitationAcceptanceValidationResult challenge?: Challenge @@ -181,13 +196,13 @@ export const isMemberClaim = (claim: IdentityClaim): claim is MemberIdentityClai } export const isInviteeMemberClaim = (claim: IdentityClaim): claim is InviteeMemberIdentityClaim => { - return isInviteeClaim(claim) && 'userKeys' in claim && claim.userKeys !== undefined + return isInviteeClaim(claim) && claim.invitationKind === 'member' } export const isInviteeDeviceClaim = (claim: IdentityClaim): claim is InviteeDeviceIdentityClaim => { - return isInviteeClaim(claim) && !('userKeys' in claim) + return isInviteeClaim(claim) && claim.invitationKind === 'device' } -export const isInviteeClaim = (claim: IdentityClaim): claim is InviteeDeviceIdentityClaim => { +export const isInviteeClaim = (claim: IdentityClaim): claim is InviteeIdentityClaim => { return 'proofOfInvitation' in claim && claim.proofOfInvitation !== undefined } diff --git a/packages/auth/src/connection/validateInvitationAcceptance.ts b/packages/auth/src/connection/validateInvitationAcceptance.ts new file mode 100644 index 000000000..5f3841a0b --- /dev/null +++ b/packages/auth/src/connection/validateInvitationAcceptance.ts @@ -0,0 +1,283 @@ +import type { Base58, MachineResult } from '@localfirst/crdx' +import type { Logger } from '@localfirst/shared' +import type { InvitationClaim, ProofOfInvitationV2 } from 'invitation/index.js' +import { isEqual } from 'lodash-es' +import { deserializeTeamGraph } from 'team/serialize.js' +import { teamMachine } from 'team/teamMachine.js' +import type { TeamAction, TeamContext, TeamGraph, TeamLink, TeamState } from 'team/types.js' +import { ValidationError } from 'util/index.js' +import { + invitationAcceptanceSenderIsActive, + openInvitationAcceptance, +} from './invitationAcceptance.js' +import type { AcceptInvitationPayload, InvitationAcceptance } from './message.js' + +export type InvitationAcceptanceFailureReason = + | 'ACCEPTANCE_INVALID' + | 'WRONG_TEAM' + | 'SENDER_UNKNOWN' + | 'ADMISSION_INVALID' + +export type InvitationAcceptanceValidation = { + acceptance: InvitationAcceptance + graph: TeamGraph + state: TeamState + admissionLink: TeamLink + machineResult: MachineResult +} + +export type InvitationAcceptanceValidationResult = + | { + isValid: true + value: InvitationAcceptanceValidation + } + | { + isValid: false + reason: InvitationAcceptanceFailureReason + error: ValidationError + } + +type ValidateInvitationAcceptanceOptions = { + acceptance: InvitationAcceptance + payload: AcceptInvitationPayload + proof: ProofOfInvitationV2 + claim: InvitationClaim + expectedTeamId: Base58 + logger?: Logger +} + +type ProcessInvitationAcceptanceOptions = Omit< + ValidateInvitationAcceptanceOptions, + 'acceptance' +> & { + invitationSeed: string +} + +/** + * Authenticates and opens an acceptance envelope, then validates its returned graph in one pass. + * The independently obtained full `expectedTeamId` is required as the graph-root trust anchor. + */ +export const processInvitationAcceptance = ({ + payload, + invitationSeed, + proof, + claim, + expectedTeamId, + logger, +}: ProcessInvitationAcceptanceOptions): InvitationAcceptanceValidationResult => { + let acceptance: InvitationAcceptance + try { + acceptance = openInvitationAcceptance({ + payload, + invitationSeed, + proof, + claim, + }) + } catch (error) { + logger?.error('Invalid invitation acceptance', error) + return invalid('ACCEPTANCE_INVALID', 'Invitation acceptance could not be authenticated', { + error, + }) + } + + return validateInvitationAcceptance({ + acceptance, + payload, + proof, + claim, + expectedTeamId, + logger, + }) +} + +/** + * Validates an opened invitation acceptance without repeating envelope decryption. + * + * The graph root must equal `expectedTeamId`; the graph must validate and reduce successfully; the + * sender must be active; and exactly one effective admission must match the exact proof and claim + * while leaving one unambiguous active identity in final state. + */ +export const validateInvitationAcceptance = ({ + acceptance, + payload, + proof, + claim, + expectedTeamId, + logger, +}: ValidateInvitationAcceptanceOptions): InvitationAcceptanceValidationResult => { + let graph: TeamGraph + let state: TeamState + + try { + graph = deserializeTeamGraph(acceptance.serializedGraph, acceptance.teamKeyring) + if (graph.root !== expectedTeamId) { + return invalid('WRONG_TEAM', 'Invitation acceptance graph has an unexpected team root') + } + const machineResult = teamMachine.derive(graph, logger) + state = machineResult.state + + const effectiveLinks = machineResult.sequence.filter(link => !link.isInvalid) + const admissionLinks = + claim.invitationKind === 'member' + ? effectiveLinks.filter(link => memberAdmissionMatches(link, proof, claim)) + : effectiveLinks.filter(link => + deviceAdmissionMatches(link, proof, claim, state.invitations[proof.id]?.userId) + ) + + return validateDerivedAcceptance({ + acceptance, + payload, + proof, + claim, + expectedTeamId, + graph, + state, + machineResult, + admissionLinks, + }) + } catch (error) { + return invalid('ADMISSION_INVALID', 'Invitation acceptance contains an invalid team graph', { + error, + }) + } +} + +type ValidateDerivedAcceptanceOptions = ValidateInvitationAcceptanceOptions & { + graph: TeamGraph + state: TeamState + machineResult: MachineResult + admissionLinks: TeamLink[] +} + +const validateDerivedAcceptance = ({ + acceptance, + payload, + proof, + claim, + graph, + state, + machineResult, + admissionLinks, +}: ValidateDerivedAcceptanceOptions): InvitationAcceptanceValidationResult => { + const invitation = state.invitations[proof.id] + if (invitation === undefined || invitation.kind !== claim.invitationKind) { + return invalid( + 'WRONG_TEAM', + 'Invitation acceptance graph does not contain the claimed invitation' + ) + } + + if (!invitationAcceptanceSenderIsActive(state, payload, acceptance)) { + return invalid( + 'SENDER_UNKNOWN', + 'Invitation acceptance sender is not an active identity in the team graph' + ) + } + + if (admissionLinks.length !== 1) { + return invalid( + 'ADMISSION_INVALID', + `Expected one effective admission for invitation '${proof.id}', found ${admissionLinks.length}` + ) + } + + const finalIdentityIsExact = + claim.invitationKind === 'member' + ? finalMemberIsExact(state, claim) + : finalDeviceIsExact(state, claim, invitation.userId) + + if (!finalIdentityIsExact) { + return invalid( + 'ADMISSION_INVALID', + 'The admitted identity is not uniquely active in the final team state' + ) + } + + return { + isValid: true, + value: { + acceptance, + graph, + state, + admissionLink: admissionLinks[0], + machineResult, + }, + } +} + +const memberAdmissionMatches = ( + link: TeamLink, + proof: ProofOfInvitationV2, + claim: Extract +): boolean => + link.body.type === 'ADMIT_MEMBER' && + link.body.payload.id === proof.id && + isEqual(link.body.payload.proof, proof) && + isEqual(link.body.payload.claim, claim) && + link.body.payload.userName === claim.userName && + isEqual(link.body.payload.memberKeys, claim.userKeys) + +const deviceAdmissionMatches = ( + link: TeamLink, + proof: ProofOfInvitationV2, + claim: Extract, + invitationUserId?: string +): boolean => { + if (invitationUserId === undefined) { + return false + } + + return ( + link.body.type === 'ADMIT_DEVICE' && + link.body.payload.id === proof.id && + isEqual(link.body.payload.proof, proof) && + isEqual(link.body.payload.claim, claim) && + isEqual(link.body.payload.device, { + ...claim.device, + userId: invitationUserId, + }) + ) +} + +const finalMemberIsExact = ( + state: TeamState, + claim: Extract +): boolean => { + const members = state.members.filter(member => member.userId === claim.userKeys.name) + return ( + members.length === 1 && + members[0].userName === claim.userName && + isEqual(members[0].keys, claim.userKeys) + ) +} + +const finalDeviceIsExact = ( + state: TeamState, + claim: Extract, + invitationUserId?: string +): boolean => { + if (invitationUserId === undefined) { + return false + } + + const devices = state.members + .flatMap(member => member.devices ?? []) + .filter(device => device.deviceId === claim.device.deviceId) + return ( + devices.length === 1 && + isEqual(devices[0], { + ...claim.device, + userId: invitationUserId, + }) + ) +} + +const invalid = ( + reason: InvitationAcceptanceFailureReason, + message: string, + details?: unknown +): InvitationAcceptanceValidationResult => ({ + isValid: false, + reason, + error: new ValidationError(message, details), +}) diff --git a/packages/auth/src/invitation/create.ts b/packages/auth/src/invitation/create.ts index 91bdd0ddb..3a934c7bf 100644 --- a/packages/auth/src/invitation/create.ts +++ b/packages/auth/src/invitation/create.ts @@ -2,20 +2,21 @@ import { type UnixTimestamp } from '@localfirst/crdx' import { generateStarterKeys } from './generateStarterKeys.js' import { deriveId } from 'invitation/deriveId.js' import { normalize } from 'invitation/normalize.js' -import { type Invitation } from 'invitation/types.js' +import { type InvitationV2 } from 'invitation/types.js' export const IKEY_LENGTH = 16 /** - * Returns an an invitation to publicly post on the team's signature chain. Inspired by Keybase's - * Seitan Token v2 exchange protocol. + * Normalizes a secret seed and returns a version-2 invitation record to publish on the team graph. + * The record exposes separate ephemeral signature and encryption public keys derived from the seed. + * Inspired by Keybase's Seitan Token v2 exchange protocol. */ export const create = ({ seed, maxUses = 1, // By default an invitation can only be used once expiration = 0 as UnixTimestamp, // By default an invitation never expires userId, -}: Params): Invitation => { +}: Params): InvitationV2 => { seed = normalize(seed) // The ID of the invitation is derived from the seed @@ -23,9 +24,18 @@ export const create = ({ // The ephemeral public signature key will be used to verify Bob's proof of invitation const starterKeys = generateStarterKeys(seed) - const { publicKey } = starterKeys.signature - - return { id, publicKey, expiration, maxUses, userId } + const signaturePublicKey = starterKeys.signature.publicKey + const encryptionPublicKey = starterKeys.encryption.publicKey + + return { + version: 2, + id, + signaturePublicKey, + encryptionPublicKey, + expiration, + maxUses, + userId, + } } type Params = { @@ -38,6 +48,6 @@ type Params = { /** Number of times the invitation can be used. If 0, the invitation can be used any number of times. By default, an invitation can only be used once. */ maxUses?: number - /** (Device invitations only) User name the device will be associated with. */ + /** (Device invitations only) User ID the device will be associated with. */ userId?: string } diff --git a/packages/auth/src/invitation/deriveId.ts b/packages/auth/src/invitation/deriveId.ts index 6b3c2fbf3..cf1a7dca9 100644 --- a/packages/auth/src/invitation/deriveId.ts +++ b/packages/auth/src/invitation/deriveId.ts @@ -1,7 +1,16 @@ import { type Hash, hash, stretch } from '@localfirst/crypto' import { HashPurpose } from 'util/index.js' +import { normalize } from './normalize.js' +/** + * Derives the public invitation ID from a secret seed. + * + * Formatting is normalized internally, so grouped, punctuated, and URL-formatted representations + * of the same seed produce the same ID. + */ export function deriveId(seed: string) { + seed = normalize(seed) + // ## Step 1b // The iKey is stretched using `scrypt` to discourage brute-force attacks (docs refer to this as // the `siKey`) diff --git a/packages/auth/src/invitation/generateProof.ts b/packages/auth/src/invitation/generateProof.ts index 60ba191cc..5aede4671 100644 --- a/packages/auth/src/invitation/generateProof.ts +++ b/packages/auth/src/invitation/generateProof.ts @@ -1,22 +1,37 @@ -import { memoize } from '@localfirst/shared' -import { signatures } from '@localfirst/crypto' +import { signatures, type Base58 } from '@localfirst/crypto' import { deriveId } from 'invitation/deriveId.js' -import { type ProofOfInvitation } from 'invitation/types.js' +import type { InvitationClaim, ProofOfInvitationV2 } from 'invitation/types.js' import { generateStarterKeys } from './generateStarterKeys.js' +import { invitationProofPayload } from './invitationProofPayload.js' import { normalize } from './normalize.js' -export const generateProof = memoize((seed: string): ProofOfInvitation => { +/** + * Generates a version-2 proof of invitation possession. + * + * The signature is domain-separated and binds the normalized invitation seed to the exact member + * or device claim plus both peers' handshake nonces. A proof therefore cannot be replayed for a + * different identity or connection transcript. + */ +export const generateProof = ({ + seed, + claim, + acceptorNonce, + inviteeNonce, +}: { + seed: string + claim: InvitationClaim + acceptorNonce: Base58 + inviteeNonce: Base58 +}): ProofOfInvitationV2 => { seed = normalize(seed) - // Bob independently derives the invitation id and the ephemeral keys const id = deriveId(seed) - const ephemeralKeys = generateStarterKeys(seed) + const starterKeys = generateStarterKeys(seed) + const proofFields = { id, acceptorNonce, inviteeNonce } + const signature = signatures.sign( + invitationProofPayload(proofFields, claim), + starterKeys.signature.secretKey + ) - // Bob uses the ephemeral keys to sign a message consisting of the invitation id - const payload = { id } - const signature = signatures.sign(payload, ephemeralKeys.signature.secretKey) - - // This signature will be shown to an existing team admin as proof that Bob knows the secret - // invitation key. - return { id, signature } -}) + return { version: 2, ...proofFields, signature } +} diff --git a/packages/auth/src/invitation/index.ts b/packages/auth/src/invitation/index.ts index 24a6e53d0..128532b92 100644 --- a/packages/auth/src/invitation/index.ts +++ b/packages/auth/src/invitation/index.ts @@ -3,5 +3,6 @@ export * from './deriveId.js' export * from './randomSeed.js' export * from './generateProof.js' export * from './generateStarterKeys.js' +export * from './invitationProofPayload.js' export * from './validate.js' export * from './types.js' diff --git a/packages/auth/src/invitation/invitationProofPayload.ts b/packages/auth/src/invitation/invitationProofPayload.ts new file mode 100644 index 000000000..318d707b2 --- /dev/null +++ b/packages/auth/src/invitation/invitationProofPayload.ts @@ -0,0 +1,44 @@ +import type { Keyset } from '@localfirst/crdx' +import { hash, type Base58, type Payload } from '@localfirst/crypto' +import type { Device, FirstUseDevice } from 'device/index.js' +import type { InvitationClaim, InvitationKind, ProofOfInvitationV2 } from './types.js' + +export const INVITATION_CLAIM_DOMAIN = 'localfirst-auth/invitation-claim' as const +export const INVITATION_CLAIM_VERSION = 2 as const + +/** Returns the canonical, domain-separated payload signed by a version-2 invitation proof. */ +export const invitationProofPayload = ( + proof: Pick, + claim: InvitationClaim +): Payload => + [ + INVITATION_CLAIM_DOMAIN, + INVITATION_CLAIM_VERSION, + proof.id, + claim.invitationKind, + proof.acceptorNonce, + proof.inviteeNonce, + claim.userName, + claim.invitationKind === 'member' ? keysetPayload(claim.userKeys) : null, + devicePayload(claim.device, claim.invitationKind), + ] as Payload + +/** Returns a stable digest of the exact proof transcript and identity claim. */ +export const invitationClaimDigest = ( + proof: Pick, + claim: InvitationClaim +): Base58 => + hash('localfirst-auth/invitation-claim-digest', invitationProofPayload(proof, claim)) as Base58 + +const keysetPayload = (keys: Keyset): Payload => + [keys.type, keys.name, keys.generation, keys.encryption, keys.signature] as Payload + +const devicePayload = (device: Device | FirstUseDevice, kind: InvitationKind): Payload => + [ + device.deviceId, + device.deviceName, + device.created ?? null, + device.deviceInfo ?? null, + keysetPayload(device.keys), + kind === 'member' ? (device as Device).userId : null, + ] as Payload diff --git a/packages/auth/src/invitation/test/invitation.test.ts b/packages/auth/src/invitation/test/invitation.test.ts index 911b41330..7e4b6a5a1 100644 --- a/packages/auth/src/invitation/test/invitation.test.ts +++ b/packages/auth/src/invitation/test/invitation.test.ts @@ -1,47 +1,127 @@ +import { createUser, redactKeys } from '@localfirst/crdx' +import { randomKey } from '@localfirst/crypto' +import { createDevice, redactDevice } from 'device/index.js' +import { + create, + deriveId, + generateProof, + randomSeed, + validate, + type MemberInvitationClaim, + type ProofOfInvitationV2, +} from 'invitation/index.js' import { describe, expect, test } from 'vitest' -import { create, generateProof, randomSeed, validate } from 'invitation/index.js' describe('invitations', () => { - test('create invitation', () => { - const seed = randomSeed() - const invitation = create({ seed }) - // Looks like an invitation - expect(invitation).toHaveProperty('id') + test('derives the same ID from grouped and URL-formatted seeds', () => { + const seed = 'abcd2345efgh6789' + + expect(deriveId('abcd 2345 efgh 6789')).toBe(deriveId(seed)) + expect(deriveId('abcd+2345-efgh_6789')).toBe(deriveId(seed)) + }) + + test('creates a v2 invitation with both starter public keys', () => { + const invitation = create({ seed: randomSeed() }) + + expect(invitation).toMatchObject({ version: 2 }) expect(invitation.id).toHaveLength(15) - expect(invitation).toHaveProperty('publicKey') + expect(invitation.signaturePublicKey).toBeDefined() + expect(invitation.encryptionPublicKey).toBeDefined() }) - test('validate member invitation', () => { - // 👩🏾 Alice generates a secret key and sends it to 👨🏻‍🦲 Bob via a trusted side channel. - const seed = 'passw0rd' + test('validates a transcript-bound member proof', () => { + const { seed, invitation, claim, proof } = fixture() - // 👩🏾 Alice generates an invitation with this key. Normally the invitation would be stored on the - // team's signature chain; here we're just keeping it around in a variable. - const invitation = create({ seed }) + expect(validate(proof, invitation, claim, proof.acceptorNonce).isValid).toBe(true) + expect( + validate( + generateProof({ seed: `${seed}-wrong`, claim, ...nonces() }), + invitation, + claim + ) + ).toMatchObject({ isValid: false }) + }) - // 👨🏻‍🦲 Bob accepts invitation and obtains a credential proving that he was invited. - const proofOfInvitation = generateProof(seed) + test.each([ + 'member encryption key', + 'member signature key', + 'device encryption key', + 'device signature key', + 'user name', + 'invitation kind', + 'acceptor nonce', + 'invitee nonce', + ])('rejects a changed %s', field => { + const { invitation, claim, proof } = fixture() + const changedClaim = structuredClone(claim) as MemberInvitationClaim + const changedProof = { ...proof } - // 👨🏻‍🦲 Bob shows up to join the team & sees 👳🏽‍♂️ Charlie. Bob shows Charlie his proof of invitation, and - // 👳🏽‍♂️ Charlie checks it against the invitation that Alice posted on the signature chain. - const validationResult = validate(proofOfInvitation, invitation) + switch (field) { + case 'member encryption key': + changedClaim.userKeys.encryption = randomKey() + break + case 'member signature key': + changedClaim.userKeys.signature = randomKey() + break + case 'device encryption key': + changedClaim.device.keys.encryption = randomKey() + break + case 'device signature key': + changedClaim.device.keys.signature = randomKey() + break + case 'user name': + changedClaim.userName = 'mallory' + break + case 'invitation kind': + ;(changedClaim as { invitationKind: string }).invitationKind = 'device' + break + case 'acceptor nonce': + changedProof.acceptorNonce = randomKey() + break + case 'invitee nonce': + changedProof.inviteeNonce = randomKey() + break + } - // ✅ - expect(validationResult.isValid).toBe(true) + expect(validate(changedProof, invitation, changedClaim).isValid).toBe(false) }) - test('you have to have the secret key to accept an invitation', () => { - // 👩🏾 Alice uses a secret key to create an invitation; she sends it to Bob via a trusted side channel - const seed = 'passw0rd' + test('rejects replay against a second request nonce', () => { + const { invitation, claim, proof } = fixture() - // And uses it to create an invitation for him - const invitation = create({ seed }) + expect(validate(proof, invitation, claim, randomKey()).isValid).toBe(false) + }) - // 🦹‍♀️ Eve tries to accept the invitation in Bob's place, but she doesn't have the correct invitation key - const proofOfInvitation = generateProof('horsebatterycorrectstaple') + test('rejects unknown versions and extra or missing proof fields', () => { + const { invitation, claim, proof } = fixture() + const unknownVersion = { ...proof, version: 3 } as unknown as ProofOfInvitationV2 + const extraField = { ...proof, extra: true } + const { signature: _signature, ...missingField } = proof - // ❌ Nice try, Eve!!! - const validationResult = validate(proofOfInvitation, invitation) - expect(validationResult.isValid).toBe(false) + expect(validate(unknownVersion, invitation, claim).isValid).toBe(false) + expect(validate(extraField, invitation, claim).isValid).toBe(false) + expect( + validate(missingField as unknown as ProofOfInvitationV2, invitation, claim) + ).toMatchObject({ isValid: false }) }) }) + +const fixture = () => { + const seed = 'passw0rd' + const invitation = create({ seed }) + const user = createUser('bob') + const device = createDevice({ userId: user.userId, deviceName: 'laptop' }) + const claim: MemberInvitationClaim = { + invitationKind: 'member', + userName: user.userName, + userKeys: redactKeys(user.keys), + device: redactDevice(device), + } + const proof = generateProof({ seed, claim, ...nonces() }) + return { seed, invitation, claim, proof } +} + +const nonces = () => ({ + acceptorNonce: randomKey(), + inviteeNonce: randomKey(), +}) diff --git a/packages/auth/src/invitation/types.ts b/packages/auth/src/invitation/types.ts index a745a8e81..fabe7d78e 100644 --- a/packages/auth/src/invitation/types.ts +++ b/packages/auth/src/invitation/types.ts @@ -1,46 +1,79 @@ -import { type Base58, type UnixTimestamp } from '@localfirst/crdx' +import type { Base58, Keyset, UnixTimestamp } from '@localfirst/crdx' +import type { Device, FirstUseDevice } from 'device/index.js' -/** - * The public record of the invitation that Alice adds to the signature chain after inviting Bob - * (or, that Bob's laptop adds after inviting Bob's phone). - * */ -export type Invitation = { +export type InvitationKind = 'member' | 'device' + +type InvitationBase = { /** Public, unique identifier for the invitation */ id: Base58 - /** The public signing key derived from the secret invitation key */ - publicKey: Base58 - /** Time when the invitation expires. If 0, the invitation does not expire. */ expiration: UnixTimestamp /** Number of times the invitation can be used. If 0, the invitation can be used any number of times. */ maxUses: number - /** (Device invitations only) User name the device will be associated with. */ + /** (Device invitations only) User id the device will be associated with. */ userId?: string } -/** - * The current state of the invitation; appears in the Team state. These properties are populated - * by the reducer. - * */ +/** Legacy invitation record. It remains readable but is not accepted without an explicit legacy mode. */ +export type InvitationV1 = InvitationBase & { + version?: 1 + publicKey: Base58 +} + +/** Invitation record for the transcript-bound admission protocol. */ +export type InvitationV2 = InvitationBase & { + version: 2 + signaturePublicKey: Base58 + encryptionPublicKey: Base58 +} + +/** Public invitation record added to the authenticated team graph. */ +export type Invitation = InvitationV1 | InvitationV2 + +/** Current reduced state of an invitation. */ export type InvitationState = { + /** Whether this invitation admits a new member or a new device. Derived from its graph action. */ + kind: InvitationKind + /** Number of times the invitation has been used */ uses: number - /** If true, this invitation was revoked at some point after it was created (but before it was used) */ + /** Whether this invitation was revoked after creation. */ revoked: boolean } & Invitation -/** - * The document an invitee presents the first time they connect to an admin, to prove that they've - * been invited. - * */ -export type ProofOfInvitation = { - /** Public, unique identifier for the invitation */ +export type MemberInvitationClaim = { + invitationKind: 'member' + userName: string + userKeys: Keyset + device: Device +} + +export type DeviceInvitationClaim = { + invitationKind: 'device' + userName: string + device: FirstUseDevice + userKeys?: never +} + +/** Identity fields cryptographically bound to an invitation proof. */ +export type InvitationClaim = MemberInvitationClaim | DeviceInvitationClaim + +export type ProofOfInvitationV1 = { + version?: 1 id: Base58 + signature: Base58 +} - /** Signature of userId and invitation id, using the private signing key derived from the secret invitation key */ +export type ProofOfInvitationV2 = { + version: 2 + id: Base58 + acceptorNonce: Base58 + inviteeNonce: Base58 signature: Base58 } + +export type ProofOfInvitation = ProofOfInvitationV1 | ProofOfInvitationV2 diff --git a/packages/auth/src/invitation/validate.ts b/packages/auth/src/invitation/validate.ts index 842af6326..213d9007f 100644 --- a/packages/auth/src/invitation/validate.ts +++ b/packages/auth/src/invitation/validate.ts @@ -1,48 +1,123 @@ -import { memoize } from '@localfirst/shared' -import { signatures } from '@localfirst/crypto' -import { type Invitation, type InvitationState, type ProofOfInvitation } from 'invitation/types.js' -import { VALID, type ValidationResult } from 'util/index.js' +import { signatures, type Base58 } from '@localfirst/crypto' +import type { + Invitation, + InvitationClaim, + InvitationState, + ProofOfInvitation, + ProofOfInvitationV2, +} from 'invitation/types.js' +import { KeyType, VALID, type ValidationResult } from 'util/index.js' +import { invitationProofPayload } from './invitationProofPayload.js' +/** + * Checks whether an invitation is active and has remaining uses at `timeOfUse`. + * + * A zero expiration or maximum-use count means that limit is disabled. + */ export const invitationCanBeUsed = (invitation: InvitationState, timeOfUse: number) => { const { revoked, maxUses, uses, expiration } = invitation - if (revoked) { - return fail('The invitation has been revoked') - } + if (revoked) return fail('The invitation has been revoked') + if (maxUses > 0 && uses >= maxUses) return fail('The invitation cannot be used again') + if (expiration > 0 && expiration < timeOfUse) return fail('The invitation has expired') + return VALID +} - if (maxUses > 0 && uses >= maxUses) { - return fail('The invitation cannot be used again') +/** + * Validates a version-2 invitation proof against its authenticated invitation record and exact + * identity claim. + * + * When `expectedAcceptorNonce` is supplied, the proof must have been created for that connection + * handshake. Legacy proofs and invitation records fail closed and must be reissued. + */ +export const validate = ( + proof: ProofOfInvitation, + invitation: Invitation, + claim: InvitationClaim, + expectedAcceptorNonce?: Base58 +): ValidationResult => { + if (invitation.version !== 2) { + return fail('Legacy invitations are disabled and must be reissued') + } + if (!isV2Proof(proof)) { + return fail('Invitation proof must use protocol version 2') + } + if (!hasExactKeys(proof, ['version', 'id', 'acceptorNonce', 'inviteeNonce', 'signature'])) { + return fail('Invitation proof has extra or missing fields') } + if (proof.id !== invitation.id) { + return fail("IDs don't match", { proof, invitation }) + } + if (expectedAcceptorNonce && proof.acceptorNonce !== expectedAcceptorNonce) { + return fail('Invitation proof was created for a different handshake') + } + + const claimValidation = validateClaim(claim) + if (!claimValidation.isValid) return claimValidation - if (expiration > 0 && expiration < timeOfUse) { - return fail('The invitation has expired') + const signatureIsValid = signatures.verify({ + payload: invitationProofPayload(proof, claim), + signature: proof.signature, + publicKey: invitation.signaturePublicKey, + }) + if (!signatureIsValid) { + return fail('Signature provided is not valid', { proof, invitation }) } return VALID } -export const validate = memoize( - (proof: ProofOfInvitation, invitation: Invitation): ValidationResult => { - const { id, signature } = proof +const isV2Proof = (proof: ProofOfInvitation): proof is ProofOfInvitationV2 => proof.version === 2 - // Check that id from proof matches invitation - if (id !== invitation.id) { - return fail("IDs don't match", { proof, invitation }) - } +const validateClaim = (claim: InvitationClaim): ValidationResult => { + const memberClaim = claim.invitationKind === 'member' + const claimKeys = memberClaim + ? ['invitationKind', 'userName', 'userKeys', 'device'] + : ['invitationKind', 'userName', 'device'] + if (!hasExactKeys(claim, claimKeys)) { + return fail('Invitation claim has extra or missing fields') + } - // Check signature on proof against public key from invitation - const { publicKey } = invitation - const signatureIsValid = signatures.verify({ - payload: { id }, - signature, - publicKey, - }) - if (!signatureIsValid) { - return fail('Signature provided is not valid', { proof, invitation }) - } + const deviceKeys = claim.device.keys + if ( + !hasExactKeys(deviceKeys, ['type', 'name', 'generation', 'encryption', 'signature']) || + deviceKeys.type !== KeyType.DEVICE || + deviceKeys.name !== claim.device.deviceId + ) { + return fail('Device key metadata does not match the claimed device') + } - return VALID + const deviceFields = ['keys', 'deviceId', 'deviceName'] + if ('created' in claim.device) deviceFields.push('created') + if ('deviceInfo' in claim.device) deviceFields.push('deviceInfo') + if (memberClaim) deviceFields.push('userId') + if (!hasExactKeys(claim.device, deviceFields)) { + return fail('Invitation device has extra or missing fields') + } + + if (memberClaim) { + const { userKeys, device } = claim + if ( + !hasExactKeys(userKeys, ['type', 'name', 'generation', 'encryption', 'signature']) || + userKeys.type !== KeyType.USER || + userKeys.generation !== 0 || + device.userId !== userKeys.name + ) { + return fail('Member identity claim is internally inconsistent') + } + } else if ('userId' in claim.device) { + return fail('A device invitation claim must not supply its owner') } -) + + return VALID +} + +const hasExactKeys = (value: object, expected: string[]) => { + const actual = Object.keys(value).sort() + return ( + actual.length === expected.length && + actual.every((key, index) => key === [...expected].sort()[index]) + ) +} export const fail = (message: string, details?: any) => ({ @@ -52,9 +127,8 @@ export const fail = (message: string, details?: any) => export class InvitationValidationError extends Error { constructor(message: string, details?: any) { - super() + super(message) this.name = 'Invitation validation failed' - this.message = message this.details = details } diff --git a/packages/auth/src/team/Team.ts b/packages/auth/src/team/Team.ts index 207d5bbfb..e1d218918 100644 --- a/packages/auth/src/team/Team.ts +++ b/packages/auth/src/team/Team.ts @@ -14,6 +14,7 @@ import type { import { createKeyset, createStore, + getChildMap, getLatestGeneration, isKeyset, redactKeys, @@ -25,7 +26,12 @@ import { type Challenge } from 'connection/types.js' import * as devices from 'device/index.js' import { redactDevice, type Device } from 'device/index.js' import * as invitations from 'invitation/index.js' -import { type ProofOfInvitation } from 'invitation/index.js' +import { + type InvitationClaim, + type InvitationKind, + type ProofOfInvitation, + type ProofOfInvitationV2, +} from 'invitation/index.js' import { normalize } from 'invitation/normalize.js' import * as lockbox from 'lockbox/index.js' import { AddRoleInput, ADMIN, type Role } from 'role/index.js' @@ -33,7 +39,10 @@ import { castServer } from 'server/castServer.js' import { type Host, type Server } from 'server/types.js' import { type LocalUserContext } from 'team/context.js' import { KeyType, Optional, VALID, scopesMatch } from 'util/index.js' +import { consumeAuthenticatedTeamGraph } from './authenticatedTeamGraph.js' import { ADMIN_SCOPE, ALL, TEAM_SCOPE, initialState } from './constants.js' +import { decryptTeamGraph } from './decryptTeamGraph.js' +import { getEvaluatedTeamGraph } from './evaluatedTeamGraph.js' import { membershipResolver as resolver } from './membershipResolver.js' import { redactUser } from './redactUser.js' import { reducer } from './reducer.js' @@ -94,7 +103,11 @@ export class Team extends EventEmitter { const { device, user } = this.context const moduleName = `auth:team:${this.userName}` - this.logger = new Logger({ moduleName, sharedLogger: options.sharedLogger, extendSharedLogger: true }) + this.logger = new Logger({ + moduleName, + sharedLogger: options.sharedLogger, + extendSharedLogger: true, + }) this.logger.debug('loading team') // Initialize a CRDX store for the team @@ -131,13 +144,24 @@ export class Team extends EventEmitter { rootPayload, keys: options.teamKeys, logger: this.logger, - }) + }) const metadata: TeamMetadata = options.metadata ?? { - selfAssignableRoles: [] + selfAssignableRoles: [], } - this.dispatch({ type: 'SET_METADATA', payload: { metadata }}, options.teamKeys) + this.dispatch({ type: 'SET_METADATA', payload: { metadata } }, options.teamKeys) } else { this.logger.debug('loading existing team') + const machineResult = getEvaluatedTeamGraph(options) + const graph = + machineResult === undefined + ? maybeDeserialize(options.source, options.teamKeyring) + : machineResult.graph + if (machineResult !== undefined) { + assert( + options.source === machineResult.graph, + 'Machine result must belong to the supplied team graph.' + ) + } // Rehydrate a team from an existing graph // Create CRDX store this.store = createStore({ @@ -145,9 +169,10 @@ export class Team extends EventEmitter { reducer, resolver, initialState, - graph: maybeDeserialize(options.source, options.teamKeyring), + graph, keys: options.teamKeyring, logger: this.logger, + machineResult, }) } @@ -213,18 +238,32 @@ export class Team extends EventEmitter { public save = () => serializeTeamGraph(this.graph) /** - * Merges another graph (e.g. from a peer) with ours. + * Merges another graph after reconstructing its plaintext from authenticated ciphertext unless it + * carries the internal one-shot sync capability. Graph validation, team reduction, and state + * derivation must all succeed before the graph and state are committed together. + * * @returns This `Team` instance. */ public merge = (theirGraph: TeamGraph) => { - this.store.merge(theirGraph) + const authenticatedGraph = consumeAuthenticatedTeamGraph(theirGraph) + ? theirGraph + : decryptTeamGraph({ + encryptedGraph: { ...theirGraph, childMap: getChildMap(theirGraph) }, + teamKeys: this.teamKeyring(), + deviceKeys: this.context.device.keys, + extendableLogger: this.logger, + }) + this.store.merge(authenticatedGraph) this.state = this.store.getState() this.emit('updated', { head: this.graph.head }) return this } - /** Add a link to the graph, then recompute team state from the new graph */ + /** + * Adds a locally authored link and commits graph and team state together only after application + * validation succeeds. + */ public dispatch(action: TeamAction, teamKeys: KeysetWithSecrets = this.teamKeys()) { this.store.dispatch(action, teamKeys) this.state = this.store.getState() @@ -242,11 +281,14 @@ export class Team extends EventEmitter { public members(userId: string, options?: LookupOptions): Member // Overload: one member public members(userIds: string[], options?: LookupOptions): Member[] // Overload: one member // - public members(userIdOrIds: string | string[] = ALL, options = { includeRemoved: true, throwOnMissing: true }): Member | Member[] { + public members( + userIdOrIds: string | string[] = ALL, + options = { includeRemoved: true, throwOnMissing: true } + ): Member | Member[] { if (typeof userIdOrIds === 'string') { return userIdOrIds === ALL // - ? this.state.members // All members - : select.member(this.state, userIdOrIds, options) // One member + ? this.state.members // All members + : select.member(this.state, userIdOrIds, options) // One member } return select.members(this.state, userIdOrIds, options) // Many members @@ -361,7 +403,9 @@ export class Team extends EventEmitter { // if we choose to add ourselves to the role we need to create our own lockbox and then dispatch // the event to add the role to our member record - this._dispatchAddMemberRole(this.userId, role.roleName, [lockbox.create(roleKeys, this.context.user.keys)]) + this._dispatchAddMemberRole(this.userId, role.roleName, [ + lockbox.create(roleKeys, this.context.user.keys), + ]) } /** Remove a role from the team */ @@ -396,7 +440,9 @@ export class Team extends EventEmitter { // Make a lockbox for the role const member = this.members(userId) const allGenKeys = this.roleKeysAllGenerations(roleName, decryptionKeys) - const lockboxRoleKeysForMember = allGenKeys.map(roleKeys => lockbox.create(roleKeys, member.keys)) + const lockboxRoleKeysForMember = allGenKeys.map(roleKeys => + lockbox.create(roleKeys, member.keys) + ) // Post the member role to the graph this._dispatchAddMemberRole(userId, roleName, lockboxRoleKeysForMember) @@ -404,7 +450,10 @@ export class Team extends EventEmitter { /** Give yourself a role */ public addMemberRoleToSelf = (roleName: string, decryptionKeys: KeysetWithSecrets) => { - assert(this.state.metadata.selfAssignableRoles.includes(roleName), `Cannot self-assign role ${roleName}`) + assert( + this.state.metadata.selfAssignableRoles.includes(roleName), + `Cannot self-assign role ${roleName}` + ) this.addMemberRole(this.userId, roleName, decryptionKeys) } @@ -426,7 +475,10 @@ export class Team extends EventEmitter { } /** Check if member is priveleged enough to perform a specific action */ - private _memberHasPrivelegeToPerformAction(memberId: string, actionType: TeamAction['type']): boolean { + private _memberHasPrivelegeToPerformAction( + memberId: string, + actionType: TeamAction['type'] + ): boolean { if (!isAdminOnlyActionType(actionType)) { return true } @@ -465,7 +517,7 @@ export class Team extends EventEmitter { if (!this.memberHasRole(memberId, roleName)) { return false } - return this._isRoleRemovable(roleName, false) + return this._isRoleRemovable(roleName, false) } /** ************** DEVICES */ @@ -521,21 +573,14 @@ export class Team extends EventEmitter { /** ************** INVITATIONS */ /** - * To invite a new member: + * Creates and posts a version-2 member invitation, returning the normalized secret seed for the + * invitee and the full immutable team root that the invitee must trust independently. * - * Alice generates an invitation using a secret seed. The seed an be randomly generated, or - * selected by Alice. Alice sends the invitation to Bob using a trusted channel. + * The invitee later signs its exact user/device claim and both handshake nonces. An existing member + * verifies that proof, appends an `ADMIT_MEMBER` containing the same proof and claim so every + * replica can revalidate possession, and returns the admitted graph in an encrypted acceptance. * - * Meanwhile, Alice adds Bob to the graph as a new member, with appropriate roles (if - * any) and any corresponding lockboxes. - * - * Bob can't authenticate directly as that member, since it has random temporary keys created by - * Alice. Instead, Bob generates a proof of invitation, and when they try to connect to Alice or - * Charlie they present that proof instead of authenticating. - * - * Once Alice or Charlie verifies Bob's proof, they send him the team graph. Bob uses that to - * instantiate the team, then he updates the team with his real public keys and adds his current - * device information. + * @returns The invitation ID, normalized secret seed, and full team root. */ public inviteMember({ seed = invitations.randomSeed(), @@ -564,22 +609,20 @@ export class Team extends EventEmitter { payload: { invitation }, }) - // Return the secret invitation seed (to pass on to invitee) and the invitation id (which could be used to revoke later) - return { id, seed } + // Return the normalized secret, revocation ID, and immutable team-root trust anchor. + return { id, seed, teamId: this.id } } /** - * To invite an existing member's device: - * - * On his laptop, Bob generates an invitation using a secret seed. He gets that seed to his phone - * using a QR code or by typing it in. + * Creates and posts a single-use version-2 device invitation for the current member, including a + * lockbox that lets the first-use device recover that member's keys. * - * On his phone, Bob connects to his laptop (or to Alice or Charlie). Bob's phone presents its - * proof of invitation. + * The invited device signs its exact first-use device claim and both handshake nonces. An existing + * member verifies the proof, appends an `ADMIT_DEVICE` containing that proof and claim, and returns + * an encrypted acceptance. The invitee verifies the admission and full expected team root before + * joining. * - * Once an existing device (Bob's laptop or Alice or Charlie) verifies Bob's phone's proof, they - * send it the team graph. Using the graph, the phone instantiates the team, then adds itself as - * a device. + * @returns The invitation ID, normalized secret seed, and full team root. */ public inviteDevice({ seed = invitations.randomSeed(), @@ -616,8 +659,8 @@ export class Team extends EventEmitter { }, }) - // Return the secret invitation seed (to pass on to invitee) and the invitation id (which could be used to revoke later) - return { id, seed } + // Return the normalized secret, revocation ID, and immutable team-root trust anchor. + return { id, seed, teamId: this.id } } /** Revoke an invitation. */ @@ -637,35 +680,64 @@ export class Team extends EventEmitter { /** Gets the invitation corresponding to the given id. If it does not exist, throws an error. */ public getInvitation = (id: Base58) => select.getInvitation(this.state, id) - /** Check whether (1) the invitation is still valid, and (2) the proof of invitation checks out. */ - public validateInvitation = (proof: ProofOfInvitation) => { + /** + * Validates invitation usability, kind, and a version-2 signature over the exact identity claim. + * When supplied, `expectedAcceptorNonce` also binds the proof to the current handshake. Legacy + * proofs fail closed. + */ + public validateInvitation = ( + proof: ProofOfInvitation, + expectedKind: InvitationKind, + claim: InvitationClaim, + expectedAcceptorNonce?: Base58 + ) => { const { id } = proof if (!this.hasInvitation(id)) return invitations.fail("This invitation code doesn't match.") const invitation = this.getInvitation(id) + if (invitation.kind !== expectedKind) { + return invitations.fail(`This ${invitation.kind} invitation cannot admit a ${expectedKind}.`) + } + if (claim.invitationKind !== expectedKind) { + return invitations.fail('Invitation claim kind does not match the requested admission.') + } // Make sure the invitation hasn't already been used, hasn't expired, and hasn't been revoked const canBeUsedResult = invitations.invitationCanBeUsed(invitation, Date.now()) if (canBeUsedResult !== VALID) return canBeUsedResult // Validate the proof of invitation - return invitations.validate(proof, invitation) + return invitations.validate(proof, invitation, claim, expectedAcceptorNonce) } public invitations(): InvitationMap { return select.invitations(this.state) } - /** An existing team member calls this to admit a new member & their device to the team based on proof of invitation */ + /** + * Admits a member only after reconstructing the exact signed claim and validating its version-2 + * proof, kind, usability, and optional acceptor nonce. The dispatched `ADMIT_MEMBER` persists that + * proof and claim so every replica can independently revalidate invitation possession. + */ public admitMember = ( proof: ProofOfInvitation, memberKeys: Keyset | KeysetWithSecrets, // We accept KeysetWithSecrets here to simplify testing - in practice we'll only receive Keyset - userName: string // The new member's desired user-facing name + userName: string, // The new member's desired user-facing name + device: Device, + expectedAcceptorNonce?: Base58 ) => { - const validation = this.validateInvitation(proof) + const publicMemberKeys = redactKeys(memberKeys) + const claim: InvitationClaim = { + invitationKind: 'member', + userName, + userKeys: publicMemberKeys, + device, + } + const validation = this.validateInvitation(proof, 'member', claim, expectedAcceptorNonce) if (!validation.isValid) throw validation.error + assert(proof.version === 2, 'Invitation proof must use protocol version 2') - const { id } = proof + const { id } = proof as ProofOfInvitationV2 // we know the team keys, so we can put them in a lockbox for the new member now (even if we're not an admin) const lockboxTeamKeysForMember = lockbox.create(this.teamKeys(), memberKeys) @@ -676,20 +748,39 @@ export class Team extends EventEmitter { payload: { id, userName, - memberKeys: redactKeys(memberKeys), + memberKeys: publicMemberKeys, + proof, + claim, lockboxes: [lockboxTeamKeysForMember], }, }) } - /** An existing team member calls this to admit a new device based on proof of invitation */ - public admitDevice = (proof: ProofOfInvitation, firstUseDevice: devices.FirstUseDevice) => { - const validation = this.validateInvitation(proof) + /** + * Admits a first-use device only after reconstructing the exact signed claim and validating its + * version-2 proof, kind, usability, and optional acceptor nonce. Device ownership comes from the + * authenticated invitation, and `ADMIT_DEVICE` persists the proof and claim for replicated + * validation. + */ + public admitDevice = ( + proof: ProofOfInvitation, + firstUseDevice: devices.FirstUseDevice, + userName: string, + expectedAcceptorNonce?: Base58 + ) => { + const claim: InvitationClaim = { + invitationKind: 'device', + userName, + device: firstUseDevice, + } + const validation = this.validateInvitation(proof, 'device', claim, expectedAcceptorNonce) if (!validation.isValid) throw validation.error + assert(proof.version === 2, 'Invitation proof must use protocol version 2') - const { id } = proof + const { id } = proof as ProofOfInvitationV2 const invitation = this.getInvitation(id) - const userId = invitation.userId! + const userId = invitation.userId + assert(userId, 'A device invitation must identify its owner.') // Now we can add the userId to the device and post it to the graph const device: Device = { ...firstUseDevice, userId } @@ -700,6 +791,8 @@ export class Team extends EventEmitter { payload: { id, device, + proof, + claim, }, }) } @@ -714,17 +807,28 @@ 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', - payload: { - device: redactDevice(device), - lockboxes: [lockboxUserKeysForDevice], + if (this.hasDevice(device.deviceId)) { + this.logger.debug('Adding joined device lockbox') + this.dispatch( + { + type: 'ADD_LOCKBOXES', + payload: { lockboxes: [lockboxUserKeysForDevice] }, }, - }, - teamKeys - ) + teamKeys + ) + } else { + this.logger.debug('Adding device on join') + this.dispatch( + { + type: 'ADD_DEVICE', + payload: { + device: redactDevice(device), + lockboxes: [lockboxUserKeysForDevice], + }, + }, + teamKeys + ) + } } /** ************** SERVERS */ @@ -824,7 +928,7 @@ export class Team extends EventEmitter { const { secretKey } = this.keys(message.recipient) return symmetric.decryptBytes(message.contents, secretKey) } - + /** * Symmetrically encrypt a byte stream for the given scope using keys available to the current user. * @@ -832,20 +936,27 @@ export class Team extends EventEmitter { * encrypt for scopes the current user has keys for (e.g. the whole team, or roles they belong * to). If we need to encrypt asymmetrically, we use the functions in the crypto module directly. */ - public encryptStream = (stream: AsyncIterable, roleName?: string): EncryptStreamTeamPayload => { + public encryptStream = ( + stream: AsyncIterable, + roleName?: string + ): EncryptStreamTeamPayload => { const scope = roleName ? { type: KeyType.ROLE, name: roleName } : TEAM_SCOPE const { secretKey, generation } = this.keys(scope) - + const { header, encryptStream } = symmetric.encryptBytesStream(stream, secretKey) return { header, encryptStream, - recipient: { ...scope, generation } + recipient: { ...scope, generation }, } } /** Decrypt a byte stream using keys available to the current user and a header generated during encryption. */ - public decryptStream = (encryptedStream: AsyncIterable, header: Uint8Array, recipient: KeyMetadata): AsyncGenerator => { + public decryptStream = ( + encryptedStream: AsyncIterable, + header: Uint8Array, + recipient: KeyMetadata + ): AsyncGenerator => { const { secretKey } = this.keys(recipient) return symmetric.decryptBytesStream(encryptedStream, header, secretKey) } @@ -888,11 +999,15 @@ 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, decryptionKeys: KeysetWithSecrets = this.context.device.keys) => - select.keys(this.state, decryptionKeys, scope) + public keys = ( + scope: KeyMetadata | KeyScope, + decryptionKeys: KeysetWithSecrets = this.context.device.keys + ) => select.keys(this.state, decryptionKeys, scope) - public keysAllGenerations = (scope: KeyMetadata | KeyScope, decryptionKeys: KeysetWithSecrets = this.context.device.keys) => - select.keysAllGen(this.state, decryptionKeys, scope) + public keysAllGenerations = ( + scope: KeyMetadata | KeyScope, + decryptionKeys: KeysetWithSecrets = this.context.device.keys + ) => select.keysAllGen(this.state, decryptionKeys, scope) public allKeys = (decryptionKeys: KeysetWithSecrets = this.context.device.keys) => select.allKeys(this.state, decryptionKeys) @@ -908,7 +1023,10 @@ export class Team extends EventEmitter { /** Returns the current team keys or a specific generation of team keys */ public teamKeys = (generation?: number) => this.keys({ ...TEAM_SCOPE, generation }) - public teamKeyring = () => select.teamKeyring(this.state, this.context.device.keys) + public teamKeyring = () => ({ + ...this.store.getKeyring(), + ...select.teamKeyring(this.state, this.context.device.keys), + }) /** Returns the admin keyset. */ public adminKeys = (generation?: number) => this.roleKeys(ADMIN, generation) @@ -944,15 +1062,18 @@ export class Team extends EventEmitter { /** * 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[] => { + public createLockbox = ( + roleName: string, + encryptionKeys: KeysetWithSecrets + ): lockbox.Lockbox[] => { const roleKeys = this.roleKeysAllGenerations(roleName) - const lockboxes = roleKeys.map((keys) => lockbox.create(keys, encryptionKeys)) - this.dispatch({ type: 'ADD_LOCKBOXES', payload: { lockboxes }}) + const lockboxes = roleKeys.map(keys => lockbox.create(keys, encryptionKeys)) + this.dispatch({ type: 'ADD_LOCKBOXES', payload: { lockboxes } }) return lockboxes } diff --git a/packages/auth/src/team/authenticatedTeamGraph.ts b/packages/auth/src/team/authenticatedTeamGraph.ts new file mode 100644 index 000000000..8e7007a30 --- /dev/null +++ b/packages/auth/src/team/authenticatedTeamGraph.ts @@ -0,0 +1,16 @@ +import type { TeamGraph } from './types.js' + +const authenticatedGraphs = new WeakSet() + +/** Internal one-shot capability for a graph authenticated during the current sync pass. */ +export const markTeamGraphAuthenticated = (graph: TeamGraph): TeamGraph => { + authenticatedGraphs.add(graph) + return graph +} + +/** Consumes the capability so it cannot be retained and reused after the graph changes. */ +export const consumeAuthenticatedTeamGraph = (graph: TeamGraph): boolean => { + const isAuthenticated = authenticatedGraphs.has(graph) + authenticatedGraphs.delete(graph) + return isAuthenticated +} diff --git a/packages/auth/src/team/constants.ts b/packages/auth/src/team/constants.ts index 3d322358d..59dfeaf69 100644 --- a/packages/auth/src/team/constants.ts +++ b/packages/auth/src/team/constants.ts @@ -18,6 +18,7 @@ export const initialState: TeamState = { removedDevices: [], removedServers: [], pendingKeyRotations: [], + retiredAuthorKeys: [], metadata: { selfAssignableRoles: [] }, } diff --git a/packages/auth/src/team/decryptTeamGraph.ts b/packages/auth/src/team/decryptTeamGraph.ts index a7ade5fe6..75bbe0d0b 100644 --- a/packages/auth/src/team/decryptTeamGraph.ts +++ b/packages/auth/src/team/decryptTeamGraph.ts @@ -1,22 +1,7 @@ -import { - type Hash, - createKeyring, - decryptLink, - type Keyring, - type KeysetWithSecrets, - type MaybePartlyDecryptedGraph, -} from '@localfirst/crdx' -import { initialState, TEAM_SCOPE } from './constants.js' -import { reducer } from './reducer.js' -import { keys } from './selectors/index.js' -import { - type TeamAction, - type TeamContext, - type TeamGraph, - type TeamLink, - type TeamState, -} from './types.js' -import { Logger } from '@localfirst/shared' +import type { Keyring, KeysetWithSecrets, MaybePartlyDecryptedGraph } from '@localfirst/crdx' +import type { Logger } from '@localfirst/shared' +import { decryptTeamGraphCore } from './decryptTeamGraphCore.js' +import type { TeamAction, TeamContext, TeamGraph } from './types.js' /** * Decrypts a graph. @@ -25,8 +10,8 @@ import { Logger } from '@localfirst/shared' * peer, we can't just use a single set of team keys to decrypt everything, because there might be * key rotations in links that we receive that we will need to decrypt subsequent links. When that * happens, each team member gets the new keys in a lockbox that's stored on the chain. So we need - * to recurse through the chain, updating the keys if necessary before continuing to decrypt - * further. + * to iteratively traverse each path, updating the keys if necessary before continuing. The core + * traversal rejects cycles and missing links and applies a bounded work limit. */ export const decryptTeamGraph = ({ encryptedGraph, @@ -36,75 +21,11 @@ export const decryptTeamGraph = ({ }: { encryptedGraph: MaybePartlyDecryptedGraph - /** - * We need the first-generation team keys to get started. If the team keys have been rotated, we - * will find them in lockboxes that we can get to with our device keys. - */ + /** First-generation or retained team keys used to begin decryption. */ teamKeys: KeysetWithSecrets | KeysetWithSecrets[] | Keyring - /** - * We need our device keys so that we can get the latest team keys from the graph if they've been - * rotated. - */ + /** Device keys used to open rotated team keys found in graph lockboxes. */ deviceKeys: KeysetWithSecrets extendableLogger?: Logger -}): TeamGraph => { - const logger = extendableLogger != null ? extendableLogger.extend('decryptTeamGraph') : new Logger({ moduleName: 'auth:decryptTeamGraph' }) - const keyring = createKeyring(teamKeys) - - const { encryptedLinks, childMap, root } = encryptedGraph - - // ignore coverage - const links = encryptedGraph.links ?? {} - - /** Recursively decrypts a link and its children. */ - const decrypt = ( - hash: Hash, - previousKeys: KeysetWithSecrets, - previousDecryptedLinks: Record = {}, - previousState: TeamState = initialState - ): Record => { - // Decrypt this link - const encryptedLink = encryptedLinks[hash] - const decryptedLink = - links[hash] ?? // If it's already decrypted, don't bother decrypting it again - decryptLink(encryptedLink, previousKeys) - let decryptedLinks = { - [hash]: decryptedLink, - } - - // Reduce & see if there are new team keys - const newState = reducer(previousState, decryptedLink, logger) - let newKeys: KeysetWithSecrets | undefined - try { - newKeys = keys(newState, deviceKeys, TEAM_SCOPE) - keyring[newKeys.encryption.publicKey] = newKeys - } catch { - newKeys = previousKeys - } - - // Decrypt its children - const children = childMap![hash] - - if (children) { - for (const hash of children) { - decryptedLinks = { - ...decryptedLinks, - ...decrypt(hash, newKeys, decryptedLinks, newState), - } - } - } - - return { ...previousDecryptedLinks, ...decryptedLinks } - } - - const rootPublicKey = encryptedLinks[root].recipientPublicKey - const rootKeys = keyring[rootPublicKey] - const decryptedLinks = decrypt(root, rootKeys) - - return { - ...encryptedGraph, - links: decryptedLinks, - } -} +}): TeamGraph => decryptTeamGraphCore({ encryptedGraph, teamKeys, deviceKeys, extendableLogger }) diff --git a/packages/auth/src/team/decryptTeamGraphCore.ts b/packages/auth/src/team/decryptTeamGraphCore.ts new file mode 100644 index 000000000..406aba06a --- /dev/null +++ b/packages/auth/src/team/decryptTeamGraphCore.ts @@ -0,0 +1,127 @@ +import { + type Hash, + createKeyring, + decryptLink, + type Keyring, + type KeysetWithSecrets, + type MaybePartlyDecryptedGraph, +} from '@localfirst/crdx' +import { Logger } from '@localfirst/shared' +import { initialState, TEAM_SCOPE } from './constants.js' +import { reducer } from './reducer.js' +import { keys } from './selectors/index.js' +import type { TeamAction, TeamContext, TeamGraph, TeamLink, TeamState } from './types.js' + +export type DecryptTeamGraphCoreOptions = { + /** Encrypted graph and child topology to traverse. */ + encryptedGraph: MaybePartlyDecryptedGraph + + /** First-generation or retained team keys used to begin decryption. */ + teamKeys: KeysetWithSecrets | KeysetWithSecrets[] | Keyring + + /** Device keys used to open rotated team keys discovered in graph lockboxes. */ + deviceKeys: KeysetWithSecrets + + /** + * Locally accepted graph whose plaintext may be reused only when the encrypted-link object is + * identical. Public callers must not supply this capability. + */ + trustedGraph?: TeamGraph + extendableLogger?: Logger + + /** Maximum number of enter steps before aborting. Defaults to 50,000. */ + maxTraversalSteps?: number +} + +/** + * Decrypts a team graph with iterative, per-path state reduction so newly discovered rotation keys + * are available to descendants. Rejects cycles, missing links, and traversal-limit exhaustion. + */ +export const decryptTeamGraphCore = ({ + encryptedGraph, + teamKeys, + deviceKeys, + trustedGraph, + extendableLogger, + maxTraversalSteps = 50_000, +}: DecryptTeamGraphCoreOptions): TeamGraph => { + const logger = + extendableLogger !== undefined + ? extendableLogger.extend('decryptTeamGraph') + : new Logger({ moduleName: 'auth:decryptTeamGraph' }) + const keyring = createKeyring(teamKeys) + + const { encryptedLinks, childMap, root } = encryptedGraph + const decryptedByHash: Record = {} + + type TraversalFrame = + | { phase: 'enter'; hash: Hash; previousKeys: KeysetWithSecrets; previousState: TeamState } + | { phase: 'exit'; hash: Hash } + + const rootPublicKey = encryptedLinks[root].recipientPublicKey + const rootKeys = keyring[rootPublicKey] + const activePath = new Set() + const stack: TraversalFrame[] = [ + { phase: 'enter', hash: root, previousKeys: rootKeys, previousState: initialState }, + ] + let traversalSteps = 0 + + while (stack.length > 0) { + const frame = stack.pop()! + if (frame.phase === 'exit') { + activePath.delete(frame.hash) + continue + } + if (++traversalSteps > maxTraversalSteps) { + throw new Error('Team graph decryption exceeded its traversal limit') + } + + const { hash, previousKeys, previousState } = frame + if (activePath.has(hash)) { + throw new Error(`Team graph decryption encountered a cycle at '${hash}'`) + } + activePath.add(hash) + stack.push({ phase: 'exit', hash }) + + const encryptedLink = encryptedLinks[hash] + if (encryptedLink === undefined) { + throw new Error(`Team graph decryption is missing link '${hash}'`) + } + const decryptionKeys = keyring[encryptedLink.recipientPublicKey] ?? previousKeys + const trustedLink = + trustedGraph?.encryptedLinks[hash] === encryptedLink ? trustedGraph.links[hash] : undefined + const decryptedLink = + trustedLink ?? + decryptedByHash[hash] ?? + decryptLink(encryptedLink, decryptionKeys) + decryptedByHash[hash] = decryptedLink + + // Reduce along every traversal path to preserve key-discovery behavior at graph joins. + const newState = reducer(previousState, decryptedLink, logger) + let newKeys: KeysetWithSecrets | undefined + try { + newKeys = keys(newState, deviceKeys, TEAM_SCOPE) + keyring[newKeys.encryption.publicKey] = newKeys + } catch { + newKeys = previousKeys + } + + const children = childMap![hash] + if (children) { + for (const childHash of [...children].reverse()) { + if (encryptedLinks[childHash] === undefined) continue + stack.push({ + phase: 'enter', + hash: childHash, + previousKeys: newKeys, + previousState: newState, + }) + } + } + } + + return { + ...encryptedGraph, + links: decryptedByHash, + } +} diff --git a/packages/auth/src/team/decryptTrustedTeamGraph.ts b/packages/auth/src/team/decryptTrustedTeamGraph.ts new file mode 100644 index 000000000..8b6cd7a93 --- /dev/null +++ b/packages/auth/src/team/decryptTrustedTeamGraph.ts @@ -0,0 +1,12 @@ +import { decryptTeamGraphCore } from './decryptTeamGraphCore.js' +import type { TeamGraph } from './types.js' + +type Options = Parameters[0] + +/** + * Internal sync-only path for bounded graph decryption that may reuse plaintext from the live, + * locally accepted Team graph when the corresponding encrypted-link object is unchanged. + */ +export const decryptTrustedTeamGraph = ( + options: Omit & { trustedGraph: TeamGraph } +): TeamGraph => decryptTeamGraphCore(options) diff --git a/packages/auth/src/team/evaluatedTeamGraph.ts b/packages/auth/src/team/evaluatedTeamGraph.ts new file mode 100644 index 000000000..06e28b968 --- /dev/null +++ b/packages/auth/src/team/evaluatedTeamGraph.ts @@ -0,0 +1,21 @@ +import type { MachineResult } from '@localfirst/crdx' +import type { ExistingTeamOptions, TeamAction, TeamContext, TeamState } from './types.js' + +const EVALUATED_TEAM_GRAPH = Symbol('evaluated-team-graph') + +type EvaluatedTeamOptions = ExistingTeamOptions & { + [EVALUATED_TEAM_GRAPH]?: MachineResult +} + +/** Internal capability for reusing a graph result that already passed the exact team machine. */ +export const withEvaluatedTeamGraph = ( + options: T, + machineResult: MachineResult +): T => { + Object.defineProperty(options, EVALUATED_TEAM_GRAPH, { value: machineResult }) + return options +} + +/** Returns the internal one-shot machine result attached to existing-team construction options. */ +export const getEvaluatedTeamGraph = (options: ExistingTeamOptions) => + (options as EvaluatedTeamOptions)[EVALUATED_TEAM_GRAPH] diff --git a/packages/auth/src/team/isAdminOnlyAction.ts b/packages/auth/src/team/isAdminOnlyAction.ts index 97813f5d6..30bef0560 100644 --- a/packages/auth/src/team/isAdminOnlyAction.ts +++ b/packages/auth/src/team/isAdminOnlyAction.ts @@ -1,6 +1,15 @@ +import { ADMIN } from 'role/index.js' import { type TeamAction, type TeamLinkBody } from './types.js' +/** + * Returns whether an action requires an administrator. Assigning the administrator role is always + * admin-only even though other `ADD_MEMBER_ROLE` actions may be self-service. + */ export const isAdminOnlyAction = (action: TeamLinkBody) => { + if (action.type === 'ADD_MEMBER_ROLE' && action.payload.roleName === ADMIN) { + return true + } + return isAdminOnlyActionType(action.type) } diff --git a/packages/auth/src/team/reducer.ts b/packages/auth/src/team/reducer.ts index c28d408bb..1756637ae 100644 --- a/packages/auth/src/team/reducer.ts +++ b/packages/auth/src/team/reducer.ts @@ -1,4 +1,4 @@ -import { ROOT, type Reducer } from '@localfirst/crdx' +import { ROOT, type Hash, type Reducer } from '@localfirst/crdx' import { ADMIN } from 'role/index.js' import { clone, composeTransforms } from 'util/index.js' import { invalidLinkReducer } from './invalidLinkReducer.js' @@ -47,9 +47,21 @@ import { Logger } from '@localfirst/shared' * * @param state The team state as of the previous link in the signature chain. * @param link The current link being processed. + * @param extendableLogger Optional logger inherited from the machine evaluation. + * @param graph Complete authenticated graph used for causal-frontier author-key validation. It may + * be omitted during provisional branch-by-branch decryption; final machine reduction always + * supplies it. */ -export const reducer: Reducer = (state, link, extendableLogger) => { - const logger = extendableLogger != null ? extendableLogger.extend('reducer') : new Logger({ moduleName: 'auth:reducer' }) +export const reducer: Reducer = ( + state, + link, + extendableLogger, + graph +) => { + const logger = + extendableLogger != null + ? extendableLogger.extend('reducer') + : new Logger({ moduleName: 'auth:reducer' }) // Invalid links are marked to be discarded by the MembershipResolver due to conflicting // concurrent actions. In most cases we just ignore these links and they don't affect state at // all; but in some cases we need to clean up, for example when someone's admission is reversed @@ -62,7 +74,7 @@ export const reducer: Reducer = (state, link state = clone(state) // Make sure this link can be applied to the previous state & doesn't put us in an invalid state - const validation = validate(state, link, logger) + const validation = validate(state, link, logger, graph) if (!validation.isValid) { throw validation.error } @@ -74,7 +86,7 @@ export const reducer: Reducer = (state, link const applyTransforms = composeTransforms([ setHead(link), collectLockboxes(action.payload.lockboxes), // Any payload can include lockboxes - ...getTransforms(action), // Get the specific transforms indicated by this action + ...getTransforms(action, link.hash), // Get the specific transforms indicated by this action ]) const newState = applyTransforms(state) @@ -86,7 +98,12 @@ export const reducer: Reducer = (state, link * new state). This returns an array of transforms that are then applied in order. * @param action The team action (type + payload) being processed */ -const getTransforms = (action: TeamAction): Transform[] => { +/** + * Maps an action to state transforms. + * + * `linkHash` is recorded as the causal retirement frontier for member/server key changes. + */ +const getTransforms = (action: TeamAction, linkHash: Hash): Transform[] => { switch (action.type) { case ROOT: { const { name, rootMember, rootDevice } = action.payload @@ -159,14 +176,14 @@ const getTransforms = (action: TeamAction): Transform[] => { case 'INVITE_MEMBER': { const { invitation } = action.payload return [ - postInvitation(invitation), // Add the invitation to the list of open invitations. + postInvitation(invitation, 'member'), // Derive kind from the authenticated graph action. ] } case 'INVITE_DEVICE': { const { invitation } = action.payload return [ - postInvitation(invitation), // Add the invitation to the list of open invitations. + postInvitation(invitation, 'device'), // Derive kind from the authenticated graph action. ] } @@ -206,7 +223,7 @@ const getTransforms = (action: TeamAction): Transform[] => { case 'CHANGE_MEMBER_KEYS': { const { keys } = action.payload return [ - changeMemberKeys(keys), // Replace this member's public keys with the ones provided + changeMemberKeys(keys, linkHash), // Replace this member's public keys with the ones provided ] } @@ -234,7 +251,7 @@ const getTransforms = (action: TeamAction): Transform[] => { case 'CHANGE_SERVER_KEYS': { const { keys } = action.payload return [ - changeServerKeys(keys), // Replace this server's public keys with the ones provided + changeServerKeys(keys, linkHash), // Replace this server's public keys with the ones provided ] } @@ -254,14 +271,12 @@ const getTransforms = (action: TeamAction): Transform[] => { case 'ADD_LOCKBOXES': { // Note: lockboxes are handled by default so we don't need to do anything special here - return [(state) => state] + return [state => state] } case 'SET_METADATA': { const { metadata } = action.payload - return [ - setMetadata(metadata) - ] + return [setMetadata(metadata)] } default: { diff --git a/packages/auth/src/team/selectors/device.ts b/packages/auth/src/team/selectors/device.ts index 14c6b8773..60966869f 100644 --- a/packages/auth/src/team/selectors/device.ts +++ b/packages/auth/src/team/selectors/device.ts @@ -4,28 +4,40 @@ import { server } from './server.js' import { hasServer } from './hasServer.js' import { castServer } from 'server/castServer.js' +/** Returns whether exactly one matching device exists; throws when the ID is ambiguous. */ export const hasDevice = ( state: TeamState, deviceId: string, options = { includeRemoved: false } ) => { - return getDevice(state, deviceId, options) !== undefined + return getDevices(state, deviceId, options).length === 1 } +/** + * Returns the unique member device or server projection for `deviceId`. Removed identities are + * included only when requested; missing and ambiguous IDs throw. + */ export const device = (state: TeamState, deviceId: string, options = { includeRemoved: false }) => { - const device = getDevice(state, deviceId, options) - assert(device, `Device ${deviceId} not found`) - return device + const matchingDevices = getDevices(state, deviceId, options) + assert(matchingDevices.length > 0, `Device ${deviceId} not found`) + assert(matchingDevices.length === 1, `Device ID '${deviceId}' is ambiguous`) + return matchingDevices[0] } -const getDevice = (state: TeamState, deviceId: string, options = { includeRemoved: false }) => { - if (hasServer(state, deviceId)) { - return castServer.toDevice(server(state, deviceId)) - } +const getDevices = (state: TeamState, deviceId: string, options = { includeRemoved: false }) => { + const matchingServers = hasServer(state, deviceId, options) + ? [castServer.toDevice(server(state, deviceId, options))] + : [] const members = state.members.concat(options.includeRemoved ? state.removedMembers : []) - const allDevices = members.flatMap(m => m.devices ?? []) - return ( - allDevices.find(d => d.deviceId === deviceId) ?? - (options.includeRemoved ? state.removedDevices.find(d => d.deviceId === deviceId) : undefined) - ) + const memberDevices = members + .flatMap(member => member.devices ?? []) + .filter(device => device.deviceId === deviceId) + const removedDevices = options.includeRemoved + ? state.removedDevices.filter(device => device.deviceId === deviceId) + : [] + const matchingDevices = [...matchingServers, ...memberDevices, ...removedDevices] + if (matchingDevices.length > 1) { + throw new Error(`Device ID '${deviceId}' is ambiguous`) + } + return matchingDevices } diff --git a/packages/auth/src/team/selectors/hasMember.ts b/packages/auth/src/team/selectors/hasMember.ts index 3343c80dc..05346cf72 100644 --- a/packages/auth/src/team/selectors/hasMember.ts +++ b/packages/auth/src/team/selectors/hasMember.ts @@ -1,4 +1,10 @@ import { type TeamState } from 'team/types.js' -export const hasMember = (state: TeamState, userId: string) => - state.members.find(m => m.userId === userId) !== undefined +/** Returns whether exactly one active member has `userId`; throws when the ID is ambiguous. */ +export const hasMember = (state: TeamState, userId: string) => { + const matchingMembers = state.members.filter(member => member.userId === userId) + if (matchingMembers.length > 1) { + throw new Error(`Member ID '${userId}' is ambiguous`) + } + return matchingMembers.length === 1 +} diff --git a/packages/auth/src/team/selectors/hasServer.ts b/packages/auth/src/team/selectors/hasServer.ts index 3ac1ab8e9..4a081d76d 100644 --- a/packages/auth/src/team/selectors/hasServer.ts +++ b/packages/auth/src/team/selectors/hasServer.ts @@ -1,5 +1,17 @@ import { type Host } from 'server/index.js' import { type TeamState } from 'team/types.js' -export const hasServer = (state: TeamState, host: Host) => - state.servers.find(s => s.host === host) !== undefined +/** + * Returns whether exactly one server has `host`; optionally includes removed servers and throws when + * the host is ambiguous. + */ +export const hasServer = (state: TeamState, host: Host, options = { includeRemoved: false }) => { + const matchingServers = [ + ...state.servers, + ...(options.includeRemoved ? state.removedServers : []), + ].filter(server => server.host === host) + if (matchingServers.length > 1) { + throw new Error(`Server host '${host}' is ambiguous`) + } + return matchingServers.length === 1 +} diff --git a/packages/auth/src/team/selectors/member.ts b/packages/auth/src/team/selectors/member.ts index 64d5c87aa..166da0033 100644 --- a/packages/auth/src/team/selectors/member.ts +++ b/packages/auth/src/team/selectors/member.ts @@ -1,20 +1,32 @@ import { type TeamState } from 'team/types.js' +/** Returns the unique member for `userId`; optionally includes removed members and throws on ambiguity. */ export const member = (state: TeamState, userId: string, options = { includeRemoved: false }) => { const membersToSearch = [ ...state.members, ...(options.includeRemoved ? state.removedMembers : []), ] - const member = membersToSearch.find(m => m.userId === userId) + const matchingMembers = membersToSearch.filter(m => m.userId === userId) - if (member === undefined) { + if (matchingMembers.length === 0) { throw new Error(`A member named '${userId}' was not found`) } + if (matchingMembers.length > 1) { + throw new Error(`Member ID '${userId}' is ambiguous`) + } - return member + return matchingMembers[0] } -export const members = (state: TeamState, userIds: string[], options = { includeRemoved: false, throwOnMissing: true }) => { +/** + * Returns members matching the requested IDs. Removed members are opt-in; missing IDs throw unless + * `throwOnMissing` is false. + */ +export const members = ( + state: TeamState, + userIds: string[], + options = { includeRemoved: false, throwOnMissing: true } +) => { const membersToSearch = [ ...state.members, ...(options.includeRemoved ? state.removedMembers : []), diff --git a/packages/auth/src/team/selectors/memberByDeviceId.ts b/packages/auth/src/team/selectors/memberByDeviceId.ts index 0931089d1..c8d4f8f9b 100644 --- a/packages/auth/src/team/selectors/memberByDeviceId.ts +++ b/packages/auth/src/team/selectors/memberByDeviceId.ts @@ -2,12 +2,18 @@ import { castServer } from 'server/castServer.js' import type { TeamState } from '../index.js' import { member, device, server, hasServer } from './index.js' +/** + * Resolves a unique device ID to its member, or projects a uniquely matching server as a member. + * Missing or ambiguous identities throw. + */ export const memberByDeviceId = ( state: TeamState, deviceId: string, options = { includeRemoved: false } ) => { - if (hasServer(state, deviceId)) return castServer.toMember(server(state, deviceId)) const { userId } = device(state, deviceId, options) + if (hasServer(state, deviceId, options)) { + return castServer.toMember(server(state, deviceId, options)) + } return member(state, userId, options) } diff --git a/packages/auth/src/team/selectors/server.ts b/packages/auth/src/team/selectors/server.ts index e3759f0cc..ed0790e6c 100644 --- a/packages/auth/src/team/selectors/server.ts +++ b/packages/auth/src/team/selectors/server.ts @@ -1,16 +1,20 @@ import { type Host } from 'server/index.js' import { type TeamState } from 'team/types.js' +/** Returns the unique server for `host`; optionally includes removed servers and throws on ambiguity. */ export const server = (state: TeamState, host: Host, options = { includeRemoved: false }) => { const serversToSearch = [ ...state.servers, ...(options.includeRemoved ? state.removedServers : []), ] - const server = serversToSearch.find(s => s.host === host) + const matchingServers = serversToSearch.filter(server => server.host === host) - if (server === undefined) { + if (matchingServers.length === 0) { throw new Error(`A server with host '${host}' was not found`) } + if (matchingServers.length > 1) { + throw new Error(`Server host '${host}' is ambiguous`) + } - return server + return matchingServers[0] } diff --git a/packages/auth/src/team/serialize.ts b/packages/auth/src/team/serialize.ts index 07c48e99f..bf9b14f21 100644 --- a/packages/auth/src/team/serialize.ts +++ b/packages/auth/src/team/serialize.ts @@ -26,10 +26,26 @@ export const deserializeTeamGraph = (serialized: Uint8Array, keys: Keyring): Tea return decryptGraph({ encryptedGraph, keys }) } +/** + * Loads a serialized graph or authenticates an in-memory graph for public use. Serialized sources + * are decrypted normally; in-memory plaintext `links` are ignored and reconstructed from encrypted + * links before validation or reduction. + */ export const maybeDeserialize = ( source: Uint8Array | TeamGraph, teamKeyring: Keyring -): TeamGraph => (isGraph(source) ? source : deserializeTeamGraph(source, teamKeyring)) +): TeamGraph => { + if (!isGraph(source)) { + return deserializeTeamGraph(source, teamKeyring) + } + + // A supplied Graph may contain attacker-controlled plaintext `links`. Reconstruct every link + // from its authenticated ciphertext before exposing it to validation or reduction. + return decryptGraph({ + encryptedGraph: { ...source, childMap: getChildMap(source) }, + keys: teamKeyring, + }) +} const isGraph = (source: Uint8Array | TeamGraph): source is TeamGraph => source?.hasOwnProperty('root') diff --git a/packages/auth/src/team/test/admissionProof.test.ts b/packages/auth/src/team/test/admissionProof.test.ts new file mode 100644 index 000000000..0c3bfece1 --- /dev/null +++ b/packages/auth/src/team/test/admissionProof.test.ts @@ -0,0 +1,103 @@ +import { redactKeys } from '@localfirst/crdx' +import { signatures } from '@localfirst/crypto' +import { redactDevice } from 'device/index.js' +import { + deviceInvitationProof, + memberInvitationProof, + redactFirstUseDevice, + setup, +} from 'util/testing/index.js' +import { describe, expect, it } from 'vitest' + +describe('replicated invitation proofs', () => { + it('rejects a raw ADMIT_MEMBER without a valid proof for its claim', () => { + const { alice, bob, eve } = setup( + 'alice', + { user: 'bob', member: false }, + 'eve' + ) + const { id, seed } = alice.team.inviteMember() + const claim = { + invitationKind: 'member' as const, + userName: bob.userName, + userKeys: redactKeys(bob.user.keys), + device: redactDevice(bob.device), + } + const validProof = memberInvitationProof(seed, bob.user, bob.device) + const forgedProof = { + ...validProof, + signature: signatures.sign(['forged'], eve.user.keys.signature.secretKey), + } + + expect(() => + eve.team.merge(alice.team.graph).dispatch({ + type: 'ADMIT_MEMBER', + payload: { + id, + userName: bob.userName, + memberKeys: redactKeys(bob.user.keys), + proof: forgedProof, + claim, + }, + }) + ).toThrow(/Admission does not contain a valid invitation proof/) + }) + + it('rejects a proof whose signed claim differs from the admitted identity', () => { + const { alice, bob, charlie } = setup( + 'alice', + { user: 'bob', member: false }, + { user: 'charlie', member: false } + ) + const { id, seed } = alice.team.inviteMember() + const proof = memberInvitationProof(seed, bob.user, bob.device) + const claim = { + invitationKind: 'member' as const, + userName: bob.userName, + userKeys: redactKeys(bob.user.keys), + device: redactDevice(bob.device), + } + + expect(() => + alice.team.dispatch({ + type: 'ADMIT_MEMBER', + payload: { + id, + userName: charlie.userName, + memberKeys: redactKeys(charlie.user.keys), + proof, + claim, + }, + }) + ).toThrow(/Admission identity does not match its signed invitation claim/) + }) + + it('rejects a raw ADMIT_DEVICE without a valid proof for its claim', () => { + const { alice, eve } = setup('alice', 'eve') + const invitedDevice = alice.phone! + const { id, seed } = alice.team.inviteDevice() + const firstUseDevice = redactFirstUseDevice(invitedDevice) + const claim = { + invitationKind: 'device' as const, + userName: alice.userName, + device: firstUseDevice, + } + const validProof = deviceInvitationProof(seed, alice.userName, invitedDevice) + const forgedProof = { + ...validProof, + signature: signatures.sign(['forged'], eve.user.keys.signature.secretKey), + } + + expect(() => + eve.team.merge(alice.team.graph).dispatch({ + type: 'ADMIT_DEVICE', + payload: { + id, + device: { ...firstUseDevice, userId: alice.userId }, + proof: forgedProof, + claim, + }, + }) + ).toThrow(/Admission does not contain a valid invitation proof/) + }) +}) diff --git a/packages/auth/src/team/test/authorAuthentication.test.ts b/packages/auth/src/team/test/authorAuthentication.test.ts new file mode 100644 index 000000000..18eefb43e --- /dev/null +++ b/packages/auth/src/team/test/authorAuthentication.test.ts @@ -0,0 +1,191 @@ +import { + append, + createKeyring, + createKeyset, + createUser, + merge, + redactKeys, + type UserWithSecrets, +} from '@localfirst/crdx' +import * as teams from 'team/index.js' +import type { TeamAction, TeamContext, TeamGraph } from 'team/types.js' +import { KeyType } from 'util/index.js' +import { setup } from 'util/testing/index.js' +import { describe, expect, it } from 'vitest' + +describe('team action author authentication', () => { + it("rejects an action that claims a member's identity but is encrypted by another key", () => { + const { alice, eve } = setup('alice', { user: 'eve', member: false }) + const forgedAuthor = { ...eve.user, userId: alice.userId } + const forgedGraph = append({ + graph: alice.team.graph, + action: { type: 'SET_TEAM_NAME', payload: { teamName: 'forged' } }, + user: forgedAuthor, + context: { deviceId: eve.device.deviceId }, + keys: alice.team.teamKeys(), + }) + + expect(() => + teams.load( + forgedGraph, + alice.localContext, + createKeyring(alice.team.teamKeys()) + ) + ).toThrow(/causal frontier/) + }) + + it('rejects an action from an unknown author', () => { + const { alice } = setup('alice') + const outsider = createUser('mallory') + const forgedGraph = append({ + graph: alice.team.graph, + action: { type: 'SET_TEAM_NAME', payload: { teamName: 'forged' } }, + user: outsider, + context: { deviceId: 'unknown-device' }, + keys: alice.team.teamKeys(), + }) + + expect(() => + teams.load( + forgedGraph, + alice.localContext, + createKeyring(alice.team.teamKeys()) + ) + ).toThrow(/unknown or ambiguous/) + }) + + it('rejects actions authored after the member was removed', () => { + const { alice, bob } = setup('alice', 'bob') + alice.team.remove(bob.userId) + const forgedGraph = append({ + graph: alice.team.graph, + action: { type: 'SET_METADATA', payload: { metadata: alice.team.state.metadata } }, + user: bob.user, + context: { deviceId: bob.device.deviceId }, + keys: alice.team.teamKeys(), + }) + + expect(() => + teams.load( + forgedGraph, + alice.localContext, + alice.team.teamKeyring() + ) + ).toThrow(/unknown or ambiguous/) + }) + + it('ignores caller-supplied plaintext when merging through the public API', () => { + const { alice, bob } = setup('alice', 'bob') + bob.team.setTeamName('Authenticated name') + const remoteGraph = bob.team.graph + const head = remoteGraph.head[0] + const forgedGraph = { + ...remoteGraph, + links: { + ...remoteGraph.links, + [head]: { + ...remoteGraph.links[head], + body: { ...remoteGraph.links[head].body, payload: { teamName: 'Forged name' } }, + }, + }, + } as TeamGraph + + alice.team.merge(forgedGraph) + + expect(alice.team.teamName).toBe('Authenticated name') + }) + + it.each(['before', 'after'] as const)( + 'accepts a concurrent pre-rotation action ordered %s the rotation', + order => { + const fixture = concurrentRotationFixture(order) + + expect(() => + teams.load( + fixture.graph, + fixture.localContext, + createKeyring(fixture.teamKeys) + ) + ).not.toThrow() + } + ) + + it('rejects an old-key action when the rotation is in its causal past', () => { + const fixture = concurrentRotationFixture('after') + const staleGraph = append({ + graph: fixture.rotationGraph, + action: { type: 'SET_TEAM_NAME', payload: { teamName: 'stale' } }, + user: fixture.oldAuthor, + context: { deviceId: fixture.localContext.device.deviceId }, + keys: fixture.teamKeys, + }) + + expect(() => + teams.load(staleGraph, fixture.localContext, createKeyring(fixture.teamKeys)) + ).toThrow(/causal frontier/) + }) + + it('accepts the rotated key after concurrent branches merge', () => { + const fixture = concurrentRotationFixture('after') + const mergedGraph = append({ + graph: fixture.graph, + action: { type: 'SET_TEAM_NAME', payload: { teamName: 'post-merge' } }, + user: { ...fixture.oldAuthor, keys: fixture.newKeys }, + context: { deviceId: fixture.localContext.device.deviceId }, + keys: fixture.teamKeys, + }) + + expect(() => + teams.load(mergedGraph, fixture.localContext, createKeyring(fixture.teamKeys)) + ).not.toThrow() + }) +}) + +const concurrentRotationFixture = (oldActionOrder: 'before' | 'after') => { + const { alice } = setup('alice') + const baseGraph = alice.team.graph + const teamKeys = alice.team.teamKeys() + const oldAuthor = structuredClone(alice.user) as UserWithSecrets + const newKeys = createKeyset({ type: KeyType.USER, name: alice.userId }) + newKeys.generation = oldAuthor.keys.generation + 1 + const rotationGraph = append({ + graph: baseGraph, + action: { + type: 'CHANGE_MEMBER_KEYS', + payload: { keys: redactKeys(newKeys) }, + }, + user: oldAuthor, + context: { deviceId: alice.device.deviceId }, + keys: teamKeys, + }) + const rotationHash = rotationGraph.head[0] + + let oldActionGraph: TeamGraph | undefined + for (let attempt = 0; attempt < 100; attempt++) { + const candidate = append({ + graph: baseGraph, + action: { + type: 'SET_METADATA', + payload: { metadata: { selfAssignableRoles: [`candidate-${attempt}`] } }, + }, + user: oldAuthor, + context: { deviceId: alice.device.deviceId }, + keys: teamKeys, + }) + const candidateIsBefore = candidate.head[0] < rotationHash + if (candidateIsBefore === (oldActionOrder === 'before')) { + oldActionGraph = candidate + break + } + } + if (oldActionGraph === undefined) throw new Error('Could not generate the requested hash order') + + return { + graph: merge(rotationGraph, oldActionGraph) as TeamGraph, + rotationGraph, + oldAuthor, + newKeys, + teamKeys, + localContext: alice.localContext, + } +} diff --git a/packages/auth/src/team/test/decryptTeamGraph.test.ts b/packages/auth/src/team/test/decryptTeamGraph.test.ts new file mode 100644 index 000000000..56d7e05d4 --- /dev/null +++ b/packages/auth/src/team/test/decryptTeamGraph.test.ts @@ -0,0 +1,136 @@ +import { asymmetric } from '@localfirst/crypto' +import { getChildMap } from '@localfirst/crdx' +import type { TeamGraph } from 'team/types.js' +import { setup } from 'util/testing/index.js' +import { describe, expect, it, vi } from 'vitest' +import { decryptTeamGraph } from '../decryptTeamGraph.js' +import { decryptTrustedTeamGraph } from '../decryptTrustedTeamGraph.js' + +describe('decryptTeamGraph trusted plaintext reuse', () => { + it('does not decrypt links retained from the exact trusted graph', () => { + const { alice } = setup('alice') + alice.team.setTeamName('Updated team') + const { graph } = alice.team + const decrypt = vi.spyOn(asymmetric, 'decryptBytes') + + try { + const decrypted = decryptTrustedTeamGraph({ + encryptedGraph: { ...graph, childMap: getChildMap(graph) }, + teamKeys: alice.team.teamKeyring(), + deviceKeys: alice.device.keys, + trustedGraph: graph, + }) + + expect(graphDecryptions(decrypt.mock.calls, graph)).toBe(0) + for (const hash of Object.keys(graph.links)) { + expect(decrypted.links[hash]).toBe(graph.links[hash]) + } + } finally { + decrypt.mockRestore() + } + }) + + it('decrypts a link whose encrypted object was replaced instead of trusting its plaintext', () => { + const { alice } = setup('alice') + alice.team.setTeamName('Authenticated name') + const trustedGraph = alice.team.graph + const [head] = trustedGraph.head + const encryptedGraph = { + ...trustedGraph, + childMap: getChildMap(trustedGraph), + encryptedLinks: { + ...trustedGraph.encryptedLinks, + [head]: { ...trustedGraph.encryptedLinks[head] }, + }, + links: { + ...trustedGraph.links, + [head]: { + ...trustedGraph.links[head], + body: { ...trustedGraph.links[head].body, payload: { teamName: 'Forged name' } }, + }, + }, + } + const decrypt = vi.spyOn(asymmetric, 'decryptBytes') + + try { + const decrypted = decryptTrustedTeamGraph({ + encryptedGraph, + teamKeys: alice.team.teamKeyring(), + deviceKeys: alice.device.keys, + trustedGraph, + }) + + expect(graphDecryptions(decrypt.mock.calls, trustedGraph)).toBe(1) + expect(decrypted.links[head].body).toEqual(trustedGraph.links[head].body) + expect(decrypted.links[head]).not.toBe(encryptedGraph.links[head]) + } finally { + decrypt.mockRestore() + } + }) + + it('does not accept a trusted graph smuggled into the public options object', () => { + const { alice } = setup('alice') + alice.team.setTeamName('Authenticated name') + const { graph } = alice.team + const decrypt = vi.spyOn(asymmetric, 'decryptBytes') + + try { + decryptTeamGraph({ + encryptedGraph: { ...graph, childMap: getChildMap(graph) }, + teamKeys: alice.team.teamKeyring(), + deviceKeys: alice.device.keys, + trustedGraph: graph, + } as Parameters[0]) + + expect(graphDecryptions(decrypt.mock.calls, graph)).toBe(Object.keys(graph.links).length) + } finally { + decrypt.mockRestore() + } + }) + + it('rejects cyclic child maps without recursive traversal', () => { + const { alice } = setup('alice') + const { graph } = alice.team + const childMap = getChildMap(graph) + + expect(() => + decryptTrustedTeamGraph({ + encryptedGraph: { + ...graph, + childMap: { ...childMap, [graph.root]: [...(childMap[graph.root] ?? []), graph.root] }, + }, + teamKeys: alice.team.teamKeyring(), + deviceKeys: alice.device.keys, + trustedGraph: graph, + }) + ).toThrow(/cycle/) + }) + + it('bounds trusted team graph traversal', () => { + const { alice } = setup('alice') + alice.team.setTeamName('Updated team') + const { graph } = alice.team + + expect(() => + decryptTrustedTeamGraph({ + encryptedGraph: { ...graph, childMap: getChildMap(graph) }, + teamKeys: alice.team.teamKeyring(), + deviceKeys: alice.device.keys, + trustedGraph: graph, + maxTraversalSteps: 1, + }) + ).toThrow(/traversal limit/) + }) +}) + +type DecryptCall = Parameters + +const graphDecryptions = (calls: DecryptCall[], graph: TeamGraph): number => { + const ciphertexts = Object.values(graph.encryptedLinks).map(link => link.encryptedBody) + return calls.filter(([options]) => + ciphertexts.some(ciphertext => bytesAreEqual(options.cipher, ciphertext)) + ).length +} + +const bytesAreEqual = (left: Uint8Array, right: Uint8Array): boolean => + left.length === right.length && left.every((byte, index) => byte === right[index]) diff --git a/packages/auth/src/team/test/identityInvariants.test.ts b/packages/auth/src/team/test/identityInvariants.test.ts new file mode 100644 index 000000000..0faa130b5 --- /dev/null +++ b/packages/auth/src/team/test/identityInvariants.test.ts @@ -0,0 +1,172 @@ +import { createKeyset, redactKeys } from '@localfirst/crdx' +import { createDevice, redactDevice } from 'device/index.js' +import { redactUser } from 'team/redactUser.js' +import * as select from 'team/selectors/index.js' +import type { TeamState } from 'team/types.js' +import { KeyType } from 'util/index.js' +import { setup } from 'util/testing/index.js' +import { describe, expect, it } from 'vitest' + +describe('team identity invariants', () => { + it('rejects a duplicate active member ID', () => { + const { alice, bob } = setup('alice', 'bob') + const duplicate = alice.team.members(bob.userId) + + expect(() => + alice.team.dispatch({ + type: 'ADD_MEMBER', + payload: { member: duplicate }, + }) + ).toThrow(/already in use/) + expect(alice.team.members().filter(member => member.userId === bob.userId)).toHaveLength(1) + }) + + it('rejects duplicate device IDs within one member and across members', () => { + const { alice, bob } = setup('alice', 'bob') + const aliceDevice = redactDevice(alice.device) + + expect(() => + alice.team.dispatch({ + type: 'ADD_DEVICE', + payload: { device: aliceDevice }, + }) + ).toThrow(/already in use/) + + expect(() => + alice.team.dispatch({ + type: 'ADD_DEVICE', + payload: { + device: { + ...aliceDevice, + userId: bob.userId, + }, + }, + }) + ).toThrow(/already in use/) + }) + + it('rejects collisions between device IDs and server hosts', () => { + const { alice } = setup('alice') + const { deviceId } = alice.device + + expect(() => + alice.team.addServer({ + host: deviceId, + keys: redactKeys(createKeyset({ type: KeyType.SERVER, name: deviceId })), + }) + ).toThrow(/already in use/) + + const host = 'sync.example.test' + alice.team.addServer({ + host, + keys: redactKeys(createKeyset({ type: KeyType.SERVER, name: host })), + }) + const collidingDevice = createDevice({ + userId: alice.userId, + deviceName: 'colliding-device', + }) + collidingDevice.deviceId = host + collidingDevice.keys.name = host + + expect(() => + alice.team.dispatch({ + type: 'ADD_DEVICE', + payload: { device: redactDevice(collidingDevice) }, + }) + ).toThrow(/already in use/) + }) + + it('rejects mismatched key names, types, and initial generations', () => { + const { alice, bob } = setup('alice', { user: 'bob', member: false }) + const member = redactUser(bob.user) + + expect(() => + alice.team.dispatch({ + type: 'ADD_MEMBER', + payload: { + member: { + ...member, + keys: { ...member.keys, name: 'not-bob' }, + }, + }, + }) + ).toThrow(/metadata/) + + const phone = redactDevice(alice.phone!) + expect(() => + alice.team.dispatch({ + type: 'ADD_DEVICE', + payload: { + device: { + ...phone, + keys: { + ...phone.keys, + type: KeyType.USER, + generation: 1, + }, + }, + }, + }) + ).toThrow(/metadata/) + }) + + it("rejects a non-admin ADD_DEVICE for another member's device", () => { + const { alice, bob } = setup('alice', { user: 'bob', admin: false }) + const alicePhone = redactDevice(alice.phone!) + + expect(() => + bob.team.dispatch({ + type: 'ADD_DEVICE', + payload: { device: alicePhone }, + }) + ).toThrow(/non-admin/) + expect(bob.team.hasDevice(alicePhone.deviceId)).toBe(false) + }) + + it('clears member and device tombstones on legitimate re-add', () => { + const { alice, bob } = setup('alice', 'bob') + alice.team.remove(bob.userId) + expect(alice.team.memberWasRemoved(bob.userId)).toBe(true) + + alice.team.addForTesting(bob.user, [], redactDevice(bob.device)) + expect(alice.team.members().filter(member => member.userId === bob.userId)).toHaveLength(1) + expect(alice.team.memberWasRemoved(bob.userId)).toBe(false) + + alice.team.removeDevice(bob.device.deviceId) + expect(alice.team.deviceWasRemoved(bob.device.deviceId)).toBe(true) + alice.team.addForTesting(bob.user, [], redactDevice(bob.device)) + expect(alice.team.hasDevice(bob.device.deviceId)).toBe(true) + expect(alice.team.deviceWasRemoved(bob.device.deviceId)).toBe(false) + }) + + it('makes singular selectors fail closed on legacy ambiguous state', () => { + const { alice, bob } = setup('alice', 'bob') + const bobMember = alice.team.members(bob.userId) + const duplicateMemberState: TeamState = { + ...alice.team.state, + members: [...alice.team.state.members, bobMember], + } + + expect(() => select.member(duplicateMemberState, bob.userId)).toThrow(/ambiguous/) + expect(() => select.hasMember(duplicateMemberState, bob.userId)).toThrow(/ambiguous/) + + const aliceMember = alice.team.members(alice.userId) + const aliceDevice = aliceMember.devices![0] + const duplicateDeviceState: TeamState = { + ...alice.team.state, + members: alice.team.state.members.map(member => + member.userId === alice.userId + ? { + ...member, + devices: [...(member.devices ?? []), { ...aliceDevice }], + } + : member + ), + } + + expect(() => select.device(duplicateDeviceState, aliceDevice.deviceId)).toThrow(/ambiguous/) + expect(() => select.memberByDeviceId(duplicateDeviceState, aliceDevice.deviceId)).toThrow( + /ambiguous/ + ) + }) +}) diff --git a/packages/auth/src/team/test/invitationKind.test.ts b/packages/auth/src/team/test/invitationKind.test.ts new file mode 100644 index 000000000..f2bfecf4d --- /dev/null +++ b/packages/auth/src/team/test/invitationKind.test.ts @@ -0,0 +1,98 @@ +import { redactKeys } from '@localfirst/crdx' +import { redactDevice } from 'device/index.js' +import { + deviceInvitationProof, + memberInvitationProof, + setup, +} from 'util/testing/index.js' +import { describe, expect, it } from 'vitest' + +describe('invitation kind', () => { + it('rejects a device invitation used for a member without consuming it', () => { + const { alice, bob } = setup('alice', { user: 'bob', member: false }) + const { id, seed } = alice.team.inviteDevice() + + expect(() => + alice.team.admitMember( + memberInvitationProof(seed, bob.user, bob.device), + bob.user.keys, + bob.userName, + redactDevice(bob.device) + ) + ).toThrow(/device invitation cannot admit a member/) + expect(alice.team.getInvitation(id).uses).toBe(0) + }) + + it('rejects a member invitation used for a device', () => { + const { alice, bob } = setup('alice', { user: 'bob', member: false }) + const { seed } = alice.team.inviteMember() + + expect(() => + alice.team.admitDevice( + deviceInvitationProof(seed, bob.userName, bob.device), + redactDevice(bob.device), + bob.userName + ) + ).toThrow(/member invitation cannot admit a device/) + }) + + it('rejects a forged ADMIT_MEMBER that references an INVITE_DEVICE action', () => { + const { alice, bob } = setup('alice', { user: 'bob', member: false }) + const { id, seed } = alice.team.inviteDevice() + const proof = memberInvitationProof(seed, bob.user, bob.device) + const claim = { + invitationKind: 'member' as const, + userName: bob.userName, + userKeys: redactKeys(bob.user.keys), + device: redactDevice(bob.device), + } + + expect(() => + alice.team.dispatch({ + type: 'ADMIT_MEMBER', + payload: { + id, + userName: bob.userName, + memberKeys: redactKeys(bob.user.keys), + proof, + claim, + }, + }) + ).toThrow(/device invitation cannot be used by ADMIT_MEMBER/) + expect(alice.team.getInvitation(id).uses).toBe(0) + }) + + it('rejects a forged ADMIT_DEVICE that references an INVITE_MEMBER action', () => { + const { alice, bob } = setup('alice', { user: 'bob', member: false }) + const { id, seed } = alice.team.inviteMember() + const proof = deviceInvitationProof(seed, bob.userName, bob.device) + const { userId: _userId, ...firstUseDevice } = redactDevice(bob.device) + const claim = { + invitationKind: 'device' as const, + userName: bob.userName, + device: firstUseDevice, + } + + expect(() => + alice.team.dispatch({ + type: 'ADMIT_DEVICE', + payload: { id, device: redactDevice(bob.device), proof, claim }, + }) + ).toThrow(/member invitation cannot be used by ADMIT_DEVICE/) + }) + + it('derives invitation kind from legacy action types without changing their payload', () => { + const { alice } = setup('alice') + const memberInvitation = alice.team.inviteMember() + const deviceInvitation = alice.team.inviteDevice() + const invitationLinks = Object.values(alice.team.graph.links).filter( + link => link.body.type === 'INVITE_MEMBER' || link.body.type === 'INVITE_DEVICE' + ) + + for (const link of invitationLinks) { + expect(link.body.payload.invitation).not.toHaveProperty('kind') + } + expect(alice.team.getInvitation(memberInvitation.id).kind).toBe('member') + expect(alice.team.getInvitation(deviceInvitation.id).kind).toBe('device') + }) +}) diff --git a/packages/auth/src/team/test/invitations.test.ts b/packages/auth/src/team/test/invitations.test.ts index a050eed20..360717d6a 100644 --- a/packages/auth/src/team/test/invitations.test.ts +++ b/packages/auth/src/team/test/invitations.test.ts @@ -1,10 +1,14 @@ import { createKeyset, type UnixTimestamp } from '@localfirst/crdx' import { signatures } from '@localfirst/crypto' -import { redactDevice, type FirstUseDevice } from 'index.js' -import { generateProof } from 'invitation/index.js' +import { createDevice, redactDevice, type FirstUseDevice } from 'index.js' import * as teams from 'team/index.js' import { KeyType } from 'util/index.js' -import { setup } from 'util/testing/index.js' +import { + deviceInvitationProof, + memberInvitationProof, + redactFirstUseDevice, + setup, +} from 'util/testing/index.js' import { describe, expect, it } from 'vitest' const { USER } = KeyType @@ -19,11 +23,16 @@ describe('Team', () => { const { seed } = alice.team.inviteMember() // 👨🏻‍🦲 Bob accepts the invitation - const proofOfInvitation = generateProof(seed) + const proofOfInvitation = memberInvitationProof(seed, bob.user, bob.device) // 👨🏻‍🦲 Bob shows 👩🏾 Alice his proof of invitation, and she lets him in, associating // him with the public keys he's provided - alice.team.admitMember(proofOfInvitation, bob.user.keys, bob.user.userName) + alice.team.admitMember( + proofOfInvitation, + bob.user.keys, + bob.user.userName, + redactDevice(bob.device) + ) // ✅ 👨🏻‍🦲 Bob is now on the team. Congratulations, Bob! expect(alice.team.has(bob.userId)).toBe(true) @@ -36,9 +45,14 @@ describe('Team', () => { const seed = 'passw0rd' alice.team.inviteMember({ seed }) - const proofOfInvitation = generateProof(seed) + const proofOfInvitation = memberInvitationProof(seed, bob.user, bob.device) - alice.team.admitMember(proofOfInvitation, bob.user.keys, bob.user.userName) + alice.team.admitMember( + proofOfInvitation, + bob.user.keys, + bob.user.userName, + redactDevice(bob.device) + ) // ✅ Still works expect(alice.team.has(bob.userId)).toBe(true) @@ -52,8 +66,17 @@ describe('Team', () => { alice.team.inviteMember({ seed }) // 👨🏻‍🦲 Bob accepts the invitation using a url-friendlier version of the key - const proofOfInvitation = generateProof('abc+def+ghi') - alice.team.admitMember(proofOfInvitation, bob.user.keys, bob.user.userName) + const proofOfInvitation = memberInvitationProof( + 'abc+def+ghi', + bob.user, + bob.device + ) + alice.team.admitMember( + proofOfInvitation, + bob.user.keys, + bob.user.userName, + redactDevice(bob.device) + ) // ✅ Bob is on the team expect(alice.team.has(bob.userId)).toBe(true) @@ -70,7 +93,7 @@ describe('Team', () => { const { seed } = alice.team.inviteMember() // 👳🏽‍♂️ Charlie accepts the invitation - const proofOfInvitation = generateProof(seed) + const proofOfInvitation = memberInvitationProof(seed, charlie.user, charlie.device) // Later, 👩🏾 Alice is no longer around, but 👨🏻‍🦲 Bob is online let persistedTeam = alice.team.save() @@ -80,7 +103,12 @@ describe('Team', () => { expect(bobsTeam.memberIsAdmin(bob.userId)).toBe(false) // 👳🏽‍♂️ Charlie shows 👨🏻‍🦲 Bob his proof of invitation - bobsTeam.admitMember(proofOfInvitation, charlie.user.keys, bob.user.userName) + bobsTeam.admitMember( + proofOfInvitation, + charlie.user.keys, + charlie.user.userName, + redactDevice(charlie.device) + ) // 👍👳🏽‍♂️ Charlie is now on the team expect(bobsTeam.has(charlie.userId)).toBe(true) @@ -97,8 +125,13 @@ describe('Team', () => { // 👩🏾 Alice invites 👨🏻‍🦲 Bob with a future expiration date const expiration = new Date(Date.UTC(2999, 12, 25)).valueOf() as UnixTimestamp // NOTE 👩‍🚀 this test will fail if run in the distant future const { seed } = alice.team.inviteMember({ expiration }) - const proofOfInvitation = generateProof(seed) - alice.team.admitMember(proofOfInvitation, bob.user.keys, bob.user.userName) + const proofOfInvitation = memberInvitationProof(seed, bob.user, bob.device) + alice.team.admitMember( + proofOfInvitation, + bob.user.keys, + bob.user.userName, + redactDevice(bob.device) + ) // ✅ 👨🏻‍🦲 Bob's invitation has not expired so he is on the team expect(alice.team.has(bob.userId)).toBe(true) @@ -110,10 +143,15 @@ describe('Team', () => { // A long time ago 👩🏾 Alice invited 👨🏻‍🦲 Bob const expiration = new Date(Date.UTC(2020, 12, 25)).valueOf() as UnixTimestamp const { seed } = alice.team.inviteMember({ expiration }) - const proofOfInvitation = generateProof(seed) + const proofOfInvitation = memberInvitationProof(seed, bob.user, bob.device) const tryToAdmitBob = () => { - alice.team.admitMember(proofOfInvitation, bob.user.keys, bob.user.userName) + alice.team.admitMember( + proofOfInvitation, + bob.user.keys, + bob.user.userName, + redactDevice(bob.device) + ) } // 👎 👨🏻‍🦲 Bob's invitation has expired so he can't get in @@ -133,12 +171,23 @@ describe('Team', () => { const { seed } = alice.team.inviteMember({ maxUses: 2 }) // 👨🏻‍🦲 Bob and 👳🏽‍♂️ Charlie both generate the same proof of invitation from the seed - const proofOfInvitation = generateProof(seed) + const bobProof = memberInvitationProof(seed, bob.user, bob.device) + const charlieProof = memberInvitationProof(seed, charlie.user, charlie.device) // 👩🏾 Alice admits them both - alice.team.admitMember(proofOfInvitation, bob.user.keys, bob.user.userName) - alice.team.admitMember(proofOfInvitation, charlie.user.keys, charlie.user.userName) + alice.team.admitMember( + bobProof, + bob.user.keys, + bob.user.userName, + redactDevice(bob.device) + ) + alice.team.admitMember( + charlieProof, + charlie.user.keys, + charlie.user.userName, + redactDevice(charlie.device) + ) // ✅ 👨🏻‍🦲 Bob and 👳🏽‍♂️ Charlie are both on the team expect(alice.team.has(bob.userId)).toBe(true) @@ -150,8 +199,6 @@ describe('Team', () => { // 👩🏾 Alice makes an invitation that anyone can use const { seed } = alice.team.inviteMember({ maxUses: 0 }) // No limit - const proofOfInvitation = generateProof(seed) - // A bunch of people use the same invitation and 👩🏾 Alice admits them all const invitees = ` amanda, bob, charlie, dwight, edwin, frida, gertrude, herbert, @@ -161,7 +208,15 @@ describe('Team', () => { .split(',') for (const userId of invitees) { const userKeys = createKeyset({ type: USER, name: userId }) - alice.team.admitMember(proofOfInvitation, userKeys, userId) + const device = createDevice({ userId, deviceName: 'laptop' }) + const user = { userName: userId, keys: userKeys } + const proofOfInvitation = memberInvitationProof(seed, user, device) + alice.team.admitMember( + proofOfInvitation, + userKeys, + userId, + redactDevice(device) + ) } // ✅ they're all on the team @@ -180,14 +235,25 @@ describe('Team', () => { const { seed } = alice.team.inviteMember({ maxUses: 1 }) // 👨🏻‍🦲 Bob and 👳🏽‍♂️ Charlie both generate the same proof of invitation from the seed - const proofOfInvitation = generateProof(seed) + const bobProof = memberInvitationProof(seed, bob.user, bob.device) + const charlieProof = memberInvitationProof(seed, charlie.user, charlie.device) const tryToAdmitBob = () => { - alice.team.admitMember(proofOfInvitation, bob.user.keys, bob.user.userName) + alice.team.admitMember( + bobProof, + bob.user.keys, + bob.user.userName, + redactDevice(bob.device) + ) } const tryToAdmitCharlie = () => { - alice.team.admitMember(proofOfInvitation, charlie.user.keys, charlie.user.userName) + alice.team.admitMember( + charlieProof, + charlie.user.keys, + charlie.user.userName, + redactDevice(charlie.device) + ) } // 👍 👨🏻‍🦲 Bob uses the invitation first and he gets in @@ -214,7 +280,7 @@ describe('Team', () => { const { seed, id } = alice.team.inviteMember() // 👳🏽‍♂️ Charlie accepts the invitation - const proofOfInvitation = generateProof(seed) + const proofOfInvitation = memberInvitationProof(seed, charlie.user, charlie.device) // 👩🏾 Alice changes her mind and revokes the invitation alice.team.revokeInvitation(id) @@ -225,7 +291,12 @@ describe('Team', () => { // 👳🏽‍♂️ Charlie shows 👨🏻‍🦲 Bob his proof of invitation const tryToAdmitCharlie = () => { - bob.team.admitMember(proofOfInvitation, charlie.user.keys, charlie.user.userName) + bob.team.admitMember( + proofOfInvitation, + charlie.user.keys, + charlie.user.userName, + redactDevice(charlie.device) + ) } // 👎 But the invitation is rejected because it was revoked @@ -240,21 +311,24 @@ describe('Team', () => { const { team } = alice // 👩🏾 Alice invites 👨🏻‍🦲 Bob by sending him a random secret key - const { seed: _seed } = alice.team.inviteMember() + const { seed } = alice.team.inviteMember() // 🦹‍♀️ Eve is a member of the group and she wants to hijack Bob's invitation for her // nefarious purposes. so she tries to create a proof of invitation. // She can get the id from the graph - const invitation = Object.values(team.state.invitations)[0] - const { id } = invitation - - const payload = { id } - const signature = signatures.sign(payload, eve.user.keys.signature.secretKey) - const badProof = { id, signature } + const proof = memberInvitationProof(seed, eve.user, eve.device) + const signature = signatures.sign(['invalid'], eve.user.keys.signature.secretKey) + const badProof = { ...proof, signature } // 🦹‍♀️ Eve shows 👩🏾 Alice her proof of invitation - const submitBadProof = () => team.admitMember(badProof, eve.user.keys, 'bob') + const submitBadProof = () => + team.admitMember( + badProof, + eve.user.keys, + eve.userName, + redactDevice(eve.device) + ) // 🦹‍♀️ GRRR I would've got away with it too, if it weren't for you meddling cryptographic algorithms! expect(submitBadProof).toThrow('Signature provided is not valid') @@ -274,10 +348,18 @@ describe('Team', () => { // 📱 Alice gets the seed to her phone, perhaps by typing it in or by scanning a QR code. // Alice's phone uses the seed to generate her starter keys and her proof of invitation - const proofOfInvitation = generateProof(seed) + const proofOfInvitation = deviceInvitationProof( + seed, + aliceLaptop.userName, + alicePhone + ) // 📱 Alice's phone connects with 💻 her laptop and presents the proof - aliceLaptop.team.admitDevice(proofOfInvitation, redactDevice(alicePhone)) + aliceLaptop.team.admitDevice( + proofOfInvitation, + redactFirstUseDevice(alicePhone), + aliceLaptop.userName + ) // 👍 The proof was good, so the laptop sends the phone the team's graph and keyring const serializedGraph = aliceLaptop.team.save() @@ -316,14 +398,22 @@ describe('Team', () => { // 📱 Alice gets the seed to her phone, perhaps by typing it in or by scanning a QR code. // Alice's phone uses the seed to generate her starter keys and her proof of invitation - const proofOfInvitation = generateProof(seed) + const proofOfInvitation = deviceInvitationProof( + seed, + alice.userName, + alice.phone! + ) // 👨🏻‍🦲 Bob syncs up with Alice const savedTeam = alice.team.save() bob.team = teams.load(savedTeam, bob.localContext, alice.team.teamKeys()) // 📱 Alice's phone connects with 👨🏻‍🦲 Bob and she presents the proof - bob.team.admitDevice(proofOfInvitation, redactDevice(alice.phone!)) + bob.team.admitDevice( + proofOfInvitation, + redactFirstUseDevice(alice.phone!), + alice.userName + ) }) it("won't accept proof of invitation with an invalid signature", () => { @@ -333,22 +423,23 @@ describe('Team', () => { expect(alice.team.members(alice.userId).devices).toHaveLength(1) // 💻 on her laptop, Alice generates an invitation for her phone - const _seed = alice.team.inviteDevice().seed + const seed = alice.team.inviteDevice().seed // 🦹‍♀️ Eve is a member of the group and she wants to hijack Alice's device invitation // for her nefarious purposes. so she tries to create a proof of invitation. // She can get the id from the graph - const invitation = Object.values(alice.team.state.invitations)[0] - const { id } = invitation - - const payload = { id } - const signature = signatures.sign(payload, eve.user.keys.signature.secretKey) - const badProof = { id, signature } + const proof = deviceInvitationProof(seed, eve.userName, eve.device) + const signature = signatures.sign(['invalid'], eve.user.keys.signature.secretKey) + const badProof = { ...proof, signature } // 🦹‍♀️ Eve shows 👩🏾 Alice her proof of invitation const submitBadProof = () => - alice.team.admitDevice(badProof, redactDevice(eve.device) as FirstUseDevice) + alice.team.admitDevice( + badProof, + redactFirstUseDevice(eve.device) as FirstUseDevice, + eve.userName + ) // 🦹‍♀️ GRRR I would've got away with it too, if it weren't for you meddling cryptographic algorithms! expect(submitBadProof).toThrow('Signature provided is not valid') diff --git a/packages/auth/src/team/test/membershipResolver.test.ts b/packages/auth/src/team/test/membershipResolver.test.ts index 35c08ff9d..6a360fb62 100644 --- a/packages/auth/src/team/test/membershipResolver.test.ts +++ b/packages/auth/src/team/test/membershipResolver.test.ts @@ -306,7 +306,10 @@ describe('membershipResolver', () => { } const summary = (graph: TeamGraph) => { - let result = graphSummary(graph).replaceAll('_MEMBER', '').replaceAll('_ROLE', '') + let result = graphSummary(graph) + .replace('ROOT,SET_METADATA:{"metadata":{"selfAssignableRoles":[]}}', 'ROOT') + .replaceAll('_MEMBER', '') + .replaceAll('_ROLE', '') for (const user of users) { result = result.replaceAll(user.userId, user.userName) } diff --git a/packages/auth/src/team/test/roles.test.ts b/packages/auth/src/team/test/roles.test.ts index a98c46007..a60b5cf02 100644 --- a/packages/auth/src/team/test/roles.test.ts +++ b/packages/auth/src/team/test/roles.test.ts @@ -47,7 +47,7 @@ describe('Team', () => { // 👩🏾 Alice adds 👨🏻‍🦲 Bob to the managers role alice.team.addMemberRole(bob.userId, MANAGERS) - expect(alice.team.membersInRole(MANAGERS).map(m => m.userName)).toEqual(['bob']) + expect(alice.team.membersInRole(MANAGERS).map(m => m.userName)).toEqual(['alice', 'bob']) }) it('admins have access to all role keys', () => { @@ -56,8 +56,8 @@ describe('Team', () => { // 👩🏾 Alice adds the managers role alice.team.addRole(managers) - // 👩🏾 Alice is not a member of the managers role - expect(alice.team.memberHasRole(alice.userId, MANAGERS)).toBe(false) + // 👩🏾 Alice is a member of the managers role because role creators are assigned automatically + expect(alice.team.memberHasRole(alice.userId, MANAGERS)).toBe(true) // But she does have access to the managers' keys const managersKeys = alice.team.roleKeys(MANAGERS) @@ -87,28 +87,16 @@ describe('Team', () => { expect(bobsAdminKeys).toLookLikeKeyset() }) - it('non-admin adds self to a role when creating', () => { + it('does not let a non-admin create a role', () => { const { alice, bob } = setup('alice', { user: 'bob', admin: false }) // 👨🏻‍🦲 Bob isn't an admin expect(alice.team.memberIsAdmin(bob.userId)).toBe(false) - // 👨🏻‍🦲 Bob adds a role and gives himself that role - bob.team.addRole(foobar) - - // Now 👨🏻‍🦲 Bob is a foobar - expect(bob.team.hasRole(FOOBAR)).toBe(true) - - // Bob persists the team - const savedTeam = bob.team.save() - - // 👩🏾 Alice loads the team - alice.team = teams.load(savedTeam, alice.localContext, bob.team.teamKeys()) - - // 👩🏾 Alice sees 👨🏻‍🦲 Bob has the foobar role - expect(alice.team.memberHasRole(bob.userId, FOOBAR)).toBe(true) - - // 👩🏾 Alice doesn't have the foobar role + // 👨🏻‍🦲 Bob cannot add a role or assign it to himself + expect(() => bob.team.addRole(foobar)).toThrow() + expect(bob.team.hasRole(FOOBAR)).toBe(false) + expect(bob.team.memberHasRole(bob.userId, FOOBAR)).toBe(false) expect(alice.team.memberHasRole(alice.userId, FOOBAR)).toBe(false) }) diff --git a/packages/auth/src/team/test/servers.test.ts b/packages/auth/src/team/test/servers.test.ts index 14e9e4d52..0b75d22b7 100644 --- a/packages/auth/src/team/test/servers.test.ts +++ b/packages/auth/src/team/test/servers.test.ts @@ -6,6 +6,7 @@ import { TestChannel, all, joinTestChannel, + memberInvitationProof, setup as setupHumans, type SetupConfig, type UserStuff, @@ -15,6 +16,7 @@ import { createTeam, invitation, loadTeam, + redactDevice, type Connection, type Context, type InviteeDeviceContext, @@ -172,7 +174,12 @@ describe('Team', () => { await connectWithServer(alice, server) // Now if Bob connects to the server, the server can admit him - server.team.admitMember(invitation.generateProof(bobInvite), bob.user.keys, bob.userId) + server.team.admitMember( + memberInvitationProof(bobInvite, bob.user, bob.device), + bob.user.keys, + bob.userName, + redactDevice(bob.device) + ) expect(server.team.members().length).toBe(2) }) @@ -227,6 +234,7 @@ describe('Team', () => { userName: bob.userName, device: bob.phone!, invitationSeed: seed, + expectedTeamId: bob.team.id, } const join = joinTestChannel(new TestChannel()) const serverConnection = join(server.connectionContext).start() diff --git a/packages/auth/src/team/transforms/addDevice.ts b/packages/auth/src/team/transforms/addDevice.ts index fd8783be4..fe62b9ecf 100644 --- a/packages/auth/src/team/transforms/addDevice.ts +++ b/packages/auth/src/team/transforms/addDevice.ts @@ -27,6 +27,6 @@ export const addDevice = }), // Remove device ID from list of removed devices (e.g. if it was removed at one point and is being re-added) - removedDevices: state.removedDevices.filter(d => d.keys.name === device.deviceId), + removedDevices: state.removedDevices.filter(d => d.keys.name !== device.deviceId), } } diff --git a/packages/auth/src/team/transforms/addMember.ts b/packages/auth/src/team/transforms/addMember.ts index ca3e39a39..0a07201f6 100644 --- a/packages/auth/src/team/transforms/addMember.ts +++ b/packages/auth/src/team/transforms/addMember.ts @@ -1,6 +1,7 @@ import { type Member } from 'team/index.js' import { type Transform } from 'team/types.js' +/** Adds or re-adds a member, clearing matching removal and retired-author-key history. */ export const addMember = (newMember: Member): Transform => state => ({ @@ -16,5 +17,6 @@ export const addMember = ], // Remove member's name from list of removed members (e.g. if member was removed and is now being re-added) - removedMembers: state.removedMembers.filter(m => m.userId === newMember.userId), + removedMembers: state.removedMembers.filter(m => m.userId !== newMember.userId), + retiredAuthorKeys: state.retiredAuthorKeys.filter(key => key.identityId !== newMember.userId), }) diff --git a/packages/auth/src/team/transforms/addServer.ts b/packages/auth/src/team/transforms/addServer.ts index 27e71ff57..2a5b9a529 100644 --- a/packages/auth/src/team/transforms/addServer.ts +++ b/packages/auth/src/team/transforms/addServer.ts @@ -2,6 +2,7 @@ import type { Server } from 'server/index.js' import type { TeamState, Transform } from 'team/types.js' import { unique } from 'util/unique.js' +/** Adds or re-adds a server, clearing matching removal and retired-author-key history. */ export const addServer = (newServer: Server): Transform => state => { @@ -13,6 +14,7 @@ export const addServer = // Remove server's url from list of removed servers (e.g. if server was removed and is now being re-added) removedServers: state.removedServers.filter(m => m.host !== newServer.host), + retiredAuthorKeys: state.retiredAuthorKeys.filter(key => key.identityId !== newServer.host), } return newState } diff --git a/packages/auth/src/team/transforms/changeMemberKeys.ts b/packages/auth/src/team/transforms/changeMemberKeys.ts index b5a551bf2..535978e59 100644 --- a/packages/auth/src/team/transforms/changeMemberKeys.ts +++ b/packages/auth/src/team/transforms/changeMemberKeys.ts @@ -1,16 +1,34 @@ -import { type Keyset } from '@localfirst/crdx' +import { type Hash, type Keyset } from '@localfirst/crdx' import { type Transform } from 'team/types.js' +/** + * Replaces a member's keys and retains the previous encryption key at `retiredAt`, allowing it only + * for actions concurrent with (not causally after) that key-change link. + */ export const changeMemberKeys = - (keys: Keyset): Transform => - state => ({ - ...state, - members: state.members.map(member => - member.userId === keys.name - ? { - ...member, - keys, // 🡐 replace keys with new ones - } - : member - ), - }) + (keys: Keyset, retiredAt: Hash): Transform => + state => { + const previousKeys = state.members.find(member => member.userId === keys.name)?.keys + return { + ...state, + members: state.members.map(member => + member.userId === keys.name + ? { + ...member, + keys, // 🡐 replace keys with new ones + } + : member + ), + retiredAuthorKeys: + previousKeys === undefined + ? state.retiredAuthorKeys + : [ + ...state.retiredAuthorKeys, + { + identityId: keys.name, + encryptionPublicKey: previousKeys.encryption, + retiredAt, + }, + ], + } + } diff --git a/packages/auth/src/team/transforms/changeServerKeys.ts b/packages/auth/src/team/transforms/changeServerKeys.ts index e8a706e52..f45f1781f 100644 --- a/packages/auth/src/team/transforms/changeServerKeys.ts +++ b/packages/auth/src/team/transforms/changeServerKeys.ts @@ -1,16 +1,34 @@ -import { type Keyset } from '@localfirst/crdx' +import { type Hash, type Keyset } from '@localfirst/crdx' import { type Transform } from 'team/types.js' +/** + * Replaces a server's keys and retains the previous encryption key at `retiredAt`, allowing it only + * for actions concurrent with (not causally after) that key-change link. + */ export const changeServerKeys = - (keys: Keyset): Transform => - state => ({ - ...state, - servers: state.servers.map(server => - server.host === keys.name - ? { - ...server, - keys, // 🡐 replace keys with new ones - } - : server - ), - }) + (keys: Keyset, retiredAt: Hash): Transform => + state => { + const previousKeys = state.servers.find(server => server.host === keys.name)?.keys + return { + ...state, + servers: state.servers.map(server => + server.host === keys.name + ? { + ...server, + keys, // 🡐 replace keys with new ones + } + : server + ), + retiredAuthorKeys: + previousKeys === undefined + ? state.retiredAuthorKeys + : [ + ...state.retiredAuthorKeys, + { + identityId: keys.name, + encryptionPublicKey: previousKeys.encryption, + retiredAt, + }, + ], + } + } diff --git a/packages/auth/src/team/transforms/postInvitation.ts b/packages/auth/src/team/transforms/postInvitation.ts index e72b88df4..d2fef01d8 100644 --- a/packages/auth/src/team/transforms/postInvitation.ts +++ b/packages/auth/src/team/transforms/postInvitation.ts @@ -1,11 +1,12 @@ -import { type Invitation } from 'invitation/index.js' +import { type Invitation, type InvitationKind } from 'invitation/index.js' import { type Transform } from 'team/types.js' export const postInvitation = - (invitation: Invitation): Transform => + (invitation: Invitation, kind: InvitationKind): Transform => state => { const invitationState = { ...invitation, + kind, uses: 0, revoked: false, } diff --git a/packages/auth/src/team/types.ts b/packages/auth/src/team/types.ts index b3882179e..61c4be29b 100644 --- a/packages/auth/src/team/types.ts +++ b/packages/auth/src/team/types.ts @@ -14,7 +14,13 @@ import type { } from '@localfirst/crdx' import type { Client, LocalContext } from 'team/context.js' import type { Device } from 'device/index.js' -import type { Invitation, InvitationState } from 'invitation/types.js' +import type { + DeviceInvitationClaim, + Invitation, + InvitationState, + MemberInvitationClaim, + ProofOfInvitationV2, +} from 'invitation/types.js' import type { Lockbox } from 'lockbox/index.js' import type { PermissionsMap, Role } from 'role/index.js' import type { Host, Server } from 'server/index.js' @@ -185,6 +191,8 @@ export type AdmitMemberAction = { id: Base58 // Invitation ID userName: string memberKeys: Keyset // Member keys provided by the new member + proof: ProofOfInvitationV2 + claim: MemberInvitationClaim } } @@ -193,6 +201,8 @@ export type AdmitDeviceAction = { payload: BasePayload & { id: Base58 // Invitation ID device: Device + proof: ProofOfInvitationV2 + claim: DeviceInvitationClaim } } @@ -324,14 +334,36 @@ export type TeamState = { // If a member's admission is reversed, we need to flag them as compromised so an admin can // rotate any keys they had access to at the first opportunity pendingKeyRotations: string[] + /** Former author keys and the causal point after which each key is no longer valid. */ + retiredAuthorKeys: RetiredAuthorKey[] metadata: TeamMetadata } +export type RetiredAuthorKey = { + /** Member user ID or server host whose key was rotated. */ + identityId: string + + /** Previous encryption public key that may authenticate concurrent actions. */ + encryptionPublicKey: Base58 + + /** Hash of the key-change link; old-key actions causally after this link are rejected. */ + retiredAt: Hash +} + export type InvitationMap = Record // ********* VALIDATION -export type TeamStateValidator = (previousState: TeamState, link: TeamLink, extendableLogger: Logger) => ValidationResult +export type TeamStateValidator = ( + /** Reduced state immediately before the candidate link in deterministic sequence order. */ + previousState: TeamState, + /** Candidate authenticated link. */ + link: TeamLink, + /** Logger for validation diagnostics. */ + extendableLogger: Logger, + /** Complete graph for causal checks; omitted only during provisional branch decryption. */ + graph?: TeamGraph +) => ValidationResult export type TeamStateValidatorSet = Record @@ -357,6 +389,9 @@ export type InviteResult = { /** The secret invitation key. (Returned in case it was generated randomly.) */ seed: string + + /** Immutable root hash identifying the team this invitation belongs to. */ + teamId: Base58 } export type LookupIdentityResult = | 'VALID_DEVICE' @@ -364,6 +399,10 @@ export type LookupIdentityResult = | 'DEVICE_UNKNOWN' | 'DEVICE_REMOVED' -export type EncryptStreamTeamPayload = { recipient: KeyMetadata, encryptStream: AsyncGenerator, header: Uint8Array } +export type EncryptStreamTeamPayload = { + recipient: KeyMetadata + encryptStream: AsyncGenerator + header: Uint8Array +} export type TeamMetadata = { selfAssignableRoles: string[] } diff --git a/packages/auth/src/team/validate.ts b/packages/auth/src/team/validate.ts index a3a6773f7..0774f13db 100644 --- a/packages/auth/src/team/validate.ts +++ b/packages/auth/src/team/validate.ts @@ -1,22 +1,40 @@ -import { debug, Logger, truncateHashes } from '@localfirst/shared' -import { ROOT } from '@localfirst/crdx' +import { Logger, truncateHashes } from '@localfirst/shared' +import { ROOT, isPredecessorHash, type Keyset } from '@localfirst/crdx' import { invitationCanBeUsed } from 'invitation/index.js' -import { VALID, ValidationError, actionFingerprint } from 'util/index.js' +import * as invitations from 'invitation/index.js' +import { isEqual } from 'lodash-es' +import { KeyType, VALID, ValidationError, actionFingerprint } from 'util/index.js' import { isAdminOnlyAction } from './isAdminOnlyAction.js' import * as select from './selectors/index.js' import { type TeamLink, + type TeamGraph, type TeamState, type TeamStateValidator, type TeamStateValidatorSet, } from './types.js' -export const validate: TeamStateValidator = (previousState: TeamState, link: TeamLink, extendableLogger?: Logger) => { - const logger = extendableLogger != null ? extendableLogger.extend('validate') : new Logger({ moduleName: 'auth:validate' }) +/** + * Runs every team-state validator for `link` against the preceding state. + * + * During speculative branch decryption `graph` may be omitted, which defers causal author-key + * checks. Final machine evaluation must supply the complete authenticated graph and reruns every + * validator before accepting the state. + */ +export const validate: TeamStateValidator = ( + previousState: TeamState, + link: TeamLink, + extendableLogger?: Logger, + graph?: TeamGraph +) => { + const logger = + extendableLogger !== undefined + ? extendableLogger.extend('validate') + : new Logger({ moduleName: 'auth:validate' }) logger.debug('Validating link') for (const key in validators) { const validator = validators[key] - const validation = validator(previousState, link, logger) + const validation = validator(previousState, link, logger, graph) if (!validation.isValid) { return validation } @@ -25,18 +43,78 @@ 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 (metadata.selfAssignableRoles.includes(roleName)) { - return true - } - if (select.memberIsAdmin(previousState, assigningUserId)) { - return true - } - return false +export const canUserAddMemberToRole = ( + roleName: string, + assigningUserId: string, + previousState: TeamState +): boolean => { + const metadata = select.getMetadata(previousState) + if (metadata.selfAssignableRoles.includes(roleName)) { + return true + } + if (select.memberIsAdmin(previousState, assigningUserId)) { + return true } + return false +} const validators: TeamStateValidatorSet = { + /** The authenticated encryption key must belong to the user or server claimed by the action. */ + actionAuthorIsAuthenticated( + previousState: TeamState, + link: TeamLink, + extendableLogger: Logger, + graph?: TeamGraph + ) { + const logger = extendableLogger.extend('actionAuthorIsAuthenticated') + const { senderPublicKey } = link + const { type, userId } = link.body + + // Branch-by-branch decryption does not have a complete authenticated graph. The final machine + // reduction always supplies one and is the security boundary for authorship validation. + if (graph === undefined) return VALID + + if (type === ROOT) { + const { rootMember } = link.body.payload + if (userId !== rootMember.userId || senderPublicKey !== rootMember.keys.encryption) { + return fail( + 'Root action author does not match the founding member', + previousState, + link, + logger + ) + } + return VALID + } + + const matchingMembers = previousState.members.filter(member => member.userId === userId) + const matchingServers = previousState.servers.filter(server => server.host === userId) + const matchingAuthors = [...matchingMembers, ...matchingServers] + + if (matchingAuthors.length !== 1) { + return fail(`Action author '${userId}' is unknown or ambiguous`, previousState, link, logger) + } + + if (senderPublicKey !== matchingAuthors[0].keys.encryption) { + const matchingRetiredKey = previousState.retiredAuthorKeys.find( + retired => + retired.identityId === userId && + retired.encryptionPublicKey === senderPublicKey && + !isPredecessorHash(graph, retired.retiredAt, link.hash) + ) + if (matchingRetiredKey !== undefined) return VALID + + return fail( + `Action author '${userId}' did not authenticate with a key valid at its causal frontier`, + previousState, + link, + logger + ) + } + + return VALID + }, + rootDeviceBelongsToRootUser(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { const logger = extendableLogger.extend('rootDeviceBelongsToRootUser') const { type, payload } = link.body @@ -50,6 +128,177 @@ const validators: TeamStateValidatorSet = { return VALID }, + identityKeyMetadataIsValid(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { + const logger = extendableLogger.extend('identityKeyMetadataIsValid') + const { type, payload } = link.body + + const invalidInitialMember = (member: { + userId: string + keys: Keyset + devices?: Array<{ deviceId: string; userId: string; keys: Keyset }> + }) => + !keysetMatches(member.keys, KeyType.USER, member.userId, 0) || + (member.devices ?? []).some( + device => + device.userId !== member.userId || + !keysetMatches(device.keys, KeyType.DEVICE, device.deviceId, 0) + ) + + if (type === ROOT) { + const { rootMember, rootDevice } = payload + if ( + invalidInitialMember(rootMember) || + !keysetMatches(rootDevice.keys, KeyType.DEVICE, rootDevice.deviceId, 0) + ) { + return fail('Root member or device key metadata is invalid', previousState, link, logger) + } + } + + if (type === 'ADD_MEMBER' && invalidInitialMember(payload.member)) { + return fail('New member key metadata is invalid', previousState, link, logger) + } + + if ( + type === 'ADMIT_MEMBER' && + !keysetMatches(payload.memberKeys, KeyType.USER, payload.memberKeys.name, 0) + ) { + return fail('Admitted member key metadata is invalid', previousState, link, logger) + } + + if ( + (type === 'ADD_DEVICE' || type === 'ADMIT_DEVICE') && + !keysetMatches(payload.device.keys, KeyType.DEVICE, payload.device.deviceId, 0) + ) { + return fail('New device key metadata is invalid', previousState, link, logger) + } + + if ( + type === 'ADD_SERVER' && + !keysetMatches(payload.server.keys, KeyType.SERVER, payload.server.host, 0) + ) { + return fail('New server key metadata is invalid', previousState, link, logger) + } + + if (type === 'CHANGE_MEMBER_KEYS') { + const members = previousState.members.filter(member => member.userId === payload.keys.name) + if ( + members.length !== 1 || + !keysetMatches( + payload.keys, + KeyType.USER, + members[0].userId, + members[0].keys.generation + 1 + ) + ) { + return fail('Changed member key metadata is invalid', previousState, link, logger) + } + } + + if (type === 'CHANGE_SERVER_KEYS') { + const servers = previousState.servers.filter(server => server.host === payload.keys.name) + if ( + servers.length !== 1 || + !keysetMatches( + payload.keys, + KeyType.SERVER, + servers[0].host, + servers[0].keys.generation + 1 + ) + ) { + return fail('Changed server key metadata is invalid', previousState, link, logger) + } + } + + return VALID + }, + + activeIdentityIdsAreUnique(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { + const logger = extendableLogger.extend('activeIdentityIdsAreUnique') + const { type, payload } = link.body + + const memberIds = + type === ROOT + ? [payload.rootMember.userId] + : type === 'ADD_MEMBER' + ? [payload.member.userId] + : type === 'ADMIT_MEMBER' + ? [payload.memberKeys.name] + : [] + for (const userId of memberIds) { + if ( + previousState.members.some(member => member.userId === userId) || + previousState.servers.some(server => server.host === userId) + ) { + return fail(`Active member ID '${userId}' is already in use`, previousState, link, logger) + } + } + + const devices = + type === ROOT + ? [payload.rootDevice, ...(payload.rootMember.devices ?? [])] + : type === 'ADD_MEMBER' + ? payload.member.devices ?? [] + : type === 'ADD_DEVICE' || type === 'ADMIT_DEVICE' + ? [payload.device] + : [] + const deviceIds = devices.map(device => device.deviceId) + if (new Set(deviceIds).size !== deviceIds.length) { + return fail('An action contains duplicate device IDs', previousState, link, logger) + } + for (const deviceId of deviceIds) { + const existingDevices = previousState.members.flatMap(member => member.devices ?? []) + if ( + existingDevices.some(device => device.deviceId === deviceId) || + previousState.servers.some(server => server.host === deviceId) + ) { + return fail(`Active device ID '${deviceId}' is already in use`, previousState, link, logger) + } + } + + if (type === 'ADD_SERVER') { + const { host } = payload.server + const deviceCollision = previousState.members + .flatMap(member => member.devices ?? []) + .some(device => device.deviceId === host) + if ( + previousState.servers.some(server => server.host === host) || + previousState.members.some(member => member.userId === host) || + deviceCollision + ) { + return fail(`Active server host '${host}' is already in use`, previousState, link, logger) + } + } + + return VALID + }, + + deviceAdditionIsAuthorized(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { + const logger = extendableLogger.extend('deviceAdditionIsAuthorized') + const { type, payload, userId: author } = link.body + if (type !== 'ADD_DEVICE' && type !== 'ADMIT_DEVICE') { + return VALID + } + if (type === 'ADMIT_DEVICE' && previousState.invitations[payload.id]?.kind !== 'device') { + // The invitation-kind validator below owns this failure. + return VALID + } + + const owners = previousState.members.filter(member => member.userId === payload.device.userId) + if (owners.length !== 1) { + return fail('Device owner is missing or ambiguous', previousState, link, logger) + } + + if ( + type === 'ADD_DEVICE' && + author !== payload.device.userId && + !select.memberIsAdmin(previousState, author) + ) { + return fail("A non-admin cannot add another member's device", previousState, link, logger) + } + + return VALID + }, + /** The user who made these changes was a member with appropriate rights at the time */ mustBeAdmin(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { const logger = extendableLogger.extend('mustBeAdmin') @@ -113,8 +362,11 @@ const validators: TeamStateValidatorSet = { }, /** Check for ADMIT with invitations that are revoked OR have been used more than maxUses OR are expired */ - cantAdmitWithInvalidInvitation(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { - const logger = extendableLogger.extend('cantAdmitWithInvalidInvitation') + cantAdmitWithInvalidInvitation( + previousState: TeamState, + link: TeamLink, + _extendableLogger: Logger + ) { if (link.body.type === 'ADMIT_MEMBER' || link.body.type === 'ADMIT_DEVICE') { const { id } = link.body.payload const invitation = select.getInvitation(previousState, id) @@ -123,20 +375,131 @@ const validators: TeamStateValidatorSet = { return VALID }, + admissionMatchesInvitationKind( + previousState: TeamState, + link: TeamLink, + extendableLogger: Logger + ) { + const logger = extendableLogger.extend('admissionMatchesInvitationKind') + if (link.body.type !== 'ADMIT_MEMBER' && link.body.type !== 'ADMIT_DEVICE') { + return VALID + } + + const { id } = link.body.payload + const invitation = select.getInvitation(previousState, id) + const expectedKind = link.body.type === 'ADMIT_MEMBER' ? 'member' : 'device' + if (invitation.kind !== expectedKind) { + return fail( + `${invitation.kind} invitation cannot be used by ${link.body.type}`, + previousState, + link, + logger + ) + } + + if (link.body.type === 'ADMIT_DEVICE') { + if (!invitation.userId) { + return fail('Device invitation has no owner', previousState, link, logger) + } + if (link.body.payload.device.userId !== invitation.userId) { + return fail( + 'Admitted device owner does not match the invitation owner', + previousState, + link, + logger + ) + } + } + + return VALID + }, + + /** Every replica must independently verify that an admission proves possession of its invite. */ + admissionProvesInvitationPossession( + previousState: TeamState, + link: TeamLink, + extendableLogger: Logger + ) { + const logger = extendableLogger.extend('admissionProvesInvitationPossession') + if (link.body.type !== 'ADMIT_MEMBER' && link.body.type !== 'ADMIT_DEVICE') { + return VALID + } + + const { id, proof, claim } = link.body.payload + if (!isRecord(proof) || !isRecord(claim)) { + return fail('Admission is missing its invitation proof or claim', previousState, link, logger) + } + const invitation = select.getInvitation(previousState, id) + const proofValidation = invitations.validate(proof, invitation, claim) + if (!proofValidation.isValid) { + return fail( + `Admission does not contain a valid invitation proof: ${proofValidation.error.message}`, + previousState, + link, + logger + ) + } + + const admissionMatchesClaim = + link.body.type === 'ADMIT_MEMBER' + ? claim.invitationKind === 'member' && + link.body.payload.userName === claim.userName && + isEqual(link.body.payload.memberKeys, claim.userKeys) + : claim.invitationKind === 'device' && + invitation.userId !== undefined && + isEqual(link.body.payload.device, { + ...claim.device, + userId: invitation.userId, + }) + + return admissionMatchesClaim + ? VALID + : fail( + 'Admission identity does not match its signed invitation claim', + previousState, + link, + logger + ) + }, + /** Check for self-assigned roles that aren't in the allowed list set by the admin */ - nonAdminsCanOnlyModifyCertainRoles(previousState: TeamState, link: TeamLink, extendableLogger: Logger) { + nonAdminsCanOnlyModifyCertainRoles( + previousState: TeamState, + link: TeamLink, + extendableLogger: Logger + ) { const logger = extendableLogger.extend('nonAdminsCanOnlyModifyCertainRoles') if (link.body.type === 'ADD_MEMBER_ROLE') { const { userId: assigningUserId } = link.body const { roleName } = link.body.payload if (canUserAddMemberToRole(roleName, assigningUserId, previousState)) return VALID - return fail(`User ${assigningUserId} attempted to assign role ${roleName} illegally`, previousState, link, logger) + return fail( + `User ${assigningUserId} attempted to assign role ${roleName} illegally`, + previousState, + link, + logger + ) } return VALID }, } -const fail = (message: string, previousState: TeamState, link: TeamLink, extendableLogger: Logger) => { +const keysetMatches = (keys: Keyset, type: string, name: string, generation: number) => + keys.type === type && + keys.name === name && + keys.generation === generation && + typeof keys.encryption === 'string' && + typeof keys.signature === 'string' + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const fail = ( + message: string, + previousState: TeamState, + link: TeamLink, + extendableLogger: Logger +) => { const logger = extendableLogger.extend('fail') message = truncateHashes(`${actionFingerprint(link)} ${message}`) logger.error(message, link.hash) diff --git a/packages/auth/src/util/testing/connectionHelpers.ts b/packages/auth/src/util/testing/connectionHelpers.ts index 23a22ab0b..d2840a17c 100644 --- a/packages/auth/src/util/testing/connectionHelpers.ts +++ b/packages/auth/src/util/testing/connectionHelpers.ts @@ -2,6 +2,7 @@ import { eventPromise } from '@localfirst/shared' import { type Connection, type ConnectionEvents } from 'connection/index.js' import { type InviteeDeviceContext, type InviteeMemberContext } from 'connection/types.js' +import { type DeviceWithSecrets, type FirstUseDeviceWithSecrets } from 'device/index.js' import { expect } from 'vitest' import { TestChannel } from './TestChannel.js' import { joinTestChannel } from './joinTestChannel.js' @@ -20,7 +21,10 @@ export const tryToConnect = async (a: UserStuff, b: UserStuff) => { export const connect = async (a: UserStuff, b: UserStuff) => { void tryToConnect(a, b) return Promise.race([ - connection(a, b).then(() => true), // + connection(a, b).then( + () => true, + () => false + ), anyDisconnected(a, b).then(() => false), ]) } @@ -35,6 +39,7 @@ export const connectWithInvitation = async ( user: invitee.user, device: invitee.device, invitationSeed: seed, + expectedTeamId: member.team.id, } as InviteeMemberContext return connect(member, invitee).then(() => { @@ -46,22 +51,31 @@ export const connectWithInvitation = async ( export const connectPhoneWithInvitation = async (user: UserStuff, seed: string) => { const phoneContext: InviteeDeviceContext = { userName: user.user.userName, - device: user.phone!, + device: asFirstUseDevice(user.phone!), invitationSeed: seed, + expectedTeamId: user.team.id, } const join = joinTestChannel(new TestChannel()) - const laptopConnection = join(user.connectionContext).start() - const phoneConnection = join(phoneContext).start() + const laptopConnection = join(user.connectionContext) + const phoneConnection = join(phoneContext) + const connected = all([laptopConnection, phoneConnection], 'connected') + laptopConnection.start() + phoneConnection.start() - await all([laptopConnection, phoneConnection], 'connected') + await connected user.team = laptopConnection.team! user.connection = { [user.phoneStuff!.deviceId]: phoneConnection } user.phoneStuff!.team = phoneConnection.team! user.phoneStuff!.connection = { [user.deviceId]: laptopConnection } } +export const asFirstUseDevice = (device: DeviceWithSecrets): FirstUseDeviceWithSecrets => { + const { userId: _userId, ...firstUseDevice } = device + return firstUseDevice +} + /** Passes if each of the given members is on the team, and knows every other member on the team */ export const expectEveryoneToKnowEveryone = (...members: UserStuff[]) => { for (const a of members) { @@ -123,7 +137,7 @@ export const all = async (connections: Connection[], event: keyof ConnectionEven Promise.all( connections.map(async connection => { if (event === 'disconnected' && connection.state === 'disconnected') return connection - if (event === 'connected' && connection.state === 'connected') return connection + if (event === 'connected') return connectedOrThrow(connection) return eventPromise(connection, event) }) ) @@ -136,3 +150,18 @@ export const any = async (connections: Connection[], event: keyof ConnectionEven return eventPromise(connection, event) }) ) + +const connectedOrThrow = async (connection: Connection) => { + if (connection._started && connection.state === 'connected') return connection + + const fail = (event: 'localError' | 'remoteError' | 'disconnected') => (payload: unknown) => { + throw new Error(`Connection emitted ${event} before connected: ${JSON.stringify(payload)}`) + } + + return Promise.race([ + eventPromise(connection, 'connected'), + eventPromise(connection, 'localError').then(fail('localError')), + eventPromise(connection, 'remoteError').then(fail('remoteError')), + eventPromise(connection, 'disconnected').then(fail('disconnected')), + ]) +} diff --git a/packages/auth/src/util/testing/index.ts b/packages/auth/src/util/testing/index.ts index 30d4c9528..b067d21f0 100644 --- a/packages/auth/src/util/testing/index.ts +++ b/packages/auth/src/util/testing/index.ts @@ -1,4 +1,5 @@ export * from 'util/testing/connectionHelpers.js' +export * from 'util/testing/invitationProof.js' export * from 'util/testing/joinTestChannel.js' export * from 'util/testing/setup.js' export * from 'util/testing/TestChannel.js' diff --git a/packages/auth/src/util/testing/invitationProof.ts b/packages/auth/src/util/testing/invitationProof.ts new file mode 100644 index 000000000..e955a4d50 --- /dev/null +++ b/packages/auth/src/util/testing/invitationProof.ts @@ -0,0 +1,55 @@ +import { redactKeys, type UserWithSecrets } from '@localfirst/crdx' +import { randomKey, type Base58 } from '@localfirst/crypto' +import { + redactDevice, + type FirstUseDevice, + type DeviceWithSecrets, +} from 'device/index.js' +import { + generateProof, + type DeviceInvitationClaim, + type MemberInvitationClaim, +} from 'invitation/index.js' + +export const memberInvitationProof = ( + seed: string, + user: Pick, + device: DeviceWithSecrets, + nonces = invitationNonces() +) => { + const claim: MemberInvitationClaim = { + invitationKind: 'member', + userName: user.userName, + userKeys: redactKeys(user.keys), + device: redactDevice(device), + } + return generateProof({ seed, claim, ...nonces }) +} + +export const deviceInvitationProof = ( + seed: string, + userName: string, + device: DeviceWithSecrets, + nonces = invitationNonces() +) => { + const firstUseDevice = redactFirstUseDevice(device) + const claim: DeviceInvitationClaim = { + invitationKind: 'device', + userName, + device: firstUseDevice, + } + return generateProof({ seed, claim, ...nonces }) +} + +export const redactFirstUseDevice = (device: DeviceWithSecrets): FirstUseDevice => { + const { userId: _userId, ...firstUseDevice } = redactDevice(device) + return firstUseDevice +} + +export const invitationNonces = (): { + acceptorNonce: Base58 + inviteeNonce: Base58 +} => ({ + acceptorNonce: randomKey(), + inviteeNonce: randomKey(), +}) diff --git a/packages/auth/src/util/testing/setup.ts b/packages/auth/src/util/testing/setup.ts index 5907b7dd6..49259cc78 100644 --- a/packages/auth/src/util/testing/setup.ts +++ b/packages/auth/src/util/testing/setup.ts @@ -107,7 +107,7 @@ export const setup = (..._config: SetupConfig) => { const connectionContext: Context = member ? { user, device, team } - : { user, device, invitationSeed: '' } + : { user, device, invitationSeed: '', expectedTeamId: team.id } const phoneStuff: UserStuff = { userName, diff --git a/packages/crdx/src/graph/append.ts b/packages/crdx/src/graph/append.ts index 48ab57662..76b7706a9 100644 --- a/packages/crdx/src/graph/append.ts +++ b/packages/crdx/src/graph/append.ts @@ -41,6 +41,7 @@ export const append = ({ // create the encrypted and unencrypted links const link: Link = { hash, + senderPublicKey, body, } const encryptedLink: EncryptedLink = { diff --git a/packages/crdx/src/graph/decrypt.ts b/packages/crdx/src/graph/decrypt.ts index c90df9a23..dde74d241 100644 --- a/packages/crdx/src/graph/decrypt.ts +++ b/packages/crdx/src/graph/decrypt.ts @@ -36,27 +36,36 @@ export const decryptLink = ( return { hash: hashEncryptedLink(encryptedBody), + senderPublicKey, body: decryptedLinkBody, } } /** - * Decrypts a graph using a one or more keys. + * Decrypts every link reachable from `root` through `childMap` using one or more keysets. + * + * Traversal is iterative and visits each hash at most once. `maxTraversalSteps` defaults to 50,000; + * exceeding it throws instead of performing unbounded peer-controlled work. */ export const decryptGraph: DecryptFn = ({ encryptedGraph, keys, + maxTraversalSteps = 50_000, }: { encryptedGraph: MaybePartlyDecryptedGraph keys: KeysetWithSecrets | KeysetWithSecrets[] | Keyring + maxTraversalSteps?: number }): Graph => { const { encryptedLinks, root, childMap = {} } = encryptedGraph - const links = encryptedGraph.links ?? {} const toVisit = [root] const visited: Set = new Set() const decryptedLinks: Record> = {} + let traversalSteps = 0 while (toVisit.length > 0) { + if (++traversalSteps > maxTraversalSteps) { + throw new Error('Graph decryption exceeded its traversal limit') + } const current = toVisit.pop() as Hash if (visited.has(current)) { @@ -64,9 +73,7 @@ export const decryptGraph: DecryptFn = ({ } const encryptedLink = encryptedLinks[current] - const decryptedLink = - links[current] ?? // if it's already decrypted, don't bother decrypting it again - decryptLink(encryptedLink, keys) + const decryptedLink = decryptLink(encryptedLink, keys) decryptedLinks[current] = decryptedLink @@ -84,8 +91,14 @@ export const decryptGraph: DecryptFn = ({ } export type DecryptFnParams = { + /** Graph ciphertext and child topology to traverse. */ encryptedGraph: MaybePartlyDecryptedGraph + + /** Keyset(s) capable of decrypting graph links. */ keys: KeysetWithSecrets | KeysetWithSecrets[] | Keyring + + /** Maximum traversal pops before aborting. Defaults to 50,000. */ + maxTraversalSteps?: number } export type DecryptFn = ({ diff --git a/packages/crdx/src/graph/index.ts b/packages/crdx/src/graph/index.ts index bab5acb23..705f8ab58 100644 --- a/packages/crdx/src/graph/index.ts +++ b/packages/crdx/src/graph/index.ts @@ -12,8 +12,10 @@ export * from './getParents.js' export * from './getPredecessors.js' export * from './getRoot.js' export * from './getSequence.js' +export * from './getSuccessors.js' export * from './headsAreEqual.js' export * from './isPredecessor.js' +export * from './isSuccessor.js' export * from './merge.js' export * from './redactGraph.js' export * from './serialize.js' diff --git a/packages/crdx/src/graph/test/decrypt.test.ts b/packages/crdx/src/graph/test/decrypt.test.ts index 70e7917ba..7249ea484 100644 --- a/packages/crdx/src/graph/test/decrypt.test.ts +++ b/packages/crdx/src/graph/test/decrypt.test.ts @@ -19,6 +19,7 @@ describe('decrypt', () => { const decryptedLink = decryptLink(link, keys) expect(decryptedLink.body).toEqual(graph.links[hash].body) expect(decryptedLink.hash).toEqual(hash) + expect(decryptedLink.senderPublicKey).toEqual(link.senderPublicKey) } }) @@ -36,6 +37,9 @@ describe('decrypt', () => { const original = graph.links[hash] expect(decrypted.body).toEqual(original.body) expect(decrypted.hash).toEqual(original.hash) + expect(decrypted.senderPublicKey).toEqual( + graph.encryptedLinks[hash].senderPublicKey + ) } }) @@ -69,4 +73,30 @@ describe('decrypt', () => { expect(decrypted.hash).toEqual(original.hash) } }) + + it('ignores supplied plaintext links and decrypts the authenticated ciphertext', () => { + const alice = createUser('alice') + let graph = createGraph({ user: alice, name: 'test graph', keys }) + graph = append({ graph, action: { type: 'FOO' }, user: alice, keys }) + const encryptedGraph = redactGraph(graph) + const [head] = graph.head + + const graphWithInjectedPlaintext = { + ...encryptedGraph, + links: { + ...graph.links, + [head]: { + ...graph.links[head], + body: { ...graph.links[head].body, userId: 'eve' }, + }, + }, + } + + const decryptedGraph = decryptGraph({ + encryptedGraph: graphWithInjectedPlaintext, + keys, + }) + + expect(decryptedGraph.links[head].body.userId).toBe(alice.userId) + }) }) diff --git a/packages/crdx/src/graph/types.ts b/packages/crdx/src/graph/types.ts index 76f4e5db5..4679d1e7f 100644 --- a/packages/crdx/src/graph/types.ts +++ b/packages/crdx/src/graph/types.ts @@ -56,9 +56,9 @@ export type Graph< } & Optional /** - * When we pass a graph to be decrypted, some of the links might already be encrypted (for - * instance, when we receive new encrypted links). We want to be able to decrypt the new links - * without re-decrypting links that we already have. + * When we pass a graph to a decryptor, some links might already have trusted plaintext. Specialized + * decryptors may reuse that plaintext when they can prove its ciphertext provenance; generic + * `decryptGraph` reconstructs every root-reachable link. */ export type MaybePartlyDecryptedGraph = Record & Optional, 'links'> @@ -97,7 +97,8 @@ export type EncryptedLink = { /** * Public key of the author of the link, at the time of authoring. After decryption, it is up to - * the application to ensure that this is in fact the public key of the author (`link.body.user`). + * the application to ensure that this is in fact the public key of the author + * (`link.body.userId`). */ senderPublicKey: Base58 @@ -114,6 +115,12 @@ export type Link = { /** Hash of the body */ hash: Hash + /** + * Public encryption key that authenticated the encrypted body. Applications must bind it to the + * identity claimed by `body.userId`. + */ + senderPublicKey: Base58 + /** The part of the link that is encrypted */ body: LinkBody diff --git a/packages/crdx/src/store/Store.ts b/packages/crdx/src/store/Store.ts index 8d5598ff4..141615567 100644 --- a/packages/crdx/src/store/Store.ts +++ b/packages/crdx/src/store/Store.ts @@ -18,15 +18,15 @@ import { type UserWithSecrets } from 'user/index.js' import { type Hash, type Optional } from 'util/index.js' import { validate, type ValidatorSet } from 'validator/index.js' import { type StoreOptions } from './StoreOptions.js' -import { makeMachine } from './makeMachine.js' +import { consumeMachineResult, makeMachine } from './makeMachine.js' import { type Reducer } from './types.js' /** * A CRDX `Store` is intended to work very much like a Redux store. * https://github.com/reduxjs/redux/blob/master/src/createStore.ts * - * The only way to change the data in the store is to `dispatch` an action to it. There should only - * be a single store in an application. + * `dispatch` is the only way to originate a local action; `merge` incorporates peer graphs. There + * should only be a single store in an application. */ export class Store< S, @@ -58,6 +58,7 @@ export class Store< resolver = baseResolver, keys, logger, + machineResult, }: StoreOptions) { super() @@ -85,8 +86,22 @@ export class Store< // if a single keyset was provided, wrap it in a keyring this.keyring = createKeyring(keys) - // set the initial state - this.updateState() + if (machineResult === undefined) { + // Derive and validate the initial state when no reusable machine result was provided. + this.updateState() + } else { + const definition = { + initialState: this.initialState, + reducer: this.reducer, + resolver: this.resolver, + validators: this.validators, + } + assert( + consumeMachineResult(machineResult, this.graph, definition), + 'Machine result does not match this store graph and definition.' + ) + this.state = machineResult.state + } } /** Returns the store's most recent state. */ @@ -99,6 +114,11 @@ export class Store< return this.graph } + /** Returns the encryption keys retained for this graph. */ + public getKeyring(): Keyring { + return { ...this.keyring } + } + /** * Returns the current hash graph in serialized form; this can be used to rehydrate this * store from storage. @@ -111,9 +131,9 @@ export class Store< * Dispatches an action to be added to the hash graph. This is the only way to trigger a * state change. * - * The `reducer` function provided when creating the store will be called with the current state - * and the given `action`. Its return value will be considered the **next** state of the tree, - * and any change listeners will be notified. + * The configured reducer receives the current state, newly appended decrypted link, logger, and + * complete candidate graph. Graph and state are committed atomically, and listeners are notified, + * only if reduction and application validation succeed. * * @returns For convenience, the same action object that was dispatched. */ @@ -147,7 +167,7 @@ export class Store< } // append this action as a new link to the graph - this.graph = append({ + const nextGraph = append({ graph: this.graph, action: actionWithPayload, user: this.user, @@ -156,10 +176,14 @@ export class Store< }) // get the newly appended link (at this point we're guaranteed a single head, which is the one we appended) - const [head] = getHead(this.graph) + const [head] = getHead(nextGraph) - // we don't need to pass the whole graph through the reducer, just the current state + the new head - this.state = this.reducer(this.state, head, this.logger) + // Validate the new head against the complete candidate graph before committing either value. + const nextState = this.reducer(this.state, head, this.logger, nextGraph) + + // Commit the graph and state together only after the action has passed application validation. + this.graph = nextGraph + this.state = nextState // notify listeners this.emit('updated', { head: this.graph.head }) @@ -168,13 +192,20 @@ export class Store< } /** - * Merges another graph (e.g. from a peer) with ours. - * @param theirGraph - * @returns this `Store` instance + * Validates, resolves, and reduces a peer graph before atomically committing the merged graph and + * state. If derivation throws, both current values remain unchanged. A successful merge emits + * `updated`. + * + * @param theirGraph Graph received from a peer. */ public merge(theirGraph: Graph) { - this.graph = merge(this.graph, theirGraph) - this.updateState() + const mergedGraph = merge(this.graph, theirGraph) + const mergedState = this.deriveState(mergedGraph) + + // Do not expose a received graph unless both graph and application validation succeeded. + this.graph = mergedGraph + this.state = mergedState + this.emit('updated', { head: this.graph.head }) } /** @@ -188,16 +219,20 @@ export class Store< // PRIVATE private updateState() { + this.state = this.deriveState(this.graph) + + // notify listeners + this.emit('updated', { head: this.graph.head }) + } + + private deriveState(graph: Graph) { const machine = makeMachine({ initialState: this.initialState, reducer: this.reducer, resolver: this.resolver, validators: this.validators, }) - this.state = machine(this.graph, this.logger) - - // notify listeners - this.emit('updated', { head: this.graph.head }) + return machine(graph, this.logger) } } diff --git a/packages/crdx/src/store/StoreOptions.ts b/packages/crdx/src/store/StoreOptions.ts index 1bc7c9d02..23d9b36f1 100644 --- a/packages/crdx/src/store/StoreOptions.ts +++ b/packages/crdx/src/store/StoreOptions.ts @@ -4,6 +4,7 @@ import { type Action, type Graph, type Resolver } from 'graph/index.js' import { type Keyring, type KeysetWithSecrets } from 'keyset/index.js' import { type UserWithSecrets } from 'user/index.js' import { type ValidatorSet } from 'validator/index.js' +import { type MachineResult } from './makeMachine.js' export type StoreOptions = { /** The user local user, along with their secret keys for signing, encrypting, etc. */ @@ -12,8 +13,10 @@ export type StoreOptions = { /** Additional context information to be added to each link (e.g. device, client, etc.) */ context?: C - /** A Redux-style reducer that calculates a new state given the previous state and an action. In - * this case an "action" is a link in a hash graph. */ + /** + * A Redux-style reducer that calculates new state from the previous state and a graph link. Store + * also supplies the complete candidate graph as the reducer's optional fourth argument. + */ reducer: Reducer /** A resolver defines how any two concurrent sequences will be merged. It is a pure function that is @@ -31,6 +34,13 @@ export type StoreOptions = { /** For pre-existing stores: A graph to preload, e.g. from saved state. */ graph?: Uint8Array | Graph + /** + * A one-shot opaque derivation produced by `makeMachine(...).derive`. Reusable state must be + * structured-cloneable. Store consumes it once and skips initial validation/reduction only when + * its graph, state, validators, and machine definition remain unchanged; rejection throws. + */ + machineResult?: MachineResult + /** For new stores: Additional information to include in the root node */ rootPayload?: unknown diff --git a/packages/crdx/src/store/compose.ts b/packages/crdx/src/store/compose.ts index fcecf1dff..89fb035a2 100644 --- a/packages/crdx/src/store/compose.ts +++ b/packages/crdx/src/store/compose.ts @@ -1,7 +1,8 @@ import { type Reducer } from './types.js' import { type Action } from 'graph/index.js' +/** Composes reducers left-to-right, forwarding the same logger and complete graph to each reducer. */ export const compose = - (reducers: Array>): Reducer => - (state, action) => - reducers.reduce((state, reducer) => reducer(state, action), state) + (reducers: Array>): Reducer => + (state, action, logger, graph) => + reducers.reduce((nextState, reducer) => reducer(nextState, action, logger, graph), state) diff --git a/packages/crdx/src/store/index.ts b/packages/crdx/src/store/index.ts index 3d5e1031c..837982fba 100644 --- a/packages/crdx/src/store/index.ts +++ b/packages/crdx/src/store/index.ts @@ -1,4 +1,5 @@ export * from './createStore.js' -export * from './makeMachine.js' +export { makeMachine } from './makeMachine.js' +export type { MachineParams, MachineResult } from './makeMachine.js' export * from './Store.js' export * from './types.js' diff --git a/packages/crdx/src/store/makeMachine.ts b/packages/crdx/src/store/makeMachine.ts index 505e2303e..33f99571c 100644 --- a/packages/crdx/src/store/makeMachine.ts +++ b/packages/crdx/src/store/makeMachine.ts @@ -1,31 +1,162 @@ +import { hash } from '@localfirst/crypto' import { Logger } from '@localfirst/shared' +import { isEqual } from 'lodash-es' import { type Reducer } from './types.js' -import { type Action, getSequence, type Graph, Link, type Resolver } from 'graph/index.js' +import { type Action, getSequence, type Graph, type Link, type Resolver } from 'graph/index.js' import { validate, type ValidatorSet } from 'validator/index.js' +const MACHINE_RESULT = Symbol('crdx-machine-result') +const MACHINE_RESULT_HASH_PURPOSE = 'CRDX_MACHINE_RESULT' + +type MachineResultMetadata = { + readonly definition: { + readonly initialState: unknown + readonly reducer: unknown + readonly resolver: unknown + readonly validators: unknown + } + readonly fingerprint: string + readonly initialStateSnapshot: unknown + readonly stateSnapshot: unknown + readonly validatorEntries: ReadonlyArray +} + +type MachineResultKey = { readonly [MACHINE_RESULT]: true } + +const machineResults = new WeakMap() + +/** + * Opaque, provenance-checked result of one graph validation, sequencing, and reduction pass. + * Do not construct, copy, mutate, or reuse this value; a Store may consume it exactly once. + */ +export type MachineResult = { + readonly graph: Graph + readonly state: S + readonly sequence: ReadonlyArray> + readonly [MACHINE_RESULT]: true +} + +/** + * Creates a deterministic graph-to-state machine. + * + * Calling the returned function validates the graph, resolves its sequence, and reduces it while + * passing the complete graph to every reducer invocation. Validation failures throw. Its `.derive` + * method additionally returns a one-shot `MachineResult` that a matching Store can consume to avoid + * repeating the same validation and reduction work. + */ export const makeMachine = ({ initialState, reducer, resolver, validators, }: MachineParams) => { - return (graph: Graph, extendableLogger?: Logger) => { + const definition = Object.freeze({ initialState, reducer, resolver, validators }) + + const evaluate = (graph: Graph, extendableLogger?: Logger) => { // extend the logger or generate a new one if none was passed in - const logger = extendableLogger != null ? extendableLogger.extend('makeMachine') : new Logger({ moduleName: 'auth:makeMachine' }) + const logger = + extendableLogger !== undefined + ? extendableLogger.extend('makeMachine') + : new Logger({ moduleName: 'auth:makeMachine' }) // Validate the graph's integrity. - validate(graph, validators, logger) + const validation = validate(graph, validators, logger) + if (!validation.isValid) { + throw validation.error + } // Use the filter & sequencer to turn the graph into an ordered sequence const sequence = getSequence(graph, resolver) - const wrappedReducer = (state: S, link: Link) => reducer(state, link, logger) + const wrappedReducer = (state: S, link: Link) => reducer(state, link, logger, graph) // Run the sequence through the reducer to calculate the current team state - return sequence.reduce(wrappedReducer, initialState) + const state = sequence.reduce(wrappedReducer, initialState) + return { sequence, state } + } + + const derive = (graph: Graph, extendableLogger?: Logger): MachineResult => { + const { sequence, state } = evaluate(graph, extendableLogger) + const metadata = Object.freeze({ + definition, + fingerprint: fingerprint(graph), + initialStateSnapshot: cloneForReuse(initialState), + stateSnapshot: cloneForReuse(state), + validatorEntries: snapshotValidators(validators), + }) + const result = Object.freeze({ + graph, + state, + sequence: Object.freeze(sequence), + [MACHINE_RESULT]: true as const, + }) + machineResults.set(result, metadata) + return result } + + return Object.assign( + (graph: Graph, extendableLogger?: Logger) => evaluate(graph, extendableLogger).state, + { derive } + ) +} + +/** + * Consumes an internal machine result and verifies graph identity/fingerprint, machine definition, + * state snapshots, and validators. Metadata is deleted on every attempt, so false results cannot be + * retried. + */ +export const consumeMachineResult = ( + result: MachineResult, + graph: Graph, + definition: MachineParams +): boolean => { + const metadata = machineResults.get(result) + machineResults.delete(result) + const matches = + metadata !== undefined && + result.graph === graph && + metadata.definition.initialState === definition.initialState && + metadata.definition.reducer === definition.reducer && + metadata.definition.resolver === definition.resolver && + metadata.definition.validators === definition.validators && + metadata.fingerprint === fingerprint(result.graph) && + isEqual(metadata.initialStateSnapshot, definition.initialState) && + isEqual(metadata.stateSnapshot, result.state) && + validatorsMatch(metadata.validatorEntries, definition.validators) + return matches +} + +const fingerprint = (graph: Graph) => + hash(MACHINE_RESULT_HASH_PURPOSE, graph) + +const cloneForReuse = (value: T): T => { + try { + return structuredClone(value) + } catch (error) { + throw new Error('Reusable machine state must be structured-cloneable.', { cause: error }) + } +} + +const snapshotValidators = (validators?: ValidatorSet): ReadonlyArray => + Object.freeze( + Object.entries(validators ?? {}) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, validator]) => Object.freeze([name, validator] as const)) + ) + +const validatorsMatch = ( + snapshot: ReadonlyArray, + validators?: ValidatorSet +): boolean => { + const current = snapshotValidators(validators) + return ( + snapshot.length === current.length && + snapshot.every( + ([name, validator], index) => name === current[index][0] && validator === current[index][1] + ) + ) } -type MachineParams = { +export type MachineParams = { initialState: S reducer: Reducer resolver: Resolver diff --git a/packages/crdx/src/store/test/createStore.test.ts b/packages/crdx/src/store/test/createStore.test.ts index b454549a9..dc9b124b9 100644 --- a/packages/crdx/src/store/test/createStore.test.ts +++ b/packages/crdx/src/store/test/createStore.test.ts @@ -1,10 +1,11 @@ import { asymmetric } from '@localfirst/crypto' -import { createGraph, getRoot, serialize } from 'graph/index.js' -import { createStore } from 'store/index.js' +import { baseResolver, createGraph, getRoot, serialize } from 'graph/index.js' +import { createStore, makeMachine } from 'store/index.js' import { createUser } from 'user/index.js' import 'util/testing/expect/toBeValid' import { TEST_GRAPH_KEYS as keys } from 'util/testing/setup.js' -import { describe, expect, test } from 'vitest' +import { fail, type ValidatorSet } from 'validator/index.js' +import { describe, expect, test, vi } from 'vitest' import { counterReducer, type CounterAction, @@ -54,6 +55,157 @@ describe('createStore', () => { expect(bobState.value).toEqual(2) }) + test('supplies the validated graph to reducers during derivation and dispatch', () => { + const reducer = vi.fn(counterReducer) + const store = createStore>({ + user: alice, + reducer, + keys, + }) + + expect(reducer.mock.calls[0]?.[3]).toBe(store.getGraph()) + reducer.mockClear() + store.dispatch({ type: 'INCREMENT' }) + expect(reducer.mock.calls[0]?.[3]).toBe(store.getGraph()) + }) + + test('reuses an opaque machine result without reducing the graph again', () => { + const graph = createGraph({ user: alice, name: 'counter', keys }) + const initialState = {} as CounterState + const reducer = vi.fn(counterReducer) + const machine = makeMachine>({ + initialState, + reducer, + resolver: baseResolver, + }) + const machineResult = machine.derive(graph) + expect(reducer).toHaveBeenCalledTimes(1) + reducer.mockClear() + + const store = createStore>({ + user: bob, + graph, + initialState, + reducer, + resolver: baseResolver, + keys, + machineResult, + }) + + expect(store.getState()).toBe(machineResult.state) + expect(reducer).not.toHaveBeenCalled() + expect(() => + createStore>({ + user: bob, + graph, + initialState, + reducer, + resolver: baseResolver, + keys, + machineResult, + }) + ).toThrow('Machine result does not match') + }) + + test('rejects a machine result derived from a different graph', () => { + const firstGraph = createGraph({ user: alice, name: 'first', keys }) + const secondGraph = createGraph({ user: alice, name: 'second', keys }) + const initialState = {} as CounterState + const machine = makeMachine>({ + initialState, + reducer: counterReducer, + resolver: baseResolver, + }) + const machineResult = machine.derive(firstGraph) + + expect(() => + createStore>({ + user: bob, + graph: secondGraph, + initialState, + reducer: counterReducer, + resolver: baseResolver, + keys, + machineResult, + }) + ).toThrow('Machine result does not match') + }) + + test('rejects a machine result when its graph changed after derivation', () => { + const graph = createGraph({ user: alice, name: 'counter', keys }) + const initialState = {} as CounterState + const machine = makeMachine>({ + initialState, + reducer: counterReducer, + resolver: baseResolver, + }) + const machineResult = machine.derive(graph) + graph.links[graph.root].body.timestamp += 1 + + expect(() => + createStore>({ + user: bob, + graph, + initialState, + reducer: counterReducer, + resolver: baseResolver, + keys, + machineResult, + }) + ).toThrow('Machine result does not match') + }) + + test('rejects a machine result when its derived state changed before consumption', () => { + const graph = createGraph({ user: alice, name: 'counter', keys }) + const initialState = {} as CounterState + const machine = makeMachine>({ + initialState, + reducer: counterReducer, + resolver: baseResolver, + }) + const machineResult = machine.derive(graph) + machineResult.state.value = 99 + + expect(() => + createStore>({ + user: bob, + graph, + initialState, + reducer: counterReducer, + resolver: baseResolver, + keys, + machineResult, + }) + ).toThrow('Machine result does not match') + }) + + test('rejects a machine result when validators changed after derivation', () => { + const graph = createGraph({ user: alice, name: 'counter', keys }) + const initialState = {} as CounterState + const validators: ValidatorSet = {} + const machine = makeMachine>({ + initialState, + reducer: counterReducer, + resolver: baseResolver, + validators, + }) + const machineResult = machine.derive(graph) + validators.rejectLateChange = () => fail('late validator') + + expect(() => + createStore>({ + user: bob, + graph, + initialState, + reducer: counterReducer, + resolver: baseResolver, + validators, + keys, + machineResult, + }) + ).toThrow('Machine result does not match') + }) + test('Eve tampers with the serialized graph', () => { // 👩🏾 Alice makes a new store and saves it const graph = createGraph({ @@ -85,18 +237,14 @@ describe('createStore', () => { const tamperedSerializedGraph = serialize(tamperedGraph) // 👩🏾 Alice tries to load the modified graph - const aliceStoreTheNextDay = createStore< - CounterState, - IncrementAction, - Record - >({ - user: alice, - graph: tamperedSerializedGraph, - reducer: counterReducer, - keys, - }) - - // 👩🏾 Alice is not fooled because the graph is no longer valid - expect(aliceStoreTheNextDay.validate()).not.toBeValid() + // 👩🏾 Alice is not fooled because invalid graphs fail closed during initial load + expect(() => + createStore>({ + user: alice, + graph: tamperedSerializedGraph, + reducer: counterReducer, + keys, + }) + ).toThrow() }) }) diff --git a/packages/crdx/src/store/types.ts b/packages/crdx/src/store/types.ts index 83d3a305e..802d86674 100644 --- a/packages/crdx/src/store/types.ts +++ b/packages/crdx/src/store/types.ts @@ -1,8 +1,16 @@ import { Logger } from '@localfirst/shared' -import { type Action, type Link } from 'graph/index.js' +import { type Action, type Graph, type Link } from 'graph/index.js' +/** + * Redux-style state reducer for graph links. + * + * Store and machine evaluation supply the complete graph being evaluated—including the candidate + * link during dispatch—as the optional fourth argument. It remains optional for compatibility with + * existing reducers. + */ export type Reducer> = ( state: S, link: Link, - extendableLogger?: Logger + extendableLogger?: Logger, + graph?: Graph ) => S diff --git a/packages/crdx/src/sync/receiveMessage.ts b/packages/crdx/src/sync/receiveMessage.ts index ec8e4b3bf..11c403ffc 100644 --- a/packages/crdx/src/sync/receiveMessage.ts +++ b/packages/crdx/src/sync/receiveMessage.ts @@ -1,18 +1,35 @@ -import { assert, Logger } from '@localfirst/shared' +import { Logger } from '@localfirst/shared' import { decryptGraph, type DecryptFn } from 'graph/decrypt.js' -import { getChildMap, invertLinkMap, merge, type Action, type Graph } from 'graph/index.js' +import { + getChildMap, + invertLinkMap, + merge, + type Action, + type EncryptedLink, + type Graph, + type LinkMap, +} from 'graph/index.js' import { createKeyring, type Keyring, type KeysetWithSecrets } from 'keyset/index.js' -import { validate } from 'validator/index.js' -import { type SyncMessage, type SyncState } from './types.js' +import { type Hash } from 'util/index.js' +import { validate, ValidationError } from 'validator/index.js' +import { DEFAULT_SYNC_LIMITS, type SyncLimits, type SyncMessage, type SyncState } from './types.js' /** * Receives a sync message from a peer and updates our sync state accordingly so that * `generateMessage` can determine what information they need. Also possibly updates our graph with * information from them. * + * Wire shape, graph root, resource limits, and advertised topology are checked before decryption. + * After decryption, every received link's authenticated `prev` must match its advertised parents; + * only then is the graph validated and merged. + * + * Invalid input does not throw to the connection actor. It returns the original graph, increments + * `failedSyncCount`, records `reportedError`, restores the prior peer head/need, and clears pending + * links and topology. Limits apply both to each message and to accumulated pending data. + * * @returns A tuple `[graph, state]` containing our updated graph and our updated sync state with * this peer. - * */ + */ export const receiveMessage = ( /** Our current graph */ graph: Graph, @@ -23,64 +40,81 @@ export const receiveMessage = ( /** The sync message they've just sent */ message: SyncMessage, + /** Keys used to decrypt received links. */ keys: KeysetWithSecrets | Keyring, + /** Decryptor used for the reconstructed peer graph. */ decrypt: DecryptFn = decryptGraph, - extendableLogger?: Logger + + /** Optional logger to extend for sync diagnostics. */ + extendableLogger?: Logger, + + /** Defensive message, topology, ciphertext, and traversal bounds. */ + limits: SyncLimits = DEFAULT_SYNC_LIMITS ): [Graph, SyncState] => { - const logger = extendableLogger != null ? extendableLogger.extend('receiveMessage') : new Logger({ moduleName: 'auth:receiveMessage' }) + const logger = + extendableLogger != null + ? extendableLogger.extend('receiveMessage') + : new Logger({ moduleName: 'auth:receiveMessage' }) // if a keyset was provided, wrap it in a keyring const keyring = createKeyring(keys) + try { + validateSyncMessage(message, limits) + if (graph.root !== message.root) { + throw new ValidationError(`Can't sync graphs with different roots`) + } + } catch (error) { + return [graph, recordSyncFailure(prevState, toValidationError(error))] + } + const their = message - // This should never happen, but just as a sanity check - assert(graph.root === their.root, `Can't sync graphs with different roots`) const state: SyncState = { ...prevState, their: { head: their.head, need: their.need ?? [], - encryptedLinks: { ...prevState.their.encryptedLinks, ...their.links }, - parentMap: { ...prevState.their.parentMap, ...their.parentMap }, + encryptedLinks: { ...prevState.their.encryptedLinks, ...(their.links ?? {}) }, + parentMap: { ...prevState.their.parentMap, ...(their.parentMap ?? {}) }, }, } // if we've received links from them, try to reconstruct their graph and merge if (Object.keys(state.their.encryptedLinks).length > 0) { - // reconstruct their graph - const { head } = their + try { + validatePendingTopology(graph, state.their.encryptedLinks, state.their.parentMap, limits) - const ourChildMap = getChildMap(graph) - const theirChildMap = invertLinkMap(state.their.parentMap) - const childMap = { ...ourChildMap, ...theirChildMap } + const ourChildMap = getChildMap(graph) + const theirChildMap = invertLinkMap(state.their.parentMap) + const childMap = mergeLinkMaps(ourChildMap, theirChildMap) + const encryptedLinks = { + ...graph.encryptedLinks, + ...state.their.encryptedLinks, + } + const encryptedGraph = { + ...graph, + head: their.head, + encryptedLinks, + childMap, + } - const encryptedLinks = { - ...graph.encryptedLinks, - ...state.their.encryptedLinks, - } - const encryptedGraph = { - ...graph, - head, - encryptedLinks, - childMap, - } - - const theirGraph = decrypt({ encryptedGraph, keys: keyring }) + const theirGraph = decrypt({ + encryptedGraph, + keys: keyring, + maxTraversalSteps: limits.maxTraversalSteps, + }) + validateAuthenticatedTopology(theirGraph, state.their.encryptedLinks, state.their.parentMap) - // merge with our graph - const mergedGraph = merge(graph, theirGraph) - - // check the integrity of the merged graph - const validation = validate(mergedGraph, undefined, logger) - if (validation.isValid) { + const mergedGraph = merge(graph, theirGraph) + const validation = validate(mergedGraph, undefined, logger) + if (!validation.isValid) throw validation.error graph = mergedGraph - } else { - // We only get here if we've received bad links from them — maliciously, or not. The - // application should monitor `failedSyncCount` and decide not to trust them if it's too high. - state.failedSyncCount += 1 - // Record the error so we can surface it in generateMessage - state.our.reportedError = validation.error + } catch (error) { + const failed = recordSyncFailure(prevState, toValidationError(error)) + state.failedSyncCount = failed.failedSyncCount + state.our = failed.our + state.their = failed.their } // either way, we can discard all pending links @@ -90,3 +124,168 @@ export const receiveMessage = ( return [graph, state] } + +const validateSyncMessage = (message: SyncMessage, limits: SyncLimits) => { + if (!isRecord(message) || typeof message.root !== 'string') { + throw new ValidationError('Sync message has an invalid root') + } + assertHashArray(message.head, 'head') + if (message.need !== undefined) assertHashArray(message.need, 'need') + if ( + message.head.length > limits.maxParentEntries || + (message.need?.length ?? 0) > limits.maxParentEntries + ) { + throw new ValidationError('Sync message exceeds the hash-list limit') + } + if (message.links !== undefined) { + if (!isRecord(message.links)) throw new ValidationError('Sync links must be an object') + for (const [hash, link] of Object.entries(message.links)) { + if ( + typeof hash !== 'string' || + !isRecord(link) || + typeof link.senderPublicKey !== 'string' || + typeof link.recipientPublicKey !== 'string' || + !(link.encryptedBody instanceof Uint8Array) + ) { + throw new ValidationError('Sync message contains an invalid encrypted link') + } + } + const entries = Object.entries(message.links) + const ciphertextBytes = entries.reduce( + (count, [, link]) => count + link.encryptedBody.byteLength, + 0 + ) + if ( + entries.length > limits.maxPendingLinks || + ciphertextBytes > limits.maxPendingCiphertextBytes + ) { + throw new ValidationError('Sync message exceeds the pending-link limit') + } + } + if (message.parentMap !== undefined) { + validateLinkMapShape(message.parentMap) + const entries = Object.entries(message.parentMap) as Array<[Hash, Hash[]]> + const edgeCount = entries.reduce((count, [, parents]) => count + parents.length, 0) + if (entries.length > limits.maxParentEntries || edgeCount > limits.maxParentEdges) { + throw new ValidationError('Sync message exceeds the topology-size limit') + } + } +} + +const validatePendingTopology = ( + graph: Graph, + encryptedLinks: Record, + parentMap: LinkMap, + limits: SyncLimits +) => { + const linkEntries = Object.entries(encryptedLinks) as Array<[Hash, EncryptedLink]> + const parentEntries = Object.entries(parentMap) as Array<[Hash, Hash[]]> + const parentEdgeCount = parentEntries.reduce((count, [, parents]) => count + parents.length, 0) + const ciphertextBytes = linkEntries.reduce( + (count, [, link]) => count + link.encryptedBody.byteLength, + 0 + ) + + if (linkEntries.length > limits.maxPendingLinks) { + throw new ValidationError('Sync message exceeds the pending-link limit') + } + if (ciphertextBytes > limits.maxPendingCiphertextBytes) { + throw new ValidationError('Sync message exceeds the ciphertext-size limit') + } + if (parentEntries.length > limits.maxParentEntries || parentEdgeCount > limits.maxParentEdges) { + throw new ValidationError('Sync message exceeds the topology-size limit') + } + + validateLinkMapShape(parentMap) + for (const [hash] of linkEntries) { + if (parentMap[hash] === undefined) { + throw new ValidationError(`Sync topology is missing parents for '${hash}'`) + } + } + for (const [hash, parents] of parentEntries) { + if (parents.includes(hash) || new Set(parents).size !== parents.length) { + throw new ValidationError(`Sync topology has an invalid parent list for '${hash}'`) + } + const localLink = graph.links[hash] + if (localLink !== undefined && !sameHashSet(localLink.body.prev, parents)) { + throw new ValidationError(`Sync topology contradicts local link '${hash}'`) + } + } + + assertAcyclic(parentMap) +} + +const validateAuthenticatedTopology = ( + graph: Graph, + receivedLinks: Record, + parentMap: LinkMap +) => { + for (const hash of Object.keys(receivedLinks) as Hash[]) { + const link = graph.links[hash] + if (link === undefined || !sameHashSet(link.body.prev, parentMap[hash])) { + throw new ValidationError(`Authenticated parents do not match sync topology for '${hash}'`) + } + } +} + +const assertAcyclic = (parentMap: LinkMap) => { + const nodes = new Set(Object.keys(parentMap) as Hash[]) + const remainingParents = new Map() + const childMap = invertLinkMap(parentMap) + for (const node of nodes) { + remainingParents.set(node, (parentMap[node] ?? []).filter(parent => nodes.has(parent)).length) + } + const queue = [...nodes].filter(node => remainingParents.get(node) === 0) + let visited = 0 + while (queue.length > 0) { + const node = queue.pop()! + visited++ + for (const child of childMap[node] ?? []) { + if (!nodes.has(child)) continue + const remaining = (remainingParents.get(child) ?? 0) - 1 + remainingParents.set(child, remaining) + if (remaining === 0) queue.push(child) + } + } + if (visited !== nodes.size) throw new ValidationError('Sync topology contains a cycle') +} + +const mergeLinkMaps = (ours: LinkMap, theirs: LinkMap): LinkMap => { + const merged: LinkMap = { ...ours } + for (const [hash, links] of Object.entries(theirs) as Array<[Hash, Hash[]]>) { + merged[hash] = [...new Set([...(merged[hash] ?? []), ...links])] + } + return merged +} + +const validateLinkMapShape = (map: LinkMap) => { + if (!isRecord(map)) throw new ValidationError('Sync topology must be an object') + for (const [hash, parents] of Object.entries(map) as Array<[Hash, unknown]>) { + if (typeof hash !== 'string') throw new ValidationError('Sync topology has an invalid hash') + assertHashArray(parents, `parents for '${hash}'`) + } +} + +function assertHashArray(value: unknown, label: string): asserts value is Hash[] { + if (!Array.isArray(value) || value.some(hash => typeof hash !== 'string')) { + throw new ValidationError(`Sync message has an invalid ${label}`) + } +} + +const sameHashSet = (left: readonly Hash[], right: readonly Hash[] | undefined) => + right !== undefined && left.length === right.length && left.every(hash => right.includes(hash)) + +const recordSyncFailure = (state: SyncState, error: ValidationError): SyncState => ({ + ...state, + their: { ...state.their, encryptedLinks: {}, parentMap: {} }, + our: { ...state.our, reportedError: error }, + failedSyncCount: state.failedSyncCount + 1, +}) + +const toValidationError = (error: unknown) => + error instanceof ValidationError + ? error + : new ValidationError((error as Error)?.message ?? 'Invalid sync message', error) + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) diff --git a/packages/crdx/src/sync/test/security.test.ts b/packages/crdx/src/sync/test/security.test.ts new file mode 100644 index 000000000..2d7d441ec --- /dev/null +++ b/packages/crdx/src/sync/test/security.test.ts @@ -0,0 +1,103 @@ +import { append, createGraph, decryptGraph } from 'graph/index.js' +import { + DEFAULT_SYNC_LIMITS, + initSyncState, + receiveMessage, + type SyncMessage, +} from 'sync/index.js' +import { createUser } from 'user/index.js' +import { TEST_GRAPH_KEYS as keys } from 'util/testing/setup.js' +import { describe, expect, it, vi } from 'vitest' + +describe('sync message hardening', () => { + it('rejects cyclic topology before decryption', () => { + const alice = createUser('alice') + const graph = createGraph({ user: alice, name: 'test graph', keys }) + const first = append({ graph, action: { type: 'FIRST' }, user: alice, keys }) + const second = append({ graph: first, action: { type: 'SECOND' }, user: alice, keys }) + const [firstHash] = first.head + const [secondHash] = second.head + const decrypt = vi.fn(decryptGraph) + const message: SyncMessage = { + root: graph.root, + head: second.head, + links: { + [firstHash]: first.encryptedLinks[firstHash], + [secondHash]: second.encryptedLinks[secondHash], + }, + parentMap: { + [firstHash]: [secondHash], + [secondHash]: [firstHash], + }, + } + + const [nextGraph, state] = receiveMessage( + graph, + initSyncState(), + message, + keys, + decrypt + ) + + expect(nextGraph).toBe(graph) + expect(state.failedSyncCount).toBe(1) + expect(state.our.reportedError?.message).toMatch(/cycle/) + expect(state.their.head).toEqual([]) + expect(decrypt).not.toHaveBeenCalled() + }) + + it('rejects advertised parents that do not match authenticated link bodies', () => { + const alice = createUser('alice') + const graph = createGraph({ user: alice, name: 'test graph', keys }) + const localGraph = append({ graph, action: { type: 'FIRST' }, user: alice, keys }) + const remoteGraph = append({ + graph: localGraph, + action: { type: 'SECOND' }, + user: alice, + keys, + }) + const [remoteHash] = remoteGraph.head + const message: SyncMessage = { + root: graph.root, + head: remoteGraph.head, + links: { [remoteHash]: remoteGraph.encryptedLinks[remoteHash] }, + parentMap: { [remoteHash]: [graph.root] }, + } + + const [nextGraph, state] = receiveMessage(localGraph, initSyncState(), message, keys) + + expect(nextGraph).toBe(localGraph) + expect(state.failedSyncCount).toBe(1) + expect(state.our.reportedError?.message).toMatch(/Authenticated parents/) + expect(state.their.head).toEqual([]) + }) + + it('enforces configurable pending-link limits before decryption', () => { + const alice = createUser('alice') + const graph = createGraph({ user: alice, name: 'test graph', keys }) + const remoteGraph = append({ graph, action: { type: 'FIRST' }, user: alice, keys }) + const [remoteHash] = remoteGraph.head + const decrypt = vi.fn(decryptGraph) + const message: SyncMessage = { + root: graph.root, + head: remoteGraph.head, + links: { [remoteHash]: remoteGraph.encryptedLinks[remoteHash] }, + parentMap: { [remoteHash]: [graph.root] }, + } + + const [nextGraph, state] = receiveMessage( + graph, + initSyncState(), + message, + keys, + decrypt, + undefined, + { ...DEFAULT_SYNC_LIMITS, maxPendingLinks: 0 } + ) + + expect(nextGraph).toBe(graph) + expect(state.failedSyncCount).toBe(1) + expect(state.our.reportedError?.message).toMatch(/pending-link limit/) + expect(decrypt).not.toHaveBeenCalled() + }) +}) diff --git a/packages/crdx/src/sync/test/sync.test.ts b/packages/crdx/src/sync/test/sync.test.ts index 069802a11..4a6058e2d 100644 --- a/packages/crdx/src/sync/test/sync.test.ts +++ b/packages/crdx/src/sync/test/sync.test.ts @@ -1,4 +1,5 @@ import { assert } from '@localfirst/shared' +import { asymmetric } from '@localfirst/crypto' import { append, createGraph, headsAreEqual, type Graph } from 'graph/index.js' import { generateMessage, initSyncState, receiveMessage } from 'sync/index.js' import { createUser, type UserWithSecrets } from 'user/index.js' @@ -9,9 +10,7 @@ import { type Network, } from 'util/testing/Network.js' import { TEST_GRAPH_KEYS as keys } from 'util/testing/setup.js' -import { describe, expect, it, vitest } from 'vitest' - -const { setSystemTime } = vitest.useFakeTimers() +import { describe, expect, it } from 'vitest' const setup = setupWithNetwork(keys) @@ -641,17 +640,23 @@ 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 appendInvalidLink = (graph: Graph, user: UserWithSecrets) => { const updatedGraph = append({ graph, action: { type: 'FOO', payload: 'pizza' }, user, keys, }) - setSystemTime(now) + const headHash = updatedGraph.head[0] + const headLink = updatedGraph.links[headHash] + updatedGraph.encryptedLinks[headHash] = { + ...updatedGraph.encryptedLinks[headHash], + encryptedBody: asymmetric.encryptBytes({ + secret: { ...headLink.body, payload: 'tampered' }, + recipientPublicKey: keys.encryption.publicKey, + senderSecretKey: user.keys.encryption.secretKey, + }), + } return updatedGraph } @@ -665,14 +670,14 @@ describe('sync', () => { // no changes yet; 👩🏾 Alice and 🦹‍♀️ Eve are synced up expectToBeSynced(alice, eve) - // 🦹‍♀️ Eve sets her system clock back when appending a link - eve.peer.graph = appendLinkInThePast(eve.peer.graph, eve.user) + // 🦹‍♀️ Eve changes an encrypted link without updating its hash + eve.peer.graph = appendInvalidLink(eve.peer.graph, eve.user) const badHash = eve.peer.graph.head[0] eve.peer.sync() // Since Eve's graph is invalid, the sync fails - expect(() => network.deliverAll()).toThrow(`timestamp can't be earlier`) + expect(() => network.deliverAll()).toThrow(`Head hash does not match`) // They are not synced expectNotToBeSynced(alice, eve) @@ -695,14 +700,14 @@ describe('sync', () => { const TRIES = 10 for (let i = 0; i < TRIES; i++) { - // 🦹‍♀️ Eve sets her system clock back when appending a link - eve.peer.graph = appendLinkInThePast(originalGraph, eve.user) + // 🦹‍♀️ Eve changes an encrypted link without updating its hash + eve.peer.graph = appendInvalidLink(originalGraph, eve.user) const badHash = eve.peer.graph.head[0] eve.peer.sync() // Since Eve's graph is invalid, the sync fails - expect(() => network.deliverAll()).toThrow("timestamp can't be earlier") + expect(() => network.deliverAll()).toThrow(`Head hash does not match`) // They are not synced expectNotToBeSynced(alice, eve) diff --git a/packages/crdx/src/sync/types.ts b/packages/crdx/src/sync/types.ts index 875893c1e..5569a82e7 100644 --- a/packages/crdx/src/sync/types.ts +++ b/packages/crdx/src/sync/types.ts @@ -7,10 +7,10 @@ export type SyncState = { /** Their head as of the last time they sent a sync message. */ head: Hash[] - /** Links they've sent that we haven't added yet (e.g. because we're missing dependencies). */ + /** Received links accumulated for the current validation/merge attempt. */ encryptedLinks: Record - /** The map of hashes they've sent to those links' parents. */ + /** Advertised parents accumulated for the current validation/merge attempt. */ parentMap: LinkMap /** Hashes of links they asked for in the last message. */ @@ -34,18 +34,21 @@ export type SyncState = { /** The head we had in common with this peer the last time we synced. If empty, we haven't synced before. */ lastCommonHead: Hash[] - /** We increment this each time a sync fails because we would have ended up with an invalid graph */ + /** + * Count of rejected syncs, including malformed messages, root mismatches, resource-limit or + * topology failures, decryption failures, and invalid merged graphs. + */ failedSyncCount: number } export type SyncMessage = { - /** Our root. We just send this as a sanity check - if our roots don't match we can't sync. */ + /** Our graph root. A peer with a different root is rejected. */ root: Hash /** Our head at the time of sending. */ head: Hash[] - /** Any links we know we need. */ + /** Encrypted links supplied to the peer. */ links?: Record /** Our most recent hashes and their dependencies. */ @@ -57,3 +60,30 @@ export type SyncMessage = { /** Any errors caused by their last sync message. */ error?: ValidationError } + +/** Defensive per-peer bounds applied before and during sync graph reconstruction. */ +export type SyncLimits = { + /** Maximum number of received links accumulated for one merge attempt. */ + maxPendingLinks: number + + /** Maximum aggregate encrypted-body bytes across pending links. */ + maxPendingCiphertextBytes: number + + /** Maximum parent-map entries; also caps `head` and `need` hash arrays. */ + maxParentEntries: number + + /** Maximum aggregate parent edges. */ + maxParentEdges: number + + /** Maximum work steps forwarded to graph decryption. */ + maxTraversalSteps: number +} + +/** Frozen default defensive bounds for sync messages and graph traversal. */ +export const DEFAULT_SYNC_LIMITS: SyncLimits = Object.freeze({ + maxPendingLinks: 10_000, + maxPendingCiphertextBytes: 64 * 1024 * 1024, + maxParentEntries: 20_000, + maxParentEdges: 50_000, + maxTraversalSteps: 100_000, +}) diff --git a/packages/crdx/src/validator/test/validate.test.ts b/packages/crdx/src/validator/test/validate.test.ts index 2597bb5f0..3759bfd53 100644 --- a/packages/crdx/src/validator/test/validate.test.ts +++ b/packages/crdx/src/validator/test/validate.test.ts @@ -194,7 +194,7 @@ describe('graphs', () => { expect(validate(graph)).not.toBeValid() }) - test(`timestamp out of order`, () => { + test(`allows timestamps out of order while clock validation is disabled`, () => { const IN_THE_PAST = new Date('2020-01-01').getTime() const graph = setupGraph() @@ -209,10 +209,10 @@ describe('graphs', () => { }) setSystemTime(now) - expect(validate(graph2)).not.toBeValid() + expect(validate(graph2)).toBeValid() }) - test(`timestamp in the future`, () => { + test(`allows timestamps in the future while clock validation is disabled`, () => { const IN_THE_FUTURE = new Date(`10000-01-01`).getTime() // NOTE: test will begin to fail 7,978 years from now const graph = setupGraph() @@ -227,7 +227,7 @@ describe('graphs', () => { }) setSystemTime(now) - expect(validate(graph2)).not.toBeValid() + expect(validate(graph2)).toBeValid() }) test(`timestamp in the future but within fuzz factor`, () => { diff --git a/packages/crdx/src/validator/validators.ts b/packages/crdx/src/validator/validators.ts index 01c86db2f..0fa741d38 100644 --- a/packages/crdx/src/validator/validators.ts +++ b/packages/crdx/src/validator/validators.ts @@ -1,9 +1,6 @@ -import { memoize } from '@localfirst/shared' -import { hash } from '@localfirst/crypto' -import { ROOT, TIMESTAMP_FUZZ_FACTOR_MS, VALID } from 'constants.js' +import { ROOT, VALID } from 'constants.js' import { getRoot } from 'graph/getRoot.js' import { hashEncryptedLink } from 'graph/hashLink.js' -import type { Graph, Link } from 'index.js' import { ValidationError, type ValidatorSet } from './types.js' const _validators: ValidatorSet = { @@ -51,7 +48,12 @@ const _validators: ValidatorSet = { hasNoPrevLink ? `Non-ROOT links must have predecessors` // not ROOT but has no prev link : 'The link referenced by the graph `root` property must be a ROOT link' // not ROOT but is the graph root - return fail(message, { hash: link.hash, isTheGraphRoot, hasRootType, predececessorHashes: link.body.prev }) + return fail(message, { + hash: link.hash, + isTheGraphRoot, + hasRootType, + predececessorHashes: link.body.prev, + }) }, // NOTE FROM ISLA: Commenting this out for now to make sure we don't have any unintended consequences but this @@ -72,7 +74,7 @@ const _validators: ValidatorSet = { // // timestamp can't be earlier than any previous link's timestamp // // NOTE FROM ISLA: we are allowing a small bit of wiggle room for link timestamps to be ahead of - // // their prececessor(s) to account for slight mismatches in system clocks across systems + // // their prececessor(s) to account for slight mismatches in system clocks across systems // // (particularly QSS vs clients) // for (const hash of link.body.prev) { // const prevLink = graph.links[hash] @@ -96,14 +98,8 @@ export const fail = (msg: string, args?: any) => { } } -const memoizeFunctionMap = (source: ValidatorSet) => { - const result = {} as ValidatorSet - const memoizeResolver = (link: Link, graph: Graph) => { - return `${link.hash}:${graph.root}` - } - - for (const key in source) result[key] = memoize(source[key], memoizeResolver) - return result -} - -export const validators = memoizeFunctionMap(_validators) +/** + * Built-in validators run against every supplied graph and link. They are intentionally not + * memoized so changed graph contents or topology cannot reuse stale validation results. + */ +export const validators = _validators