diff --git a/CHANGELOG.md b/CHANGELOG.md index eac559ccc7..642a7eb32a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ * Moved some responsibilities of identity management to the backend ([#2602](https://github.com/TryQuiet/quiet/issues/2602)) * Added auth submodule in preparation for future encyrption work ([#2623](https://github.com/TryQuiet/quiet/issues/2623)) +### New features + +* Adds basic sigchain functions ([#2649](https://github.com/TryQuiet/quiet/pull/2649)) + ### Fixes * Fixed memory leak associated with autoUpdater ([#2606](https://github.com/TryQuiet/quiet/issues/2606)) diff --git a/package.json b/package.json index 18ccbd06ed..44f05801c4 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,9 @@ "e2e:linux:run": "lerna run --scope e2e-tests test --", "prepare": "husky", "lint-staged": "lerna run lint-staged", - "watch": "lerna watch -- lerna run build --since", - "build:auth": "cd ./3rd-party/auth && pnpm install && pnpm build" + "build:auth": "cd ./3rd-party/auth && pnpm install && pnpm build", + "bootstrap": "npm run build:auth && lerna bootstrap", + "watch": "lerna watch -- lerna run build --since" }, "engines": { "node": "18.12.1", diff --git a/packages/backend/package-lock.json b/packages/backend/package-lock.json index 03ddc5e1eb..23d54892f8 100644 --- a/packages/backend/package-lock.json +++ b/packages/backend/package-lock.json @@ -11,12 +11,14 @@ "dependencies": { "@chainsafe/libp2p-gossipsub": "6.1.0", "@chainsafe/libp2p-noise": "11.0.0", - "@localfirst/auth": "file:../../3rd-party/auth/packages/auth", + "@localfirst/auth": "file:../../3rd-party/auth/packages/auth/dist", + "@localfirst/crdx": "file:../../3rd-party/auth/packages/crdx/dist", "@nestjs/common": "^10.2.10", "@nestjs/core": "^10.2.10", "@nestjs/platform-express": "^10.2.10", "@peculiar/webcrypto": "1.4.3", "abortable-iterator": "^3.0.0", + "bs58": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.13.1", "cli-table": "^0.3.6", @@ -30,6 +32,7 @@ "fastq": "^1.17.1", "fetch-retry": "^6.0.0", "get-port": "^5.1.1", + "getmac": "^6.6.0", "go-ipfs": "npm:mocked-go-ipfs@0.17.0", "http-server": "^0.12.3", "https-proxy-agent": "^5.0.0", @@ -60,6 +63,7 @@ "socks-proxy-agent": "^5.0.0", "string-replace-loader": "3.1.0", "ts-jest-resolver": "^2.0.0", + "utf-8-validate": "^5.0.2", "validator": "^13.11.0" }, "devDependencies": { @@ -113,6 +117,25 @@ "node": ">=18" } }, + "../../3rd-party/auth/packages/auth/dist": {}, + "../../3rd-party/auth/packages/crdx": { + "name": "@localfirst/crdx", + "version": "6.0.0-alpha.6", + "license": "MIT", + "dependencies": { + "@herbcaudill/eventemitter42": "^0.3.1", + "@localfirst/crypto": "workspace:*", + "@localfirst/shared": "workspace:*", + "@paralleldrive/cuid2": "^2.2.2", + "@types/lodash-es": "^4.17.12", + "lodash-es": "^4.17.21", + "msgpackr": "^1.10.0" + }, + "engines": { + "node": ">=18" + } + }, + "../../3rd-party/auth/packages/crdx/dist": {}, "../common": { "name": "@quiet/common", "version": "1.4.0-alpha.0", @@ -4570,7 +4593,11 @@ } }, "node_modules/@localfirst/auth": { - "resolved": "../../3rd-party/auth/packages/auth", + "resolved": "../../3rd-party/auth/packages/auth/dist", + "link": true + }, + "node_modules/@localfirst/crdx": { + "resolved": "../../3rd-party/auth/packages/crdx/dist", "link": true }, "node_modules/@lukeed/csprng": { @@ -8798,12 +8825,18 @@ } }, "node_modules/bs58": { - "version": "4.0.1", - "license": "MIT", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", + "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", "dependencies": { - "base-x": "^3.0.2" + "base-x": "^5.0.0" } }, + "node_modules/bs58/node_modules/base-x": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.0.tgz", + "integrity": "sha512-sMW3VGSX1QWVFA6l8U62MLKz29rRfpTlYdCqLdpLo1/Yd4zZwSbnUaDfciIAowAqvq7YFnWq9hrhdg1KYgc1lQ==" + }, "node_modules/bser": { "version": "2.1.1", "license": "Apache-2.0", @@ -11381,6 +11414,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/getmac": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/getmac/-/getmac-6.6.0.tgz", + "integrity": "sha512-o1sq9o5QTfwUyWy7Dao1YGZOI9lN+xzEr9Ul36hyOxFrtuwgLG1ff7oiBEfRDxOrB3jJ2u4jKEs5KMSElyE0cQ==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, "node_modules/getpass": { "version": "0.1.7", "license": "MIT", @@ -13057,6 +13101,14 @@ "node": ">=14.0.0" } }, + "node_modules/ipfs-log/node_modules/bs58": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", + "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==", + "dependencies": { + "base-x": "^3.0.2" + } + }, "node_modules/ipfs-log/node_modules/iso-random-stream": { "version": "1.1.2", "license": "MIT", @@ -17408,6 +17460,14 @@ "npm": ">=3.0.0" } }, + "node_modules/libp2p-crypto-secp256k1/node_modules/bs58": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", + "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==", + "dependencies": { + "base-x": "^3.0.2" + } + }, "node_modules/libp2p-crypto-secp256k1/node_modules/buffer": { "version": "5.7.1", "funding": [ @@ -23136,6 +23196,18 @@ "node": ">=4" } }, + "node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/utf8-byte-length": { "version": "1.0.4", "license": "WTFPL" @@ -26687,17 +26759,10 @@ } }, "@localfirst/auth": { - "version": "file:../../3rd-party/auth/packages/auth", - "requires": { - "@herbcaudill/eventemitter42": "^0.3.1", - "@localfirst/crdx": "workspace:*", - "@localfirst/crypto": "workspace:*", - "@localfirst/shared": "workspace:*", - "@paralleldrive/cuid2": "^2.2.2", - "lodash-es": "^4.17.21", - "msgpackr": "^1.10.0", - "xstate": "^5.9.1" - } + "version": "file:../../3rd-party/auth/packages/auth/dist" + }, + "@localfirst/crdx": { + "version": "file:../../3rd-party/auth/packages/crdx/dist" }, "@lukeed/csprng": { "version": "1.1.0" @@ -29708,9 +29773,18 @@ } }, "bs58": { - "version": "4.0.1", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", + "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", "requires": { - "base-x": "^3.0.2" + "base-x": "^5.0.0" + }, + "dependencies": { + "base-x": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.0.tgz", + "integrity": "sha512-sMW3VGSX1QWVFA6l8U62MLKz29rRfpTlYdCqLdpLo1/Yd4zZwSbnUaDfciIAowAqvq7YFnWq9hrhdg1KYgc1lQ==" + } } }, "bser": { @@ -31395,6 +31469,11 @@ "get-intrinsic": "^1.1.1" } }, + "getmac": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/getmac/-/getmac-6.6.0.tgz", + "integrity": "sha512-o1sq9o5QTfwUyWy7Dao1YGZOI9lN+xzEr9Ul36hyOxFrtuwgLG1ff7oiBEfRDxOrB3jJ2u4jKEs5KMSElyE0cQ==" + }, "getpass": { "version": "0.1.7", "optional": true, @@ -32458,6 +32537,14 @@ "p-whilst": "^2.1.0" }, "dependencies": { + "bs58": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", + "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==", + "requires": { + "base-x": "^3.0.2" + } + }, "iso-random-stream": { "version": "1.1.2", "requires": { @@ -35299,6 +35386,14 @@ "secp256k1": "^3.6.2" }, "dependencies": { + "bs58": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", + "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==", + "requires": { + "base-x": "^3.0.2" + } + }, "buffer": { "version": "5.7.1", "requires": { @@ -38739,6 +38834,14 @@ "nan": "^2.14.2" } }, + "utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "requires": { + "node-gyp-build": "^4.3.0" + } + }, "utf8-byte-length": { "version": "1.0.4" }, diff --git a/packages/backend/package.json b/packages/backend/package.json index a3be3e9fe2..8707a58644 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -87,9 +87,10 @@ "yargs": "^17.1.0" }, "dependencies": { - "@localfirst/auth": "file:../../3rd-party/auth/packages/auth", "@chainsafe/libp2p-gossipsub": "6.1.0", "@chainsafe/libp2p-noise": "11.0.0", + "@localfirst/auth": "file:../../3rd-party/auth/packages/auth/dist", + "@localfirst/crdx": "file:../../3rd-party/auth/packages/crdx/dist", "@nestjs/common": "^10.2.10", "@nestjs/core": "^10.2.10", "@nestjs/platform-express": "^10.2.10", @@ -99,6 +100,7 @@ "@quiet/logger": "^2.0.2-alpha.0", "@quiet/types": "^2.0.2-alpha.1", "abortable-iterator": "^3.0.0", + "bs58": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.13.1", "cli-table": "^0.3.6", @@ -112,6 +114,7 @@ "fastq": "^1.17.1", "fetch-retry": "^6.0.0", "get-port": "^5.1.1", + "getmac": "^6.6.0", "go-ipfs": "npm:mocked-go-ipfs@0.17.0", "http-server": "^0.12.3", "https-proxy-agent": "^5.0.0", @@ -142,6 +145,7 @@ "socks-proxy-agent": "^5.0.0", "string-replace-loader": "3.1.0", "ts-jest-resolver": "^2.0.0", + "utf-8-validate": "^5.0.2", "validator": "^13.11.0" }, "overrides": { diff --git a/packages/backend/src/nest/auth/services/chainServiceBase.ts b/packages/backend/src/nest/auth/services/chainServiceBase.ts new file mode 100644 index 0000000000..164ccbd050 --- /dev/null +++ b/packages/backend/src/nest/auth/services/chainServiceBase.ts @@ -0,0 +1,14 @@ +import { SigChain } from '../sigchain' +import { createLogger } from '../../common/logger' + +const logger = createLogger('auth:baseChainService') + +class ChainServiceBase { + protected constructor(protected sigChain: SigChain) {} + + public static init(sigChain: SigChain, ...params: any[]): ChainServiceBase { + throw new Error('init not implemented') + } +} + +export { ChainServiceBase } diff --git a/packages/backend/src/nest/auth/services/crypto/crypto.service.ts b/packages/backend/src/nest/auth/services/crypto/crypto.service.ts new file mode 100644 index 0000000000..84cb39afcd --- /dev/null +++ b/packages/backend/src/nest/auth/services/crypto/crypto.service.ts @@ -0,0 +1,161 @@ +/** + * Handles invite-related chain operations + */ +import * as bs58 from 'bs58' + +import { EncryptedAndSignedPayload, EncryptedPayload, EncryptionScope, EncryptionScopeType } from './types' +import { ChainServiceBase } from '../chainServiceBase' +import { SigChain } from '../../sigchain' +import { + asymmetric, + Base58, + Keyset, + KeysetWithSecrets, + LocalUserContext, + Member, + SignedEnvelope, +} from '@localfirst/auth' +import { DEFAULT_SEARCH_OPTIONS, MemberSearchOptions } from '../members/types' +import { ChannelService } from '../roles/channel.service' +import { createLogger } from '../../../common/logger' + +const logger = createLogger('auth:cryptoService') + +class CryptoService extends ChainServiceBase { + public static init(sigChain: SigChain): CryptoService { + return new CryptoService(sigChain) + } + + // TODO: Can we get other members' keys by generation? + public getPublicKeysForMembersById( + memberIds: string[], + searchOptions: MemberSearchOptions = DEFAULT_SEARCH_OPTIONS + ): Keyset[] { + const members = this.sigChain.users.getUsersById(memberIds, searchOptions) + return members.map((member: Member) => { + return member.keys + }) + } + + public encryptAndSign(message: any, scope: EncryptionScope, context: LocalUserContext): EncryptedAndSignedPayload { + let encryptedPayload: EncryptedPayload + switch (scope.type) { + // Symmetrical Encryption Types + case EncryptionScopeType.CHANNEL: + case EncryptionScopeType.ROLE: + case EncryptionScopeType.TEAM: + encryptedPayload = this.symEncrypt(message, scope) + break + // Asymmetrical Encryption Types + case EncryptionScopeType.USER: + encryptedPayload = this.asymUserEncrypt(message, scope, context) + break + // Unknown Type + default: + throw new Error(`Unknown encryption type ${scope.type} provided!`) + } + + const signature = this.sigChain.team.sign(encryptedPayload.contents) + + return { + encrypted: encryptedPayload, + signature, + ts: Date.now(), + username: context.user.userName, + } + } + + private symEncrypt(message: any, scope: EncryptionScope): EncryptedPayload { + if (scope.type != EncryptionScopeType.TEAM && scope.name == null) { + throw new Error(`Must provide a scope name when encryption scope is set to ${scope.type}`) + } + + const envelope = this.sigChain.team.encrypt(message, scope.name) + return { + contents: bs58.default.encode(envelope.contents) as Base58, + scope: { + ...scope, + generation: envelope.recipient.generation, + }, + } + } + + private asymUserEncrypt(message: any, scope: EncryptionScope, context: LocalUserContext): EncryptedPayload { + if (scope.name == null) { + throw new Error(`Must provide a user ID when encryption scope is set to ${scope.type}`) + } + + const recipientKeys = this.getPublicKeysForMembersById([scope.name]) + const recipientKey = recipientKeys[0].encryption + const senderKey = context.user.keys.encryption.secretKey + const generation = recipientKeys[0].generation + + const encryptedContents = asymmetric.encrypt({ + secret: message, + senderSecretKey: senderKey, + recipientPublicKey: recipientKey, + }) + + return { + contents: encryptedContents, + scope: { + ...scope, + generation, + }, + } + } + + public decryptAndVerify(encrypted: EncryptedPayload, signature: SignedEnvelope, context: LocalUserContext): any { + const isValid = this.sigChain.team.verify(signature) + if (!isValid) { + throw new Error(`Couldn't verify signature on message`) + } + + switch (encrypted.scope.type) { + // Symmetrical Encryption Types + case EncryptionScopeType.CHANNEL: + case EncryptionScopeType.ROLE: + case EncryptionScopeType.TEAM: + return this.symDecrypt(encrypted) + // Asymmetrical Encryption Types + case EncryptionScopeType.USER: + return this.asymUserDecrypt(encrypted, signature, context) + // Unknown Type + default: + throw new Error(`Unknown encryption scope type ${encrypted.scope.type}`) + } + } + + private symDecrypt(encrypted: EncryptedPayload): any { + if (encrypted.scope.type !== EncryptionScopeType.TEAM && encrypted.scope.name == null) { + throw new Error(`Must provide a scope name when encryption scope is set to ${encrypted.scope.type}`) + } + + return this.sigChain.team.decrypt({ + contents: bs58.default.decode(encrypted.contents), + recipient: { + ...encrypted.scope, + // you don't need a name on the scope when encrypting but you need one for decrypting because of how LFA searches for keys in lockboxes + name: encrypted.scope.type === EncryptionScopeType.TEAM ? EncryptionScopeType.TEAM : encrypted.scope.name!, + }, + }) + } + + private asymUserDecrypt(encrypted: EncryptedPayload, signature: SignedEnvelope, context: LocalUserContext): any { + if (encrypted.scope.name == null) { + throw new Error(`Must provide a user ID when encryption scope is set to ${encrypted.scope.type}`) + } + + const senderKeys = this.sigChain.crypto.getPublicKeysForMembersById([signature.author.name]) + const recipientKey = context.user.keys.encryption.secretKey + const senderKey = senderKeys[0].encryption + + return asymmetric.decrypt({ + cipher: encrypted.contents, + senderPublicKey: senderKey, + recipientSecretKey: recipientKey, + }) + } +} + +export { CryptoService } diff --git a/packages/backend/src/nest/auth/services/crypto/types.ts b/packages/backend/src/nest/auth/services/crypto/types.ts new file mode 100644 index 0000000000..3e15f0d7e0 --- /dev/null +++ b/packages/backend/src/nest/auth/services/crypto/types.ts @@ -0,0 +1,27 @@ +import { Base58, SignedEnvelope } from '@localfirst/auth' + +export enum EncryptionScopeType { + ROLE = 'ROLE', + CHANNEL = 'CHANNEL', + USER = 'USER', + TEAM = 'TEAM', +} + +export type EncryptionScope = { + type: EncryptionScopeType + name?: string +} + +export type EncryptedPayload = { + contents: Base58 + scope: EncryptionScope & { + generation: number + } +} + +export type EncryptedAndSignedPayload = { + encrypted: EncryptedPayload + signature: SignedEnvelope + ts: number + username: string +} diff --git a/packages/backend/src/nest/auth/services/invites/invite.service.spec.ts b/packages/backend/src/nest/auth/services/invites/invite.service.spec.ts new file mode 100644 index 0000000000..f98c39f3c8 --- /dev/null +++ b/packages/backend/src/nest/auth/services/invites/invite.service.spec.ts @@ -0,0 +1,99 @@ +import { jest } from '@jest/globals' +import { SigChain } from '../../sigchain' +import { SigChainManager } from '../../sigchainManager' +import { createLogger } from '../../../common/logger' +import { device, InviteResult, LocalUserContext } from '@localfirst/auth' +import { RoleName } from '..//roles/roles' +import { UserService } from '../members/user.service' +import { InviteService } from './invite.service' +import { DeviceService } from '../members/device.service' + +const logger = createLogger('auth:services:invite.spec') + +describe('invites', () => { + let adminSigChain: SigChain + let adminContext: LocalUserContext + let newMemberSigChain: SigChain + let newMemberContext: LocalUserContext + it('should initialize a new sigchain and be admin', () => { + ;({ sigChain: adminSigChain, context: adminContext } = SigChain.create('test', 'user')) + expect(adminSigChain).toBeDefined() + expect(adminContext).toBeDefined() + expect(adminSigChain.team.teamName).toBe('test') + expect(adminContext.user.userName).toBe('user') + expect(adminSigChain.roles.amIMemberOfRole(adminContext, RoleName.ADMIN)).toBe(true) + expect(adminSigChain.roles.amIMemberOfRole(adminContext, RoleName.MEMBER)).toBe(true) + }) + it('admin should generate an invite and it be added to team graph', () => { + const newInvite = adminSigChain.invites.createUserInvite() + expect(newInvite).toBeDefined() + expect(adminSigChain.invites.getAllInvites().length).toBe(1) + expect(adminSigChain.invites.getById(newInvite.id)).toBeDefined() + }) + it('admin should generate an invite seed and create a new user from it', () => { + const invite = adminSigChain.invites.createUserInvite() + expect(invite).toBeDefined() + const prospectiveMember = UserService.createFromInviteSeed('user2', invite.seed) + const inviteProof = InviteService.generateProof(invite.seed) + expect(inviteProof).toBeDefined() + expect(adminSigChain.invites.validateProof(inviteProof)).toBe(true) + expect(prospectiveMember).toBeDefined() + ;({ sigChain: newMemberSigChain, context: newMemberContext } = SigChain.join( + prospectiveMember.context, + adminSigChain.team.save(), + adminSigChain.team.teamKeyring() + )) + expect(newMemberSigChain).toBeDefined() + expect(newMemberContext).toBeDefined() + expect(newMemberContext.user.userName).toBe('user2') + expect(newMemberContext.user.userId).not.toBe(adminContext.user.userId) + expect(newMemberSigChain.roles.amIMemberOfRole(newMemberContext, RoleName.MEMBER)).toBe(false) + expect(newMemberSigChain.roles.amIMemberOfRole(newMemberContext, RoleName.ADMIN)).toBe(false) + expect( + adminSigChain.invites.admitMemberFromInvite( + inviteProof, + newMemberContext.user.userName, + newMemberContext.user.userId, + newMemberContext.user.keys + ) + ).toBeDefined() + expect(adminSigChain.roles.amIMemberOfRole(newMemberContext, RoleName.MEMBER)).toBe(true) + }) + it('admin should be able to revoke an invite', () => { + const inviteToRevoke = adminSigChain.invites.createUserInvite() + expect(inviteToRevoke).toBeDefined() + adminSigChain.invites.revoke(inviteToRevoke.id) + const InvalidInviteProof = InviteService.generateProof(inviteToRevoke.seed) + expect(InvalidInviteProof).toBeDefined() + expect(adminSigChain.invites.validateProof(InvalidInviteProof)).toBe(false) + }) + it('admitting a new member with an invalid invite should fail', () => { + const invalidInviteProof = InviteService.generateProof('invalidseed') + expect(invalidInviteProof).toBeDefined() + expect(adminSigChain.invites.validateProof(invalidInviteProof)).toBe(false) + const prospectiveMember = UserService.createFromInviteSeed('user3', 'invalidseed') + expect(prospectiveMember).toBeDefined() + const newSigchain = SigChain.join( + prospectiveMember.context, + adminSigChain.team.save(), + adminSigChain.team.teamKeyring() + ) + expect(() => { + adminSigChain.invites.admitMemberFromInvite( + invalidInviteProof, + prospectiveMember.context.user.userName, + prospectiveMember.context.user.userId, + prospectiveMember.publicKeys + ) + }).toThrowError() + }) + it('should invite device', () => { + const newDevice = DeviceService.generateDeviceForUser(adminContext.user.userId) + const deviceInvite = adminSigChain.invites.createDeviceInvite() + const inviteProof = InviteService.generateProof(deviceInvite.seed) + expect(inviteProof).toBeDefined() + expect(adminSigChain.invites.validateProof(inviteProof)).toBe(true) + adminSigChain.invites.admitDeviceFromInvite(inviteProof, DeviceService.redactDevice(newDevice)) + expect(adminSigChain.team.hasDevice(newDevice.deviceId)).toBe(true) + }) +}) diff --git a/packages/backend/src/nest/auth/services/invites/invite.service.ts b/packages/backend/src/nest/auth/services/invites/invite.service.ts new file mode 100644 index 0000000000..dbe220e893 --- /dev/null +++ b/packages/backend/src/nest/auth/services/invites/invite.service.ts @@ -0,0 +1,98 @@ +/** + * Handles invite-related chain operations + */ + +import { ChainServiceBase } from '../chainServiceBase' +import { ValidationResult } from '@localfirst/crdx' +import { + Base58, + FirstUseDevice, + InvitationState, + InviteResult, + Keyset, + ProofOfInvitation, + UnixTimestamp, +} from '@localfirst/auth' +import { SigChain } from '../../sigchain' +import { RoleName } from '../roles/roles' +import { createLogger } from '../../../common/logger' + +const logger = createLogger('auth:inviteService') + +export const DEFAULT_MAX_USES = 1 +export const DEFAULT_INVITATION_VALID_FOR_MS = 604_800_000 // 1 week + +class InviteService extends ChainServiceBase { + public static init(sigChain: SigChain): InviteService { + return new InviteService(sigChain) + } + + public createUserInvite( + validForMs: number = DEFAULT_INVITATION_VALID_FOR_MS, + maxUses: number = DEFAULT_MAX_USES, + seed?: string + ): InviteResult { + const expiration = (Date.now() + validForMs) as UnixTimestamp + const invitation: InviteResult = this.sigChain.team.inviteMember({ + seed, + expiration, + maxUses, + }) + return invitation + } + + public createDeviceInvite(validForMs: number = DEFAULT_INVITATION_VALID_FOR_MS, seed?: string): InviteResult { + const expiration = (Date.now() + validForMs) as UnixTimestamp + const invitation: InviteResult = this.sigChain.team.inviteDevice({ + expiration, + seed, + }) + return invitation + } + + public revoke(id: string) { + this.sigChain.team.revokeInvitation(id) + } + + public getById(id: Base58): InvitationState { + return this.sigChain.team.getInvitation(id) + } + + public static generateProof(seed: string): ProofOfInvitation { + return SigChain.lfa.invitation.generateProof(seed) + } + + public validateProof(proof: ProofOfInvitation): boolean { + const validationResult = this.sigChain.team.validateInvitation(proof) as ValidationResult + if (!validationResult.isValid) { + logger.warn(`Proof was invalid or was on an invalid invitation`, validationResult.error) + return false + } + return true + } + + public admitUser(proof: ProofOfInvitation, username: string, publicKeys: Keyset) { + this.sigChain.team.admitMember(proof, publicKeys, username) + } + + public admitMemberFromInvite(proof: ProofOfInvitation, username: string, userId: string, publicKeys: Keyset): string { + this.sigChain.team.admitMember(proof, publicKeys, username) + this.sigChain.roles.addMember(userId, RoleName.MEMBER) + return username + } + + public admitDeviceFromInvite(proof: ProofOfInvitation, firstUseDevice: FirstUseDevice): void { + this.sigChain.team.admitDevice(proof, firstUseDevice) + } + + public getAllInvites(): InvitationState[] { + const inviteMap = this.sigChain.team.invitations() + const invites: InvitationState[] = [] + for (const invite of Object.entries(inviteMap)) { + invites.push(invite[1]) + } + return invites + } +} + +export { InviteService } diff --git a/packages/backend/src/nest/auth/services/members/device.service.spec.ts b/packages/backend/src/nest/auth/services/members/device.service.spec.ts new file mode 100644 index 0000000000..df8a731291 --- /dev/null +++ b/packages/backend/src/nest/auth/services/members/device.service.spec.ts @@ -0,0 +1,37 @@ +import { SigChain } from '../../sigchain' +import { createLogger } from '../../../common/logger' +import { DeviceWithSecrets, LocalUserContext } from '3rd-party/auth/packages/auth/dist' +import { RoleName } from '..//roles/roles' +import { DeviceService } from './device.service' + +const logger = createLogger('auth:services:device.spec') + +describe('invites', () => { + let adminSigChain: SigChain + let adminContext: LocalUserContext + let newDevice: DeviceWithSecrets + + it('should initialize a new sigchain and be admin', () => { + ;({ sigChain: adminSigChain, context: adminContext } = SigChain.create('test', 'user')) + expect(adminSigChain).toBeDefined() + expect(adminContext).toBeDefined() + expect(adminSigChain.team.teamName).toBe('test') + expect(adminContext.user.userName).toBe('user') + expect(adminSigChain.roles.amIMemberOfRole(adminContext, RoleName.ADMIN)).toBe(true) + expect(adminSigChain.roles.amIMemberOfRole(adminContext, RoleName.MEMBER)).toBe(true) + }) + it('sigchain should contain admin device', () => { + const adminDeviceName = DeviceService.determineDeviceName() + adminSigChain.team.hasDevice(adminContext.device.deviceId) + }) + it('should generate a new device', () => { + newDevice = DeviceService.generateDeviceForUser(adminContext.user.userId) + expect(newDevice).toBeDefined() + }) + it('should redactDevice', () => { + const redactedDevice = DeviceService.redactDevice(newDevice) + expect(redactedDevice).toBeDefined() + expect(redactedDevice.deviceId).toBe(newDevice.deviceId) + expect(redactedDevice.deviceName).toBe(newDevice.deviceName) + }) +}) diff --git a/packages/backend/src/nest/auth/services/members/device.service.ts b/packages/backend/src/nest/auth/services/members/device.service.ts new file mode 100644 index 0000000000..18dff4cf5b --- /dev/null +++ b/packages/backend/src/nest/auth/services/members/device.service.ts @@ -0,0 +1,47 @@ +/** + * Handles device-related chain operations + */ + +import getMAC from 'getmac' +import { ChainServiceBase } from '../chainServiceBase' +import { Device, DeviceWithSecrets, redactDevice } from '@localfirst/auth' +import { SigChain } from '../../sigchain' +import { createLogger } from '../../../common/logger' + +const logger = createLogger('auth:deviceService') +class DeviceService extends ChainServiceBase { + public static init(sigChain: SigChain): DeviceService { + return new DeviceService(sigChain) + } + + /** + * Generate a brand new QuietDevice for a given User ID + * + * @param userId User ID that this device is associated with + * @returns A newly generated QuietDevice instance + */ + public static generateDeviceForUser(userId: string): DeviceWithSecrets { + const params = { + userId, + deviceName: DeviceService.determineDeviceName(), + } + + return SigChain.lfa.createDevice(params) + } + + /** + * Get an identifier for the current device + * + * @returns Formatted MAC address of the current device + */ + public static determineDeviceName(): string { + const mac = getMAC() + return mac.replace(/:/g, '') + } + + public static redactDevice(device: DeviceWithSecrets): Device { + return redactDevice(device) + } +} + +export { DeviceService } diff --git a/packages/backend/src/nest/auth/services/members/types.ts b/packages/backend/src/nest/auth/services/members/types.ts new file mode 100644 index 0000000000..d499756838 --- /dev/null +++ b/packages/backend/src/nest/auth/services/members/types.ts @@ -0,0 +1,14 @@ +import { Keyset, LocalUserContext, ProofOfInvitation } from '@localfirst/auth' + +export type MemberSearchOptions = { + includeRemoved: boolean + throwOnMissing: boolean +} + +export type ProspectiveUser = { + context: LocalUserContext + inviteProof: ProofOfInvitation + publicKeys: Keyset +} + +export const DEFAULT_SEARCH_OPTIONS: MemberSearchOptions = { includeRemoved: false, throwOnMissing: true } diff --git a/packages/backend/src/nest/auth/services/members/user.service.spec.ts b/packages/backend/src/nest/auth/services/members/user.service.spec.ts new file mode 100644 index 0000000000..fc1729c0da --- /dev/null +++ b/packages/backend/src/nest/auth/services/members/user.service.spec.ts @@ -0,0 +1,46 @@ +import { jest } from '@jest/globals' +import { SigChain } from '../../sigchain' +import { SigChainManager } from '../../sigchainManager' +import { createLogger } from '../../../common/logger' +import { device, InviteResult, LocalUserContext } from '@localfirst/auth' +import { RoleName } from '..//roles/roles' +import { UserService } from './user.service' +import { DeviceService } from '../members/device.service' + +const logger = createLogger('auth:services:invite.spec') + +describe('invites', () => { + let adminSigChain: SigChain + let adminContext: LocalUserContext + it('should initialize a new sigchain and be admin', () => { + ;({ sigChain: adminSigChain, context: adminContext } = SigChain.create('test', 'user')) + expect(adminSigChain).toBeDefined() + expect(adminContext).toBeDefined() + expect(adminSigChain.team.teamName).toBe('test') + expect(adminContext.user.userName).toBe('user') + expect(adminSigChain.roles.amIMemberOfRole(adminContext, RoleName.ADMIN)).toBe(true) + expect(adminSigChain.roles.amIMemberOfRole(adminContext, RoleName.MEMBER)).toBe(true) + }) + it('should get keys', () => { + const keys = adminSigChain.users.getKeys() + expect(keys).toBeDefined() + }) + it('get all members', () => { + const users = adminSigChain.users.getAllUsers() + expect(users).toBeDefined() + }) + it('get admin member by id', () => { + const users = adminSigChain.users.getUsersById([adminContext.user.userId]) + expect(users.map(u => u.userId)).toContain(adminContext.user.userId) + }) + it('get admin member by name', () => { + const user = adminSigChain.users.getUserByName(adminContext.user.userName) + expect(user!.userName).toEqual(adminContext.user.userName) + }) + it('should redact user', () => { + const redactedUser = UserService.redactUser(adminContext.user) + expect(redactedUser).toBeDefined() + expect(redactedUser.userId).toBe(adminContext.user.userId) + expect(redactedUser.userName).toBe(adminContext.user.userName) + }) +}) diff --git a/packages/backend/src/nest/auth/services/members/user.service.ts b/packages/backend/src/nest/auth/services/members/user.service.ts new file mode 100644 index 0000000000..6eca4c7917 --- /dev/null +++ b/packages/backend/src/nest/auth/services/members/user.service.ts @@ -0,0 +1,86 @@ +/** + * Handles user-related chain operations + */ + +//import { KeyMap } from '../../../../../../packages/auth/dist/team/selectors/keyMap' +import { ChainServiceBase } from '../chainServiceBase' +import { ProspectiveUser, MemberSearchOptions, DEFAULT_SEARCH_OPTIONS } from './types' +import { DeviceWithSecrets, LocalUserContext, Member, User, UserWithSecrets } from '@localfirst/auth' +import { SigChain } from '../../sigchain' +import { DeviceService } from './device.service' +import { InviteService } from '../invites/invite.service' +import { KeyMap } from '@localfirst/auth/team/selectors/keyMap' +import { createLogger } from '../../../common/logger' + +const logger = createLogger('auth:userService') + +class UserService extends ChainServiceBase { + public static init(sigChain: SigChain): UserService { + return new UserService(sigChain) + } + + /** + * 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 + */ + public static create(name: string, id?: string): LocalUserContext { + const user: UserWithSecrets = SigChain.lfa.createUser(name, id) + const device: DeviceWithSecrets = DeviceService.generateDeviceForUser(user.userId) + + return { + user, + device, + } + } + + /** + * Generates a new prospective user from an invite seed + * + * @param name The username + * @param seed The invite seed + * @returns ProspectiveUser instance + */ + 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 + + return { + context, + inviteProof, + publicKeys, + } + } + + /** + * Get + */ + public getKeys(): KeyMap { + return this.sigChain.team.allKeys() + } + + public getAllUsers(): Member[] { + return this.sigChain.team.members() + } + + public getUsersById(memberIds: string[], options: MemberSearchOptions = DEFAULT_SEARCH_OPTIONS): Member[] { + if (memberIds.length === 0) { + return [] + } + + return this.sigChain.team.members(memberIds, options) + } + + public getUserByName(memberName: string): Member | undefined { + return this.getAllUsers().find(member => member.userName === memberName) + } + + public static redactUser(user: UserWithSecrets): User { + return SigChain.lfa.redactUser(user) + } +} + +export { UserService } diff --git a/packages/backend/src/nest/auth/services/roles/channel.service.spec.ts b/packages/backend/src/nest/auth/services/roles/channel.service.spec.ts new file mode 100644 index 0000000000..fd04454299 --- /dev/null +++ b/packages/backend/src/nest/auth/services/roles/channel.service.spec.ts @@ -0,0 +1,84 @@ +import { SigChain } from '../../sigchain' +import { createLogger } from '../../../common/logger' +import { LocalUserContext } from '@localfirst/auth' +import { RoleName, Channel } from './roles' +import { UserService } from '../members/user.service' +import { InviteService } from '../invites/invite.service' + +const logger = createLogger('auth:services:invite.spec') + +const privateChannelName = 'testChannel' + +describe('invites', () => { + let adminSigChain: SigChain + let adminContext: LocalUserContext + let newMemberSigChain: SigChain + let newMemberContext: LocalUserContext + + it('should initialize a new sigchain and be admin', () => { + ;({ sigChain: adminSigChain, context: adminContext } = SigChain.create('test', 'user')) + expect(adminSigChain).toBeDefined() + expect(adminContext).toBeDefined() + expect(adminSigChain.team.teamName).toBe('test') + expect(adminContext.user.userName).toBe('user') + expect(adminSigChain.roles.amIMemberOfRole(adminContext, RoleName.ADMIN)).toBe(true) + expect(adminSigChain.roles.amIMemberOfRole(adminContext, RoleName.MEMBER)).toBe(true) + }) + it('should create a private channel', () => { + const privateChannel = adminSigChain.channels.createPrivateChannel(privateChannelName, adminContext) + expect(privateChannel).toBeDefined() + }) + it('admin should generate an invite seed and admit a new user from it', () => { + const invite = adminSigChain.invites.createUserInvite() + expect(invite).toBeDefined() + const prospectiveMember = UserService.createFromInviteSeed('user2', invite.seed) + const inviteProof = InviteService.generateProof(invite.seed) + expect(inviteProof).toBeDefined() + expect(adminSigChain.invites.validateProof(inviteProof)).toBe(true) + expect(prospectiveMember).toBeDefined() + ;({ sigChain: newMemberSigChain, context: newMemberContext } = SigChain.join( + prospectiveMember.context, + adminSigChain.team.save(), + adminSigChain.team.teamKeyring() + )) + expect(newMemberSigChain).toBeDefined() + expect(newMemberContext).toBeDefined() + expect(newMemberContext.user.userName).toBe('user2') + expect(newMemberContext.user.userId).not.toBe(adminContext.user.userId) + expect(newMemberSigChain.roles.amIMemberOfRole(newMemberContext, RoleName.MEMBER)).toBe(false) + expect(newMemberSigChain.roles.amIMemberOfRole(newMemberContext, RoleName.ADMIN)).toBe(false) + expect( + adminSigChain.invites.admitMemberFromInvite( + inviteProof, + newMemberContext.user.userName, + newMemberContext.user.userId, + newMemberContext.user.keys + ) + ).toBeDefined() + expect(adminSigChain.roles.amIMemberOfRole(newMemberContext, RoleName.MEMBER)).toBe(true) + }) + it('should add the new member to the private channel', () => { + const privateChannel = adminSigChain.channels.getChannel(privateChannelName, adminContext) + adminSigChain.channels.addMemberToPrivateChannel(newMemberContext.user.userId, privateChannel.channelName) + expect(adminSigChain.channels.memberInChannel(newMemberContext.user.userId, privateChannel.channelName)).toBe(true) + }) + it('should remove the new member from the private channel', () => { + const privateChannel = adminSigChain.channels.getChannel(privateChannelName, adminContext) + adminSigChain.channels.revokePrivateChannelMembership(newMemberContext.user.userId, privateChannel.channelName) + expect(adminSigChain.channels.getChannels(newMemberContext, true).length).toBe(0) + expect(adminSigChain.channels.memberInChannel(newMemberContext.user.userId, privateChannel.channelName)).toBe(false) + }) + it('should delete channel', () => { + const privateChannel = adminSigChain.channels.getChannel(privateChannelName, adminContext) + adminSigChain.channels.deletePrivateChannel(privateChannel.channelName) + expect(adminSigChain.channels.getChannels(adminContext).length).toBe(0) + }) + it('should create new channel and then leave it', () => { + const channel = adminSigChain.channels.createPrivateChannel(privateChannelName, adminContext) + expect(channel).toBeDefined() + adminSigChain.channels.leaveChannel(channel.channelName, adminContext) + expect(adminSigChain.channels.memberInChannel(adminContext.user.userId, channel.channelName)).toBe(false) + expect(adminSigChain.channels.getChannels(adminContext).length).toBe(1) + expect(adminSigChain.channels.getChannels(adminContext, true).length).toBe(0) + }) +}) diff --git a/packages/backend/src/nest/auth/services/roles/channel.service.ts b/packages/backend/src/nest/auth/services/roles/channel.service.ts new file mode 100644 index 0000000000..036c37d87b --- /dev/null +++ b/packages/backend/src/nest/auth/services/roles/channel.service.ts @@ -0,0 +1,103 @@ +/** + * Handles channel-related chain operations + */ + +import { LocalUserContext, Role } from '@localfirst/auth' +import { SigChain } from '../../sigchain' +import { ChainServiceBase } from '../chainServiceBase' +import { Channel, QuietRole } from './roles' +import { createLogger } from '../../../common/logger' + +const logger = createLogger('auth:channelService') + +const CHANNEL_ROLE_KEY_PREFIX = 'priv_chan_' + +class ChannelService extends ChainServiceBase { + public static init(sigChain: SigChain): ChannelService { + return new ChannelService(sigChain) + } + + // TODO: figure out permissions + public createPrivateChannel(channelName: string, context: LocalUserContext): Channel { + logger.info(`Creating private channel role with name ${channelName}`) + this.sigChain.roles.create(ChannelService.getPrivateChannelRoleName(channelName)) + this.addMemberToPrivateChannel(context.user.userId, channelName) + + return this.getChannel(channelName, context) + } + + public addMemberToPrivateChannel(userId: string, channelName: string) { + logger.info(`Adding member with ID ${userId} to private channel role with name ${channelName}`) + this.sigChain.roles.addMember(userId, ChannelService.getPrivateChannelRoleName(channelName)) + } + + public revokePrivateChannelMembership(userId: string, channelName: string) { + logger.info(`Removing member with ID ${userId} from private channel with name ${channelName}`) + this.sigChain.roles.revokeMembership(userId, ChannelService.getPrivateChannelRoleName(channelName)) + } + + public deletePrivateChannel(channelName: string) { + logger.info(`Deleting private channel with name ${channelName}`) + this.sigChain.roles.delete(ChannelService.getPrivateChannelRoleName(channelName)) + } + + public leaveChannel(channelName: string, context: LocalUserContext) { + logger.info(`Leaving private channel with name ${channelName}`) + this.revokePrivateChannelMembership(context.user.userId, channelName) + } + + public getChannel(channelName: string, context: LocalUserContext): Channel { + const role = this.sigChain.roles.getRole(ChannelService.getPrivateChannelRoleName(channelName), context) + return this.roleToChannel(role, channelName, context) + } + + public getChannels(context: LocalUserContext, haveAccessOnly: boolean = false): Channel[] { + const allRoles = this.sigChain.roles.getAllRoles(context, haveAccessOnly) + const allChannels = allRoles + .filter((role: QuietRole) => this.isRoleChannel(context, role.roleName)) + .map((role: QuietRole) => + this.roleToChannel(role, ChannelService.getPrivateChannelNameFromRoleName(role.roleName), context) + ) + + return allChannels + } + + public memberInChannel(userId: string, channelName: string): boolean { + const roleName = ChannelService.getPrivateChannelRoleName(channelName) + return this.sigChain.roles.memberHasRole(userId, roleName) + } + + public amIInChannel(context: LocalUserContext, channelName: string): boolean { + return this.memberInChannel(context.user.userId, channelName) + } + + public isRoleChannel(context: LocalUserContext, roleName: string): boolean + public isRoleChannel(context: LocalUserContext, role: QuietRole | Role): boolean + public isRoleChannel(context: LocalUserContext, roleNameOrRole: string | QuietRole | Role): boolean { + let roleName: string + if (typeof roleNameOrRole === 'string') { + roleName = roleNameOrRole + } else { + roleName = roleNameOrRole.roleName + } + + return roleName.startsWith(CHANNEL_ROLE_KEY_PREFIX) + } + + private roleToChannel(role: QuietRole, channelName: string, context: LocalUserContext): Channel { + return { + ...role, + channelName, + } as Channel + } + + public static getPrivateChannelRoleName(channelName: string): string { + return `${CHANNEL_ROLE_KEY_PREFIX}${channelName}` + } + + public static getPrivateChannelNameFromRoleName(roleName: string): string { + return roleName.split(CHANNEL_ROLE_KEY_PREFIX)[1] + } +} + +export { ChannelService } diff --git a/packages/backend/src/nest/auth/services/roles/permissions.ts b/packages/backend/src/nest/auth/services/roles/permissions.ts new file mode 100644 index 0000000000..2ee83d74b5 --- /dev/null +++ b/packages/backend/src/nest/auth/services/roles/permissions.ts @@ -0,0 +1,3 @@ +export enum Permissions { + MODIFIABLE_MEMBERSHIP = 'modifiable-membership', +} diff --git a/packages/backend/src/nest/auth/services/roles/role.service.ts b/packages/backend/src/nest/auth/services/roles/role.service.ts new file mode 100644 index 0000000000..80154bbfc5 --- /dev/null +++ b/packages/backend/src/nest/auth/services/roles/role.service.ts @@ -0,0 +1,109 @@ +/** + * Handles role-related chain operations + */ + +import { SigChain } from '../../sigchain' +import { ChainServiceBase } from '../chainServiceBase' +import { Permissions } from './permissions' +import { QuietRole, RoleName } from './roles' +import { LocalUserContext, Member, PermissionsMap, Role } from '@localfirst/auth' +import { createLogger } from '../../../common/logger' +import { QuietLogger } from '@quiet/logger' + +class RoleService extends ChainServiceBase { + private readonly logger: QuietLogger + + constructor(sigChain: SigChain) { + super(sigChain) + this.logger = createLogger(`auth:roleService(${sigChain.team.teamName})`) + } + + public static init(sigChain: SigChain): RoleService { + return new RoleService(sigChain) + } + + // TODO: figure out permissions + public create(roleName: RoleName | string, permissions: PermissionsMap = {}, staticMembership: boolean = false) { + this.logger.info(`Adding new role with name ${roleName}`) + if (!staticMembership) { + permissions[Permissions.MODIFIABLE_MEMBERSHIP] = true + } + + const role: Role = { + roleName, + permissions, + } + + this.sigChain.team.addRole(role) + } + + // TODO: figure out permissions + public createWithMembers( + roleName: RoleName | string, + memberIdsForRole: string[], + permissions: PermissionsMap = {}, + staticMembership: boolean = false + ) { + this.create(roleName, permissions, staticMembership) + for (const memberId of memberIdsForRole) { + this.addMember(memberId, roleName) + } + } + + public addMember(memberId: string, roleName: string) { + this.logger.info(`Adding member with ID ${memberId} to role ${roleName}`) + this.sigChain.team.addMemberRole(memberId, roleName) + } + + public revokeMembership(memberId: string, roleName: string) { + this.logger.info(`Revoking role ${roleName} for member with ID ${memberId}`) + this.sigChain.team.removeMemberRole(memberId, roleName) + } + + public delete(roleName: string) { + this.logger.info(`Removing role with name ${roleName}`) + this.sigChain.team.removeRole(roleName) + } + + public getRole(roleName: string, context: LocalUserContext): QuietRole { + const role = this.sigChain.team.roles(roleName) + if (!role) { + throw new Error(`No role found with name ${roleName}`) + } + + return this.roleToQuietRole(role, context) + } + + public getAllRoles(context: LocalUserContext, haveAccessOnly: boolean = false): QuietRole[] { + const allRoles = this.sigChain.team.roles().map(role => this.roleToQuietRole(role, context)) + if (haveAccessOnly) { + return allRoles.filter((role: QuietRole) => role.hasRole === true) + } + + return allRoles + } + + public memberHasRole(memberId: string, roleName: string): boolean { + return this.sigChain.team.memberHasRole(memberId, roleName) + } + + public amIMemberOfRole(context: LocalUserContext, roleName: string): boolean { + return this.memberHasRole(context.user.userId, roleName) + } + + public getMembersForRole(roleName: string): Member[] { + return this.sigChain.team.membersInRole(roleName) + } + + private roleToQuietRole(role: Role, context: LocalUserContext): QuietRole { + const members = this.sigChain.roles.getMembersForRole(role.roleName) + const hasRole = this.sigChain.roles.amIMemberOfRole(context, role.roleName) + return { + ...role, + members, + hasRole, + } + } +} + +export { RoleService } diff --git a/packages/backend/src/nest/auth/services/roles/roles.ts b/packages/backend/src/nest/auth/services/roles/roles.ts new file mode 100644 index 0000000000..d2e19ee32e --- /dev/null +++ b/packages/backend/src/nest/auth/services/roles/roles.ts @@ -0,0 +1,31 @@ +import { Member, Role } from '@localfirst/auth' + +export enum RoleName { + ADMIN = 'admin', + MEMBER = 'member', +} + +export type RoleMemberInfo = { + id: string + name: string +} + +export type BaseQuietRole = Role & { + hasRole?: boolean +} + +export type QuietRole = BaseQuietRole & { + members: Member[] +} + +export type TruncatedQuietRole = BaseQuietRole & { + members: RoleMemberInfo[] +} + +export type BaseChannel = { + channelName: string +} + +export type Channel = QuietRole & BaseChannel + +export type TruncatedChannel = TruncatedQuietRole & BaseChannel diff --git a/packages/backend/src/nest/auth/sigchain.spec.ts b/packages/backend/src/nest/auth/sigchain.spec.ts new file mode 100644 index 0000000000..59ca5c0e44 --- /dev/null +++ b/packages/backend/src/nest/auth/sigchain.spec.ts @@ -0,0 +1,30 @@ +import { jest } from '@jest/globals' +import { SigChain } from './sigchain' +import { SigChainManager } from './sigchainManager' +import { createLogger } from '../common/logger' +import { LocalUserContext } from '3rd-party/auth/packages/auth/dist' +import exp from 'constants' +import { RoleName } from './services/roles/roles' +import { UserService } from './services/members/user.service' + +const logger = createLogger('auth:sigchainManager.spec') + +describe('SigChain', () => { + let sigChain: SigChain + let sigChain2: SigChain + let context: LocalUserContext + let context2: LocalUserContext + + it('should initialize a new sigchain and be admin', () => { + ;({ sigChain, context } = SigChain.create('test', 'user')) + expect(sigChain).toBeDefined() + expect(context).toBeDefined() + expect(sigChain.team.teamName).toBe('test') + expect(context.user.userName).toBe('user') + expect(sigChain.roles.amIMemberOfRole(context, RoleName.ADMIN)).toBe(true) + expect(sigChain.roles.amIMemberOfRole(context, RoleName.MEMBER)).toBe(true) + }) + it('admin should not have a role that does not exist', () => { + expect(sigChain.roles.amIMemberOfRole(context, 'nonexistent')).toBe(false) + }) +}) diff --git a/packages/backend/src/nest/auth/sigchain.ts b/packages/backend/src/nest/auth/sigchain.ts new file mode 100644 index 0000000000..6e7d22098f --- /dev/null +++ b/packages/backend/src/nest/auth/sigchain.ts @@ -0,0 +1,135 @@ +/** + * Handles generating the chain and aggregating all chain operations + */ + +import * as auth from '@localfirst/auth' +import { LoadedSigChain } from './types' +import { UserService } from './services/members/user.service' +import { RoleService } from './services/roles/role.service' +import { ChannelService } from './services/roles/channel.service' +import { DeviceService } from './services/members/device.service' +import { InviteService } from './services/invites/invite.service' +import { CryptoService } from './services/crypto/crypto.service' +import { RoleName } from './services/roles/roles' +import { createLogger } from '../common/logger' + +const logger = createLogger('auth:sigchain') + +class SigChain { + private _team: auth.Team + private _users: UserService | null = null + private _devices: DeviceService | null = null + private _roles: RoleService | null = null + private _channels: ChannelService | null = null + private _invites: InviteService | null = null + private _crypto: CryptoService | null = null + + private constructor(team: auth.Team) { + this._team = team + } + + /** + * Create a brand new SigChain with a given name and also generate the initial user with a given name + * + * @param teamName Name of the team we are creating + * @param username Username of the initial user we are generating + * @returns LoadedSigChain instance with the new SigChain and user context + */ + public static create(teamName: string, username: string): LoadedSigChain { + const context = UserService.create(username) + const team: auth.Team = this.lfa.createTeam(teamName, context) + const sigChain = this.init(team) + + // sigChain.roles.createWithMembers(RoleName.ADMIN, [context.user.userId]) + sigChain.roles.createWithMembers(RoleName.MEMBER, [context.user.userId]) + + return { + sigChain, + context, + } + } + + public static createFromTeam(team: auth.Team, context: auth.LocalUserContext): LoadedSigChain { + const sigChain = this.init(team) + return { + context, + sigChain, + } + } + + // TODO: Is this the right signature for this method? + public static join( + context: auth.LocalUserContext, + serializedTeam: Uint8Array, + teamKeyRing: auth.Keyring + ): LoadedSigChain { + const team: auth.Team = this.lfa.loadTeam(serializedTeam, context, teamKeyRing) + team.join(teamKeyRing) + + const sigChain = this.init(team) + + return { + sigChain, + context, + } + } + + private static init(team: auth.Team): SigChain { + const sigChain = new SigChain(team) + sigChain.initServices() + + return sigChain + } + + private initServices() { + this._users = UserService.init(this) + this._devices = DeviceService.init(this) + this._roles = RoleService.init(this) + this._channels = ChannelService.init(this) + this._invites = InviteService.init(this) + this._crypto = CryptoService.init(this) + } + + // TODO: persist to storage + public persist(): Uint8Array { + return this.team.save() // this doesn't actually do anything but create the new state to save + } + + get team(): auth.Team { + return this._team + } + + get teamGraph(): auth.TeamGraph { + return this._team.graph + } + + get users(): UserService { + return this._users! + } + + get roles(): RoleService { + return this._roles! + } + + get channels(): ChannelService { + return this._channels! + } + + get devices(): DeviceService { + return this._devices! + } + + get invites(): InviteService { + return this._invites! + } + + get crypto(): CryptoService { + return this._crypto! + } + + static get lfa(): typeof auth { + return auth + } +} + +export { SigChain } diff --git a/packages/backend/src/nest/auth/sigchainManager.spec.ts b/packages/backend/src/nest/auth/sigchainManager.spec.ts new file mode 100644 index 0000000000..3f86ce6f1c --- /dev/null +++ b/packages/backend/src/nest/auth/sigchainManager.spec.ts @@ -0,0 +1,43 @@ +import { jest } from '@jest/globals' +import { SigChain } from './sigchain' +import { SigChainManager } from './sigchainManager' +import { createLogger } from '../common/logger' + +const logger = createLogger('auth:sigchainManager.spec') + +describe('SigChainManager', () => { + let sigChainManager: SigChainManager + + it('should initialize a new SigChainManager', () => { + sigChainManager = SigChainManager.init() + expect(sigChainManager).toBeDefined() + }) + it('should throw an error when trying to get an active chain without setting one', () => { + expect(() => sigChainManager.getActiveChain()).toThrowError() + }) + it('should throw an error when trying to set an active chain that does not exist', () => { + expect(() => sigChainManager.setActiveChain('nonexistent')).toThrowError() + }) + it('should add a new chain and it not be active if not set to be', () => { + const { context, sigChain } = sigChainManager.createChain('test', 'user', false) + expect(sigChainManager.activeChainTeamName).toBeUndefined() + expect(() => sigChainManager.getActiveChain()).toThrowError() + sigChainManager.setActiveChain('test') + expect(sigChainManager.getActiveChain()).toBe(sigChain) + }) + it('should add a new chain and it be active if set to be', () => { + const { context, sigChain } = sigChainManager.createChain('test2', 'user2', true) + expect(sigChainManager.getActiveChain()).toBe(sigChain) + const prevSigChain = sigChainManager.getChainByTeamName('test') + expect(prevSigChain).toBeDefined() + expect(prevSigChain).not.toBe(sigChain) + }) + it('should delete nonactive chain without changing active chain', () => { + sigChainManager.deleteChain('test') + expect(() => sigChainManager.getChainByTeamName('test')).toThrowError() + }) + it('should delete active chain and set active chain to undefined', () => { + sigChainManager.deleteChain('test2') + expect(sigChainManager.activeChainTeamName).toBeUndefined() + }) +}) diff --git a/packages/backend/src/nest/auth/sigchainManager.ts b/packages/backend/src/nest/auth/sigchainManager.ts new file mode 100644 index 0000000000..372016b25b --- /dev/null +++ b/packages/backend/src/nest/auth/sigchainManager.ts @@ -0,0 +1,96 @@ +/** + * Manages the chain(s) and makes them accesible across the application + */ + +import { SigChain } from './sigchain' +import { createLogger } from '../common/logger' +import { LoadedSigChain } from './types' + +const logger = createLogger('auth:sigchainManager') + +class SigChainManager { + public chains: Map = new Map() + public activeChainTeamName: string | undefined + private static _instance: SigChainManager | undefined + + private constructor() {} + + public static init(): SigChainManager { + if (SigChainManager._instance !== undefined) { + throw new Error(`SigChainManager already initialized!`) + } + SigChainManager._instance = new SigChainManager() + + return SigChainManager.instance + } + + public getActiveChain(): SigChain { + if (this.activeChainTeamName == undefined) { + throw new Error(`No active chain found!`) + } + + return this.getChainByTeamName(this.activeChainTeamName) + } + + public setActiveChain(teamName: string): SigChain { + if (!this.chains.has(teamName)) { + throw new Error(`No chain found for team ${teamName}, can't set to active!`) + } + + this.activeChainTeamName = teamName + return this.getActiveChain() + } + + public addChain(chain: SigChain, setActive: boolean): boolean { + if (this.chains.has(chain.team.teamName)) { + throw new Error(`Chain for team ${chain.team.teamName} already exists`) + } + + this.chains.set(chain.team.teamName, chain) + if (setActive) { + this.setActiveChain(chain.team.teamName) + return true + } + return false + } + + public deleteChain(teamName: string): void { + if (!this.chains.has(teamName)) { + throw new Error(`No chain found for team ${teamName} to delete!`) + } + + this.chains.delete(teamName) + if (this.activeChainTeamName === teamName) { + this.activeChainTeamName = undefined + } + return + } + + public createChain(teamName: string, username: string, setActive: boolean): LoadedSigChain { + if (this.chains.has(teamName)) { + throw new Error(`Chain for team ${teamName} already exists`) + } + + const { context, sigChain } = SigChain.create(teamName, username) + this.addChain(sigChain, setActive) + return { context, sigChain } + } + + public getChainByTeamName(teamName: string): SigChain { + if (!this.chains.has(teamName)) { + throw new Error(`No chain found for team ${teamName}!`) + } + + return this.chains.get(teamName)! + } + + public static get instance(): SigChainManager { + if (SigChainManager._instance == undefined) { + throw new Error(`SigChainManager hasn't been initialized yet! Run init() before accessing`) + } + + return SigChainManager._instance + } +} + +export { SigChainManager } diff --git a/packages/backend/src/nest/auth/types.ts b/packages/backend/src/nest/auth/types.ts new file mode 100644 index 0000000000..28d1aef33b --- /dev/null +++ b/packages/backend/src/nest/auth/types.ts @@ -0,0 +1,7 @@ +import { LocalUserContext } from '@localfirst/auth' +import { SigChain } from './sigchain' + +export type LoadedSigChain = { + sigChain: SigChain + context: LocalUserContext +}