Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a92b7f4
Enhance invitation admission logic and tests
adrastaea Jul 24, 2026
e321417
Enforce authenticated graph authorship
adrastaea Jul 24, 2026
666b3a1
Enforce invitation admission kind
adrastaea Jul 24, 2026
b430a96
Bind invitation proofs to identity claims
adrastaea Jul 24, 2026
bde832e
Encrypt invitation acceptance bundles
adrastaea Jul 24, 2026
9d0a611
Validate exact effective invitation admissions
adrastaea Jul 24, 2026
fb82e69
Enforce global team identity invariants
adrastaea Jul 24, 2026
74f6a3a
Delay joined until session authentication
adrastaea Jul 24, 2026
a7fb99e
Guard optional member role assignment
adrastaea Jul 24, 2026
33b76e4
Align team tests with metadata and role semantics
adrastaea Jul 24, 2026
8b9b7b2
Restore CRDX successor exports
adrastaea Jul 24, 2026
2d5d735
Revalidate CRDX graph integrity on every pass
adrastaea Jul 24, 2026
4603b9d
Classify connection decryption failures
adrastaea Jul 24, 2026
8e45692
Emit remote connection errors
adrastaea Jul 24, 2026
79f99bb
Retain team keys across synchronized rotations
adrastaea Jul 24, 2026
fb9225d
Keep administrator grants admin-only
adrastaea Jul 24, 2026
df3f6a6
Migrate Quiet sandbox to bound invitation proofs
adrastaea Jul 24, 2026
092b923
add tests showing merge behavior
adrastaea Jul 29, 2026
b990495
feat(auth): enhance invitation acceptance validation and team graph d…
adrastaea Jul 31, 2026
4d7c3d9
fix(auth): verify invitation proofs during reduction
adrastaea Jul 31, 2026
50a176d
fix(auth): normalize formatted invitation seeds
adrastaea Jul 31, 2026
a007d4d
fix(auth): bind invitation acceptance to team root
adrastaea Jul 31, 2026
ba4eb94
fix(auth): validate authors at their causal frontier
adrastaea Jul 31, 2026
61890e9
fix(auth): harden sync graph traversal
adrastaea Jul 31, 2026
2cf6da0
fix(auth): reject malformed identity requests
adrastaea Jul 31, 2026
069519f
fix(auth): emit session events after context commit
adrastaea Jul 31, 2026
0480768
docs(auth): update protocol API documentation
adrastaea Jul 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions demos/automerge-repo-todos/src/components/JoinTeam.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
4 changes: 1 addition & 3 deletions demos/automerge-repo-todos/src/components/TeamAdmin.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 = () => {
Expand Down
9 changes: 6 additions & 3 deletions demos/automerge-repo-todos/src/util/parseInvitationCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
96 changes: 77 additions & 19 deletions demos/quiet-sandbox/src/auth/services/invites/inviteService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -71,6 +131,4 @@ class InviteService extends BaseChainService {
}
}

export {
InviteService
}
export { InviteService }
3 changes: 2 additions & 1 deletion demos/quiet-sandbox/src/auth/services/members/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Keyset, LocalUserContext, ProofOfInvitation } from "@localfirst/auth"
import { Base58, Keyset, LocalUserContext, ProofOfInvitation } from "@localfirst/auth"

export type MemberSearchOptions = {
includeRemoved: boolean
Expand All @@ -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 }
44 changes: 34 additions & 10 deletions demos/quiet-sandbox/src/auth/services/members/userService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand All @@ -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,
}
}

Expand All @@ -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 []
}
Expand All @@ -62,14 +88,12 @@ 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 {
return SigChain.lfa.redactUser(user)
}
}

export {
UserService
}
export { UserService }
3 changes: 2 additions & 1 deletion demos/quiet-sandbox/src/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
6 changes: 4 additions & 2 deletions demos/quiet-sandbox/src/scripts/test_auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)}`)
console.log(`Decrypted: ${newUsersChain.crypto.decryptAndVerify(encryptedAndSigned.encrypted, encryptedAndSigned.signature, newUsersContext)}`)
10 changes: 8 additions & 2 deletions packages/auth-provider-automerge-repo/src/AuthProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,8 +298,14 @@ export class AuthProvider extends EventEmitter<AuthProviderEvents> {
}

/**
* 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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',
})
Expand Down Expand Up @@ -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,
})
Expand All @@ -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,
})
Expand Down Expand Up @@ -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,
})
Expand Down
Loading