Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* Messages can now be relayed using QSS [#2805](https://github.com/TryQuiet/quiet/issues/2805)
* Messages can be retrieved from QSS stores [#2806](https://github.com/TryQuiet/quiet/issues/2806)
* Profile photos are now uploaded via IPFS [#3048](https://github.com/TryQuiet/quiet/issues/3048)
* Create an invite lockbox when using QSS [#3057](https://github.com/TryQuiet/quiet/issues/3057)
* Requests iOS notification permissions when app launches [#3079](https://github.com/TryQuiet/quiet/issues/3079)

### Fixes
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Handles invite-related chain operations
* Handles crypto-related chain operations
*/
import * as bs58 from 'bs58'

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { SigChain } from '../../sigchain'
import { createLogger } from '../../../common/logger'
import { RoleName } from '../roles/roles'
import { hash, randomBytes } from '@localfirst/crypto'
import * as uint8arrays from 'uint8arrays'
import { EncryptionScopeType, InviteLockboxMetadata } from './types'

const logger = createLogger('auth:services:lockbox.spec')

describe('lockbox', () => {
let adminSigChain: SigChain
let seed: string
let salt: string
let generatedKeys: InviteLockboxMetadata

it('should initialize a new sigchain and be admin', () => {
adminSigChain = SigChain.create('test', 'user')
expect(adminSigChain).toBeDefined()
expect(adminSigChain.context).toBeDefined()
expect(adminSigChain.team!.teamName).toBe('test')
expect(adminSigChain.user.userName).toBe('user')
expect(adminSigChain.roles.amIMemberOfRole(RoleName.ADMIN)).toBe(true)
expect(adminSigChain.roles.amIMemberOfRole(RoleName.MEMBER)).toBe(true)
})
it('should create keys from seed and salt', () => {
seed = uint8arrays.toString(randomBytes(32), 'hex')
salt = uint8arrays.toString(randomBytes(32), 'hex')
generatedKeys = adminSigChain.lockbox.generateLockboxKeys(seed, salt)
expect(generatedKeys.id).toBe(hash(salt, seed))
expect(generatedKeys.keys.name).toBe(generatedKeys.id)
expect(generatedKeys.keys.generation).toBe(0)
})
it('should create a lockbox encrypted to our generated keys', () => {
const lockboxes = adminSigChain.lockbox.createInviteLockboxes(seed, salt)
expect(lockboxes).toHaveLength(1)
const lockbox = lockboxes[0]
expect(lockbox.recipient.name).toBe(generatedKeys.id)
expect(lockbox.recipient.generation).toBe(0)
expect(lockbox.contents.name).toBe(RoleName.MEMBER)
expect(lockbox.contents.type).toBe(EncryptionScopeType.ROLE)

const keysFromLockbox = adminSigChain.team?.allKeys(generatedKeys.keys)
expect(keysFromLockbox).toBeDefined()
expect(keysFromLockbox!['ROLE'][RoleName.MEMBER].length).toBe(1)
})
})
57 changes: 57 additions & 0 deletions packages/backend/src/nest/auth/services/crypto/lockbox.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Handles lockbox-related chain operations
*/
import { InviteLockboxMetadata } from './types'
import { ChainServiceBase } from '../chainServiceBase'
import { SigChain } from '../../sigchain'
import { Lockbox, createKeyset } from '@localfirst/auth'
import { createLogger } from '../../../common/logger'
import { RoleName } from '../roles/roles'
import { hash } from '@localfirst/crypto'

const logger = createLogger('auth:lockbox')

class LockboxService extends ChainServiceBase {
constructor(sigChain: SigChain) {
super(sigChain)
}

/**
* Generate a keyset from an invite seed for encrypting/decrypting a lockbox
*
* @param seed Invite seed generated by the owner (this is the string included in the invite link)
* @param salt Random salt generated at the time of invite creation, used to create a key scope name
* @returns Object containing the key scope name and the keys created using the seed
*/
public generateLockboxKeys(seed: string, salt: string): InviteLockboxMetadata {
logger.debug('Generating keys from invite seed')
const name = hash(salt, seed)
const keys = createKeyset({ type: 'INVITE_LOCKBOX', name }, seed)
return {
id: name,
keys,
}
}

/**
* Create a lockbox containing role keys accessible using the invite seed
*
* @param seed Invite seed generated by the owner (this is the string included in the invite link)
* @param salt Random salt generated at the time of invite creation, used to create a key scope name
* @param roleName Role whose keys will be encrypted inside the lockbox (default = member)
* @returns Lockbox containing role keys encrypted using the invite seed
*/
public createInviteLockboxes(seed: string, salt: string, roleName: string | RoleName = RoleName.MEMBER): Lockbox[] {
logger.debug(`Creating lockbox containing ${roleName} role keys encrypted to invite-based keys`)
if (this.sigChain.team == null) {
throw new Error('Error while creating invite lockbox - No team')
}
if (!this.sigChain.roles.memberHasRole(this.sigChain.context.user.userId, roleName)) {
throw new Error(`Error while creating invite lockbox - User is missing ${roleName} role`)
}
const inviteKeyset = this.generateLockboxKeys(seed, salt)
return this.sigChain.team.createLockbox(roleName, inviteKeyset.keys)
}
}

export { LockboxService }
7 changes: 6 additions & 1 deletion packages/backend/src/nest/auth/services/crypto/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { KeyMetadata } from '@localfirst/crdx/'
import { Base58 } from '@localfirst/auth'
import { Base58, KeysetWithSecrets } from '@localfirst/auth'

export enum EncryptionScopeType {
ROLE = 'ROLE',
Expand Down Expand Up @@ -39,3 +39,8 @@ export type Signature = {
signature: Base58
author: KeyMetadata
}

export type InviteLockboxMetadata = {
id: string
keys: KeysetWithSecrets
}
9 changes: 8 additions & 1 deletion packages/backend/src/nest/auth/sigchain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ServerService } from './services/members/server.service'
import { RoleName } from './services/roles/roles'
import { createLogger } from '../common/logger'
import EventEmitter from 'events'
import { LockboxService } from './services/crypto/lockbox.service'

const logger = createLogger('auth:sigchain')

Expand All @@ -23,6 +24,7 @@ class SigChain extends EventEmitter {
private _invites: InviteService | null = null
private _crypto: CryptoService | null = null
private _server: ServerService | null = null
private _lockbox: LockboxService | null = null

private constructor(context: auth.MemberContext | auth.InviteeMemberContext) {
super()
Expand Down Expand Up @@ -85,7 +87,7 @@ class SigChain extends EventEmitter {
*/
public static create(teamName: string, username: string, userId?: string): SigChain {
const localUser = UserService.create(username, userId)
const team: auth.Team = auth.createTeam(teamName, localUser)
const team: auth.Team = auth.createTeam(teamName, localUser, undefined, { selfAssignableRoles: [RoleName.MEMBER] })
const adminContext = {
user: localUser.user,
device: localUser.device,
Expand Down Expand Up @@ -158,6 +160,7 @@ class SigChain extends EventEmitter {
this._invites = new InviteService(this)
this._crypto = new CryptoService(this)
this._server = new ServerService(this)
this._lockbox = new LockboxService(this)
}

public save(): Uint8Array {
Expand Down Expand Up @@ -191,6 +194,10 @@ class SigChain extends EventEmitter {
return this._server!
}

get lockbox(): LockboxService {
return this._lockbox!
}

static get lfa(): typeof auth {
return auth
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,6 @@ import { privateKeyFromRaw } from '@libp2p/crypto/keys'
import { SigChainService } from '../auth/sigchain.service'
import { QSSService } from '../qss/qss.service'
import { RoleName } from '../auth/services/roles/roles'
import { SigChain } from '../auth/sigchain'
import { QSSOperationResult, QSSEvents } from '../qss/qss.types'

/**
* A monolith service that handles lots of events received from the state-manager.
Expand Down Expand Up @@ -721,7 +719,11 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI
} else {
try {
const newInvite = this.sigChainService.getActiveChain().invites.createLongLivedUserInvite()

const qssInitStatus = await this.qssService.getQssInitStatus()
// create the lockboxes using invite-based keys for users to self-assign the MEMBER role
if (qssInitStatus.qssEnabled) {
this.sigChainService.activeChain.lockbox.createInviteLockboxes(newInvite.seed, newInvite.salt)
}
await this.sigChainService.saveChain(this.sigChainService.activeChainTeamName)
this.serverIoProvider.io.emit(SocketEvents.CREATED_LONG_LIVED_LFA_INVITE, newInvite)
callback({ valid: false, newInvite })
Expand Down
Loading