diff --git a/Cargo.lock b/Cargo.lock index 41c1de96..4889bca6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,6 +78,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + [[package]] name = "bitflags" version = "1.3.2" @@ -111,6 +117,12 @@ version = "1.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2ef034f05691a48569bd920a96c81b9d91bbad1ab5ac7c4616c1f6ef36cb79f" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.5.0" @@ -138,6 +150,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "contrast" version = "0.1.0" @@ -228,6 +246,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "der" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fffa369a668c8af7dbf8b5e56c9f744fbd399949ed171606040001947de40b1c" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "derive_builder" version = "0.12.0" @@ -266,6 +295,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", ] @@ -608,6 +638,9 @@ name = "lazy_static" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +dependencies = [ + "spin", +] [[package]] name = "libc" @@ -625,6 +658,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "libm" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" + [[package]] name = "linux-raw-sys" version = "0.4.13" @@ -672,6 +711,7 @@ name = "matrix-hookshot" version = "5.2.1" dependencies = [ "atom_syndication", + "base64ct", "contrast", "hex", "md-5", @@ -681,6 +721,7 @@ dependencies = [ "rand", "reqwest", "rgb", + "rsa", "rss", "ruma", "serde", @@ -821,6 +862,43 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54" +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d869c01cc0c455284163fd0092f1f93835385ccab5a98a0dcc497b2f8bf055a9" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.18" @@ -828,6 +906,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -922,6 +1001,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -1020,6 +1108,27 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.30" @@ -1193,6 +1302,26 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "rsa" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rss" version = "2.0.7" @@ -1450,6 +1579,16 @@ dependencies = [ "serde", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -1481,6 +1620,22 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "string_cache" version = "0.8.7" @@ -1513,6 +1668,12 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + [[package]] name = "syn" version = "1.0.109" @@ -2046,3 +2207,9 @@ dependencies = [ "cfg-if", "windows-sys 0.48.0", ] + +[[package]] +name = "zeroize" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" diff --git a/Cargo.toml b/Cargo.toml index 5475bb53..0711159f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ atom_syndication = "0.12" ruma = { version = "0.9", features = ["events", "html"] } reqwest = "0.11" rand = "0.8.5" - +rsa = "0.9.6" +base64ct = { version = "1.6.0", features = ["alloc"] } [build-dependencies] napi-build = "2" diff --git a/changelog.d/915.misc b/changelog.d/915.misc new file mode 100644 index 00000000..7261adb4 --- /dev/null +++ b/changelog.d/915.misc @@ -0,0 +1 @@ +Track which key was used to encrypt secrets in storage, and encrypt/decrypt secrets in Rust. diff --git a/config.sample.yml b/config.sample.yml index 9c3aa289..00201678 100644 --- a/config.sample.yml +++ b/config.sample.yml @@ -10,7 +10,7 @@ bridge: passFile: # A passkey used to encrypt tokens stored inside the bridge. # Run openssl genpkey -out passkey.pem -outform PEM -algorithm RSA -pkeyopt rsa_keygen_bits:4096 to generate - passkey.pem + ./passkey.pem logging: # Logging settings. You can have a severity debug,info,warn,error level: info diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 6a8fdce4..9b25f720 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # exit when any command fails set -e diff --git a/spec/basic.spec.ts b/spec/basic.spec.ts index ec36ddf0..e375a18a 100644 --- a/spec/basic.spec.ts +++ b/spec/basic.spec.ts @@ -19,12 +19,12 @@ describe('Basic test setup', () => { const user = testEnv.getUser('user'); const roomId = await user.createRoom({ name: 'Test room', invite:[testEnv.botMxid] }); await user.waitForRoomJoin({sender: testEnv.botMxid, roomId }); - await user.sendText(roomId, "!hookshot help"); - const msg = await user.waitForRoomEvent({ + const msg = user.waitForRoomEvent({ eventType: 'm.room.message', sender: testEnv.botMxid, roomId }); + await user.sendText(roomId, "!hookshot help"); // Expect help text. - expect(msg.data.content.body).to.include('!hookshot help` - This help text\n'); + expect((await msg).data.content.body).to.include('!hookshot help` - This help text\n'); }); // TODO: Move test to it's own generic connections file. diff --git a/src/AdminRoom.ts b/src/AdminRoom.ts index efa2c130..69155163 100644 --- a/src/AdminRoom.ts +++ b/src/AdminRoom.ts @@ -16,7 +16,7 @@ import { Intent } from "matrix-bot-sdk"; import { JiraBotCommands } from "./jira/AdminCommands"; import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters"; import { ProjectsListResponseData } from "./github/Types"; -import { UserTokenStore } from "./UserTokenStore"; +import { UserTokenStore } from "./tokens/UserTokenStore"; import { Logger } from "matrix-appservice-bridge"; import markdown from "markdown-it"; type ProjectsListForRepoResponseData = Endpoints["GET /repos/{owner}/{repo}/projects"]["response"]; diff --git a/src/AdminRoomCommandHandler.ts b/src/AdminRoomCommandHandler.ts index 10102e7e..6840729e 100644 --- a/src/AdminRoomCommandHandler.ts +++ b/src/AdminRoomCommandHandler.ts @@ -1,7 +1,7 @@ import EventEmitter from "events"; import { Intent } from "matrix-bot-sdk"; import { BridgeConfig } from "./config/Config"; -import { UserTokenStore } from "./UserTokenStore"; +import { UserTokenStore } from "./tokens/UserTokenStore"; export enum Category { diff --git a/src/App/BridgeApp.ts b/src/App/BridgeApp.ts index 1c40b23e..83f90957 100644 --- a/src/App/BridgeApp.ts +++ b/src/App/BridgeApp.ts @@ -9,6 +9,7 @@ import { getAppservice } from "../appservice"; import BotUsersManager from "../Managers/BotUsersManager"; import * as Sentry from '@sentry/node'; import { GenericHookConnection } from "../Connections"; +import { UserTokenStore } from "../tokens/UserTokenStore"; Logger.configure({console: "info"}); const log = new Logger("App"); @@ -50,7 +51,8 @@ export async function start(config: BridgeConfig, registration: IAppserviceRegis const botUsersManager = new BotUsersManager(config, appservice); - const bridgeApp = new Bridge(config, listener, appservice, storage, botUsersManager); + const tokenStore = await UserTokenStore.fromKeyPath(config.passFile , appservice.botIntent, config); + const bridgeApp = new Bridge(config, tokenStore, listener, appservice, storage, botUsersManager); process.once("SIGTERM", () => { log.error("Got SIGTERM"); diff --git a/src/Bridge.ts b/src/Bridge.ts index c9251745..0e6daed4 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -23,7 +23,7 @@ import { NotificationsEnableEvent, NotificationsDisableEvent, Webhooks } from ". import { GitHubOAuthToken, GitHubOAuthTokenResponse, ProjectsGetResponseData } from "./github/Types"; import { retry } from "./PromiseUtil"; import { UserNotificationsEvent } from "./Notifications/UserNotificationWatcher"; -import { UserTokenStore } from "./UserTokenStore"; +import { UserTokenStore } from "./tokens/UserTokenStore"; import * as GitHubWebhookTypes from "@octokit/webhooks-types"; import { Logger } from "matrix-appservice-bridge"; import { Provisioner } from "./provisioning/provisioner"; @@ -49,11 +49,9 @@ export class Bridge { private readonly queue: MessageQueue; private readonly commentProcessor: CommentProcessor; private readonly notifProcessor: NotificationProcessor; - private readonly tokenStore: UserTokenStore; private connectionManager?: ConnectionManager; private github?: GithubInstance; private adminRooms: Map = new Map(); - private widgetApi?: BridgeWidgetApi; private feedReader?: FeedReader; private provisioningApi?: Provisioner; private replyProcessor = new RichRepliesPreprocessor(true); @@ -62,6 +60,7 @@ export class Bridge { constructor( private config: BridgeConfig, + private readonly tokenStore: UserTokenStore, private readonly listener: ListenerService, private readonly as: Appservice, private readonly storage: IBridgeStorageProvider, @@ -71,8 +70,6 @@ export class Bridge { this.messageClient = new MessageSenderClient(this.queue); this.commentProcessor = new CommentProcessor(this.as, this.config.bridge.mediaUrl || this.config.bridge.url); this.notifProcessor = new NotificationProcessor(this.storage, this.messageClient); - this.tokenStore = new UserTokenStore(this.config.passFile || "./passkey.pem", this.as.botIntent, this.config); - this.tokenStore.on("onNewToken", this.onTokenUpdated.bind(this)); // Legacy routes, to be removed. this.as.expressAppInstance.get("/live", (_, res) => res.send({ok: true})); @@ -87,8 +84,8 @@ export class Bridge { } public async start() { + this.tokenStore.on("onNewToken", this.onTokenUpdated.bind(this)); log.info('Starting up'); - await this.tokenStore.load(); await this.storage.connect?.(); await this.queue.connect?.(); @@ -755,7 +752,7 @@ export class Bridge { if (apps.length > 1) { throw Error('You may only bind `widgets` to one listener.'); } - this.widgetApi = new BridgeWidgetApi( + new BridgeWidgetApi( this.adminRooms, this.config, this.storage, diff --git a/src/ConnectionManager.ts b/src/ConnectionManager.ts index c475aa77..891f6c08 100644 --- a/src/ConnectionManager.ts +++ b/src/ConnectionManager.ts @@ -17,7 +17,7 @@ import { IBridgeStorageProvider } from "./Stores/StorageProvider"; import { JiraProject, JiraVersion } from "./jira/Types"; import { Logger } from "matrix-appservice-bridge"; import { MessageSenderClient } from "./MatrixSender"; -import { UserTokenStore } from "./UserTokenStore"; +import { UserTokenStore } from "./tokens/UserTokenStore"; import BotUsersManager from "./Managers/BotUsersManager"; import { retry, retryMatrixErrorFilter } from "./PromiseUtil"; import Metrics from "./Metrics"; diff --git a/src/Connections/GithubDiscussion.ts b/src/Connections/GithubDiscussion.ts index 5529118c..d3787d1d 100644 --- a/src/Connections/GithubDiscussion.ts +++ b/src/Connections/GithubDiscussion.ts @@ -1,6 +1,6 @@ import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection"; import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; -import { UserTokenStore } from "../UserTokenStore"; +import { UserTokenStore } from "../tokens/UserTokenStore"; import { CommentProcessor } from "../CommentProcessor"; import { MessageSenderClient } from "../MatrixSender"; import { ensureUserIsInRoom, getIntentForUser } from "../IntentUtils"; diff --git a/src/Connections/GithubIssue.ts b/src/Connections/GithubIssue.ts index 449349d4..4c0a728d 100644 --- a/src/Connections/GithubIssue.ts +++ b/src/Connections/GithubIssue.ts @@ -2,7 +2,7 @@ import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnectio import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent"; import markdown from "markdown-it"; -import { UserTokenStore } from "../UserTokenStore"; +import { UserTokenStore } from "../tokens/UserTokenStore"; import { Logger } from "matrix-appservice-bridge"; import { CommentProcessor } from "../CommentProcessor"; import { MessageSenderClient } from "../MatrixSender"; diff --git a/src/Connections/GithubRepo.ts b/src/Connections/GithubRepo.ts index 6e77191d..dc4e452d 100644 --- a/src/Connections/GithubRepo.ts +++ b/src/Connections/GithubRepo.ts @@ -13,7 +13,7 @@ import { MatrixMessageContent, MatrixEvent, MatrixReactionContent } from "../Mat import { MessageSenderClient } from "../MatrixSender"; import { CommandError, NotLoggedInError } from "../errors"; import { ReposGetResponseData } from "../github/Types"; -import { UserTokenStore } from "../UserTokenStore"; +import { UserTokenStore } from "../tokens/UserTokenStore"; import axios, { AxiosError } from "axios"; import { emojify } from "node-emoji"; import { Logger } from "matrix-appservice-bridge"; diff --git a/src/Connections/GitlabIssue.ts b/src/Connections/GitlabIssue.ts index 6a19cdd6..7d2df87d 100644 --- a/src/Connections/GitlabIssue.ts +++ b/src/Connections/GitlabIssue.ts @@ -1,7 +1,7 @@ import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection"; import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent"; -import { UserTokenStore } from "../UserTokenStore"; +import { UserTokenStore } from "../tokens/UserTokenStore"; import { Logger } from "matrix-appservice-bridge"; import { CommentProcessor } from "../CommentProcessor"; import { MessageSenderClient } from "../MatrixSender"; diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index c3936a4c..29e7f3e1 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -1,4 +1,4 @@ -import { UserTokenStore } from "../UserTokenStore"; +import { UserTokenStore } from "../tokens/UserTokenStore"; import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { BotCommands, botCommand, compileBotCommands } from "../BotCommands"; import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent"; diff --git a/src/Connections/IConnection.ts b/src/Connections/IConnection.ts index 9fe63a9a..c01c8878 100644 --- a/src/Connections/IConnection.ts +++ b/src/Connections/IConnection.ts @@ -3,7 +3,7 @@ import { IssuesOpenedEvent, IssuesEditedEvent } from "@octokit/webhooks-types"; import { ConnectionWarning, GetConnectionsResponseItem } from "../provisioning/api"; import { Appservice, Intent, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk"; import { BridgeConfig, BridgePermissionLevel } from "../config/Config"; -import { UserTokenStore } from "../UserTokenStore"; +import { UserTokenStore } from "../tokens/UserTokenStore"; import { CommentProcessor } from "../CommentProcessor"; import { MessageSenderClient } from "../MatrixSender"; import { IBridgeStorageProvider } from "../Stores/StorageProvider"; diff --git a/src/Connections/JiraProject.ts b/src/Connections/JiraProject.ts index a1aa2acd..05aabec1 100644 --- a/src/Connections/JiraProject.ts +++ b/src/Connections/JiraProject.ts @@ -9,7 +9,7 @@ import { JiraProject, JiraVersion } from "../jira/Types"; import { botCommand, BotCommands, compileBotCommands } from "../BotCommands"; import { MatrixMessageContent } from "../MatrixEvent"; import { CommandConnection } from "./CommandConnection"; -import { UserTokenStore } from "../UserTokenStore"; +import { UserTokenStore } from "../tokens/UserTokenStore"; import { CommandError, NotLoggedInError } from "../errors"; import { ApiError, ErrCode } from "../api"; import JiraApi from "jira-client"; diff --git a/src/Gitlab/GrantChecker.ts b/src/Gitlab/GrantChecker.ts index 66bd7bea..de759fc6 100644 --- a/src/Gitlab/GrantChecker.ts +++ b/src/Gitlab/GrantChecker.ts @@ -3,7 +3,7 @@ import { Appservice } from "matrix-bot-sdk"; import { BridgeConfigGitLab } from "../config/Config"; import { GitLabRepoConnection } from "../Connections"; import { GrantChecker } from "../grants/GrantCheck"; -import { UserTokenStore } from "../UserTokenStore"; +import { UserTokenStore } from "../tokens/UserTokenStore"; const log = new Logger('GitLabGrantChecker'); diff --git a/src/Widgets/BridgeWidgetApi.ts b/src/Widgets/BridgeWidgetApi.ts index 38555789..0962d5d0 100644 --- a/src/Widgets/BridgeWidgetApi.ts +++ b/src/Widgets/BridgeWidgetApi.ts @@ -11,7 +11,7 @@ import BotUsersManager, {BotUser} from "../Managers/BotUsersManager"; import { assertUserPermissionsInRoom, GetConnectionsResponseItem } from "../provisioning/api"; import { Appservice, PowerLevelsEvent } from "matrix-bot-sdk"; import { GithubInstance } from '../github/GithubInstance'; -import { AllowedTokenTypes, TokenType, UserTokenStore } from '../UserTokenStore'; +import { AllowedTokenTypes, TokenType, UserTokenStore } from '../tokens/UserTokenStore'; const log = new Logger("BridgeWidgetApi"); diff --git a/src/config/Config.ts b/src/config/Config.ts index beb73857..1926d91a 100644 --- a/src/config/Config.ts +++ b/src/config/Config.ts @@ -550,7 +550,7 @@ export class BridgeConfig { this.generic = configData.generic && new BridgeConfigGenericWebhooks(configData.generic); this.feeds = configData.feeds && new BridgeConfigFeeds(configData.feeds); this.provisioning = configData.provisioning; - this.passFile = configData.passFile; + this.passFile = configData.passFile ?? "./passkey.pem"; this.bot = configData.bot; this.serviceBots = configData.serviceBots; this.metrics = configData.metrics; diff --git a/src/config/Defaults.ts b/src/config/Defaults.ts index 190f47b7..2ea9e1f5 100644 --- a/src/config/Defaults.ts +++ b/src/config/Defaults.ts @@ -34,7 +34,7 @@ export const DefaultConfigRoot: BridgeConfigRoot = { level: "admin" }], }], - passFile: "passkey.pem", + passFile: "./passkey.pem", widgets: { publicUrl: `${hookshotWebhooksUrl}/widgetapi/v1/static`, addToAdminRooms: false, diff --git a/src/github/GrantChecker.ts b/src/github/GrantChecker.ts index a1497f30..e3eb27ee 100644 --- a/src/github/GrantChecker.ts +++ b/src/github/GrantChecker.ts @@ -1,7 +1,7 @@ import { Appservice } from "matrix-bot-sdk"; import { GitHubRepoConnection } from "../Connections"; import { GrantChecker } from "../grants/GrantCheck"; -import { UserTokenStore } from "../UserTokenStore"; +import { UserTokenStore } from "../tokens/UserTokenStore"; import { Logger } from 'matrix-appservice-bridge'; const log = new Logger('GitHubGrantChecker'); diff --git a/src/github/Router.ts b/src/github/Router.ts index 94ba2ce9..fa05c57b 100644 --- a/src/github/Router.ts +++ b/src/github/Router.ts @@ -1,7 +1,7 @@ import { Router, Request, Response, NextFunction } from "express"; import { BridgeConfigGitHub } from "../config/Config"; import { ApiError, ErrCode } from "../api"; -import { UserTokenStore } from "../UserTokenStore"; +import { UserTokenStore } from "../tokens/UserTokenStore"; import { Logger } from "matrix-appservice-bridge"; import { GithubInstance } from "./GithubInstance"; import { NAMELESS_ORG_PLACEHOLDER } from "./Types"; diff --git a/src/jira/GrantChecker.ts b/src/jira/GrantChecker.ts index 211fc821..1fc19b14 100644 --- a/src/jira/GrantChecker.ts +++ b/src/jira/GrantChecker.ts @@ -1,7 +1,7 @@ import { Appservice } from "matrix-bot-sdk"; import { JiraProjectConnection } from "../Connections"; import { GrantChecker } from "../grants/GrantCheck"; -import { UserTokenStore } from "../UserTokenStore"; +import { UserTokenStore } from "../tokens/UserTokenStore"; interface JiraGrantConnectionId{ url: string; diff --git a/src/jira/Router.ts b/src/jira/Router.ts index 2bd6815b..7771e5f7 100644 --- a/src/jira/Router.ts +++ b/src/jira/Router.ts @@ -1,7 +1,7 @@ import { BridgeConfigJira } from "../config/Config"; import { MessageQueue } from "../MessageQueue"; import { Router, Request, Response, NextFunction, json } from "express"; -import { UserTokenStore } from "../UserTokenStore"; +import { UserTokenStore } from "../tokens/UserTokenStore"; import { Logger } from "matrix-appservice-bridge"; import { ApiError, ErrCode } from "../api"; import { JiraOAuthRequestCloud, JiraOAuthRequestOnPrem, JiraOAuthRequestResult } from "./OAuth"; diff --git a/src/lib.rs b/src/lib.rs index 3a15bd65..4e4e1bfe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod feeds; pub mod format_util; pub mod github; pub mod jira; +pub mod tokens; pub mod util; #[macro_use] diff --git a/src/UserTokenStore.ts b/src/tokens/UserTokenStore.ts similarity index 83% rename from src/UserTokenStore.ts rename to src/tokens/UserTokenStore.ts index afd6520d..7ea2ce3b 100644 --- a/src/UserTokenStore.ts +++ b/src/tokens/UserTokenStore.ts @@ -1,22 +1,22 @@ -import { GithubInstance } from "./github/GithubInstance"; -import { GitLabClient } from "./Gitlab/Client"; +import { GithubInstance } from "../github/GithubInstance"; +import { GitLabClient } from "../Gitlab/Client"; import { Intent } from "matrix-bot-sdk"; import { promises as fs } from "fs"; -import { publicEncrypt, privateDecrypt } from "crypto"; import { Logger } from "matrix-appservice-bridge"; -import { isJiraCloudInstance, JiraClient } from "./jira/Client"; -import { JiraStoredToken } from "./jira/Types"; -import { BridgeConfig, BridgeConfigJira, BridgeConfigJiraOnPremOAuth, BridgePermissionLevel } from "./config/Config"; +import { isJiraCloudInstance, JiraClient } from "../jira/Client"; +import { JiraStoredToken } from "../jira/Types"; +import { BridgeConfig, BridgeConfigJira, BridgeConfigJiraOnPremOAuth, BridgePermissionLevel } from "../config/Config"; import { randomUUID } from 'node:crypto'; -import { GitHubOAuthToken } from "./github/Types"; -import { ApiError, ErrCode } from "./api"; -import { JiraOAuth } from "./jira/OAuth"; -import { JiraCloudOAuth } from "./jira/oauth/CloudOAuth"; -import { JiraOnPremOAuth } from "./jira/oauth/OnPremOAuth"; -import { JiraOnPremClient } from "./jira/client/OnPremClient"; -import { JiraCloudClient } from "./jira/client/CloudClient"; -import { TokenError, TokenErrorCode } from "./errors"; +import { GitHubOAuthToken } from "../github/Types"; +import { ApiError, ErrCode } from "../api"; +import { JiraOAuth } from "../jira/OAuth"; +import { JiraCloudOAuth } from "../jira/oauth/CloudOAuth"; +import { JiraOnPremOAuth } from "../jira/oauth/OnPremOAuth"; +import { JiraOnPremClient } from "../jira/client/OnPremClient"; +import { JiraCloudClient } from "../jira/client/CloudClient"; +import { TokenError, TokenErrorCode } from "../errors"; import { TypedEmitter } from "tiny-typed-emitter"; +import { hashId, TokenEncryption } from "../libRs"; const ACCOUNT_DATA_TYPE = "uk.half-shot.matrix-hookshot.github.password-store:"; const ACCOUNT_DATA_GITLAB_TYPE = "uk.half-shot.matrix-hookshot.gitlab.password-store:"; @@ -31,6 +31,8 @@ export const AllowedTokenTypes = ["github", "gitlab", "jira"]; interface StoredTokenData { encrypted: string|string[]; + keyId: string; + algorithm: 'rsa'; instance?: string; } @@ -51,20 +53,29 @@ function tokenKey(type: TokenType, userId: string, legacy = false, instanceUrl?: return `${legacy ? LEGACY_ACCOUNT_DATA_GITLAB_TYPE : ACCOUNT_DATA_GITLAB_TYPE}${instanceUrl}${userId}`; } -const MAX_TOKEN_PART_SIZE = 128; const OAUTH_TIMEOUT_MS = 1000 * 60 * 30; interface Emitter { onNewToken: (type: TokenType, userId: string, token: string, instanceUrl?: string) => void, } export class UserTokenStore extends TypedEmitter { - private key!: Buffer; + + public static async fromKeyPath(keyPath: string, intent: Intent, config: BridgeConfig) { + log.info(`Loading token key file ${keyPath}`); + const key = await fs.readFile(keyPath); + return new UserTokenStore(key, intent, config); + } + private oauthSessionStore: Map = new Map(); private userTokens: Map; public readonly jiraOAuth?: JiraOAuth; - constructor(private keyPath: string, private intent: Intent, private config: BridgeConfig) { + private tokenEncryption: TokenEncryption; + private readonly keyId: string; + constructor(key: Buffer, private readonly intent: Intent, private readonly config: BridgeConfig) { super(); + this.tokenEncryption = new TokenEncryption(key); this.userTokens = new Map(); + this.keyId = hashId(key.toString('utf-8')); if (config.jira?.oauth) { if ("client_id" in config.jira.oauth) { this.jiraOAuth = new JiraCloudOAuth(config.jira.oauth); @@ -76,11 +87,6 @@ export class UserTokenStore extends TypedEmitter { } } - public async load() { - log.info(`Loading token key file ${this.keyPath}`); - this.key = await fs.readFile(this.keyPath); - } - public stop() { for (const session of this.oauthSessionStore.values()) { clearTimeout(session.timeout); @@ -92,21 +98,16 @@ export class UserTokenStore extends TypedEmitter { throw new ApiError('User does not have permission to log in to service', ErrCode.ForbiddenUser); } const key = tokenKey(type, userId, false, instanceUrl); - const tokenParts: string[] = []; - let tokenSource = token; - while (tokenSource && tokenSource.length > 0) { - const part = tokenSource.slice(0, MAX_TOKEN_PART_SIZE); - tokenSource = tokenSource.substring(MAX_TOKEN_PART_SIZE); - tokenParts.push(publicEncrypt(this.key, Buffer.from(part)).toString("base64")); - } + const tokenParts: string[] = this.tokenEncryption.encrypt(token); const data: StoredTokenData = { encrypted: tokenParts, + keyId: this.keyId, + algorithm: "rsa", instance: instanceUrl, }; await this.intent.underlyingClient.setAccountData(key, data); this.userTokens.set(key, token); log.info(`Stored new ${type} token for ${userId}`); - log.debug(`Stored`, data); this.emit("onNewToken", type, userId, token, instanceUrl); } @@ -146,8 +147,19 @@ export class UserTokenStore extends TypedEmitter { if (!obj || "deleted" in obj) { return null; } + // For legacy we just assume it's the current configured key. + const algorithm = obj.algorithm ?? "rsa"; + const keyId = obj.keyId ?? this.keyId; + + if (algorithm !== 'rsa') { + throw new Error(`Algorithm for stored data is '${algorithm}', but we only support RSA`); + } + if (keyId !== this.keyId) { + throw new Error(`Stored data was encrypted with a different key to the one currently configured`); + } + const encryptedParts = typeof obj.encrypted === "string" ? [obj.encrypted] : obj.encrypted; - const token = encryptedParts.map((t) => privateDecrypt(this.key, Buffer.from(t, "base64")).toString("utf-8")).join(""); + const token = this.tokenEncryption.decrypt(encryptedParts); this.userTokens.set(key, token); return token; } catch (ex) { diff --git a/src/tokens/mod.rs b/src/tokens/mod.rs new file mode 100644 index 00000000..6aa0cc17 --- /dev/null +++ b/src/tokens/mod.rs @@ -0,0 +1,116 @@ +use std::string::FromUtf8Error; + +use base64ct::{Base64, Encoding}; +use napi::bindgen_prelude::Buffer; +use napi::Error; +use rsa::pkcs8::DecodePrivateKey; +use rsa::{Pkcs1v15Encrypt, RsaPrivateKey, RsaPublicKey}; + +static MAX_TOKEN_PART_SIZE: usize = 128; + +struct TokenEncryption { + pub private_key: RsaPrivateKey, + pub public_key: RsaPublicKey, +} + +#[derive(Debug)] +#[allow(dead_code)] +enum TokenEncryptionError { + FromUtf8(FromUtf8Error), + PrivateKey(rsa::pkcs8::Error), +} + +#[derive(Debug)] +#[allow(dead_code)] +enum DecryptError { + Base64(base64ct::Error), + Decryption(rsa::Error), + FromUtf8(FromUtf8Error), +} + +impl TokenEncryption { + pub fn new(private_key_data: Vec) -> Result { + let data = String::from_utf8(private_key_data).map_err(TokenEncryptionError::FromUtf8)?; + let private_key = RsaPrivateKey::from_pkcs8_pem(data.as_str()) + .map_err(TokenEncryptionError::PrivateKey)?; + let public_key = private_key.to_public_key(); + Ok(TokenEncryption { + private_key, + public_key, + }) + } +} + +#[napi(js_name = "TokenEncryption")] +pub struct JsTokenEncryption { + inner: TokenEncryption, +} + +#[napi] +impl JsTokenEncryption { + #[napi(constructor)] + pub fn new(private_key_data: Buffer) -> Result { + let buf: Vec = private_key_data.into(); + match TokenEncryption::new(buf) { + Ok(inner) => Ok(JsTokenEncryption { inner }), + Err(err) => Err(Error::new( + napi::Status::GenericFailure, + format!("Error reading private key: {:?}", err).to_string(), + )), + } + } + + #[napi] + pub fn decrypt(&self, parts: Vec) -> Result { + let mut result = String::new(); + + for v in parts { + match self.decrypt_value(v) { + Ok(new_value) => { + result += &new_value; + Ok(()) + } + Err(err) => Err(Error::new( + napi::Status::GenericFailure, + format!("Could not decrypt string: {:?}", err).to_string(), + )), + }? + } + Ok(result) + } + + fn decrypt_value(&self, value: String) -> Result { + let raw_value = Base64::decode_vec(&value).map_err(DecryptError::Base64)?; + let decrypted_value = self + .inner + .private_key + .decrypt(Pkcs1v15Encrypt, &raw_value) + .map_err(DecryptError::Decryption)?; + let utf8_value = String::from_utf8(decrypted_value).map_err(DecryptError::FromUtf8)?; + Ok(utf8_value) + } + + #[napi] + pub fn encrypt(&self, input: String) -> Result, Error> { + let mut rng = rand::thread_rng(); + let mut parts: Vec = Vec::new(); + for part in input.into_bytes().chunks(MAX_TOKEN_PART_SIZE) { + match self + .inner + .public_key + .encrypt(&mut rng, Pkcs1v15Encrypt, part) + { + Ok(encrypted) => { + let b64 = Base64::encode_string(encrypted.as_slice()); + parts.push(b64); + Ok(()) + } + Err(err) => Err(Error::new( + napi::Status::GenericFailure, + format!("Could not encrypt string: {:?}", err).to_string(), + )), + }? + } + Ok(parts) + } +} diff --git a/tests/AdminRoomTest.ts b/tests/AdminRoomTest.ts index ee171e17..658c0cfc 100644 --- a/tests/AdminRoomTest.ts +++ b/tests/AdminRoomTest.ts @@ -4,7 +4,7 @@ import { AdminRoom } from "../src/AdminRoom"; import { DefaultConfig } from "../src/config/Defaults"; import { ConnectionManager } from "../src/ConnectionManager"; import { NotifFilter } from "../src/NotificationFilters"; -import { UserTokenStore } from "../src/UserTokenStore"; +import { UserTokenStore } from "../src/tokens/UserTokenStore"; import { IntentMock } from "./utils/IntentMock"; const ROOM_ID = "!foo:bar"; @@ -14,9 +14,8 @@ function createAdminRoom(data: any = {admin_user: "@admin:bar"}): [AdminRoom, In if (!data.admin_user) { data.admin_user = "@admin:bar"; } - const tokenStore = new UserTokenStore("notapath", intent, DefaultConfig); - return [new AdminRoom(ROOM_ID, data, NotifFilter.getDefaultContent(), intent, tokenStore, DefaultConfig, {} as ConnectionManager), intent]; -} + return [new AdminRoom(ROOM_ID, data, NotifFilter.getDefaultContent(), intent, {} as UserTokenStore, DefaultConfig, {} as ConnectionManager), intent]; +} describe("AdminRoom", () => { it("will present help text", async () => { diff --git a/tests/connections/GithubRepoTest.ts b/tests/connections/GithubRepoTest.ts index 8c1728d4..7359e55b 100644 --- a/tests/connections/GithubRepoTest.ts +++ b/tests/connections/GithubRepoTest.ts @@ -1,7 +1,7 @@ import { GitHubRepoConnection, GitHubRepoConnectionState } from "../../src/Connections/GithubRepo" import { GithubInstance } from "../../src/github/GithubInstance"; import { createMessageQueue } from "../../src/MessageQueue"; -import { UserTokenStore } from "../../src/UserTokenStore"; +import { UserTokenStore } from "../../src/tokens/UserTokenStore"; import { DefaultConfig } from "../../src/config/Defaults"; import { AppserviceMock } from "../utils/AppserviceMock"; import { ApiError, ErrCode, ValidatorApiError } from "../../src/api"; diff --git a/tests/connections/GitlabRepoTest.ts b/tests/connections/GitlabRepoTest.ts index b2376fe2..af438e61 100644 --- a/tests/connections/GitlabRepoTest.ts +++ b/tests/connections/GitlabRepoTest.ts @@ -1,5 +1,5 @@ import { createMessageQueue } from "../../src/MessageQueue"; -import { UserTokenStore } from "../../src/UserTokenStore"; +import { UserTokenStore } from "../../src/tokens/UserTokenStore"; import { AppserviceMock } from "../utils/AppserviceMock"; import { ApiError, ErrCode, ValidatorApiError } from "../../src/api"; import { GitLabRepoConnection, GitLabRepoConnectionState } from "../../src/Connections"; diff --git a/tests/tokens/tokenencryption.spec.ts b/tests/tokens/tokenencryption.spec.ts new file mode 100644 index 00000000..bd969447 --- /dev/null +++ b/tests/tokens/tokenencryption.spec.ts @@ -0,0 +1,48 @@ +import { TokenEncryption } from "../../src/libRs"; +import { RSAKeyPairOptions, generateKeyPair } from "node:crypto"; +import { expect } from "chai"; + +describe("TokenEncryption", () => { + let keyPromise: Promise; + async function createTokenEncryption() { + return new TokenEncryption(await keyPromise); + } + + before('generate RSA key', () => { + // Generate this once since it will take an age. + keyPromise = new Promise((resolve, reject) => generateKeyPair("rsa", { + // Deliberately shorter length to speed up test + modulusLength: 2048, + privateKeyEncoding: { + type: "pkcs8", + format: "pem", + }, + publicKeyEncoding: { + format: "pem", + type: "pkcs1", + } + } satisfies RSAKeyPairOptions<"pem", "pem">, (err, _, privateKey) => { + if (err) { reject(err) } else { resolve(Buffer.from(privateKey)) } + })); + }, ); + it('should be able to encrypt a string into a single part', async() => { + const tokenEncryption = await createTokenEncryption(); + const result = tokenEncryption.encrypt('hello world'); + expect(result).to.have.lengthOf(1); + }); + it('should be able to decrypt from a single part into a string', async() => { + const tokenEncryption = await createTokenEncryption(); + const value = tokenEncryption.encrypt('hello world'); + const result = tokenEncryption.decrypt(value); + expect(result).to.equal('hello world'); + }); + it('should be able to decrypt from many parts into string', async() => { + const plaintext = 'This is a very long string that needs to be encoded into multiple parts in order for us to store it properly. This ' + + ' should end up as multiple encrypted values in base64.'; + const tokenEncryption = await createTokenEncryption(); + const value = tokenEncryption.encrypt(plaintext); + expect(value).to.have.lengthOf(2); + const result = tokenEncryption.decrypt(value); + expect(result).to.equal(plaintext); + }); +});