Handle RSA token handling in Rust (#915)

* Split out queue and cache config

* Update usages of cache config,

* Update default

* Cleanup

* Make queue optional.

* config updates.

* changelog

* update spec config

* Update tests

* tweak import

* Update default config.

* fixup test

* Update config.sample.yml

Co-authored-by: Andrew Ferrazzutti <andrewf@element.io>
Signed-off-by: Will Hunt <will@half-shot.uk>

* Update encryption.md

Signed-off-by: Will Hunt <will@half-shot.uk>

* Clear up worker config

Signed-off-by: Will Hunt <will@half-shot.uk>

* Update src/config/Config.ts

Co-authored-by: Andrew Ferrazzutti <andrewf@element.io>
Signed-off-by: Will Hunt <will@half-shot.uk>

* update helm config

* move UserTokenStore.ts

* Port all the imports to new path.

* Port RSA handling to rust.

* Add tests.

* linting

* lint rust

* Remove unwraps / panics

* fix build script

* Ensure we store and check with algorithm and key was used.

* quieten false deadcode warnings

* changelog

* fix test imports

* lazy mock out UTS

* Refactor so that UserTokenStore is initiated by the time Bridge is created.

* update defaults

* replace if with match

* Use the magic of ?

* fmt

---------

Signed-off-by: Will Hunt <will@half-shot.uk>
Co-authored-by: Andrew Ferrazzutti <andrewf@element.io>
This commit is contained in:
Will Hunt 2024-04-08 15:22:57 +01:00 committed by GitHub
parent 6618ab6db8
commit 6482c7ed55
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 413 additions and 69 deletions

167
Cargo.lock generated
View File

@ -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"

View File

@ -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"

1
changelog.d/915.misc Normal file
View File

@ -0,0 +1 @@
Track which key was used to encrypt secrets in storage, and encrypt/decrypt secrets in Rust.

View File

@ -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

View File

@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# exit when any command fails
set -e

View File

@ -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<MessageEventContent>({
const msg = user.waitForRoomEvent<MessageEventContent>({
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.

View File

@ -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"];

View File

@ -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 {

View File

@ -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");

View File

@ -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<string, AdminRoom> = 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,

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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');

View File

@ -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");

View File

@ -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;

View File

@ -34,7 +34,7 @@ export const DefaultConfigRoot: BridgeConfigRoot = {
level: "admin"
}],
}],
passFile: "passkey.pem",
passFile: "./passkey.pem",
widgets: {
publicUrl: `${hookshotWebhooksUrl}/widgetapi/v1/static`,
addToAdminRooms: false,

View File

@ -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');

View File

@ -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";

View File

@ -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;

View File

@ -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";

View File

@ -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]

View File

@ -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<Emitter> {
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<string, {userId: string, timeout: NodeJS.Timeout}> = new Map();
private userTokens: Map<string, string>;
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<Emitter> {
}
}
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<Emitter> {
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<Emitter> {
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) {

116
src/tokens/mod.rs Normal file
View File

@ -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<u8>) -> Result<Self, TokenEncryptionError> {
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<Self, Error> {
let buf: Vec<u8> = 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<String>) -> Result<String, Error> {
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<String, DecryptError> {
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<Vec<String>, Error> {
let mut rng = rand::thread_rng();
let mut parts: Vec<String> = 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)
}
}

View File

@ -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 () => {

View File

@ -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";

View File

@ -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";

View File

@ -0,0 +1,48 @@
import { TokenEncryption } from "../../src/libRs";
import { RSAKeyPairOptions, generateKeyPair } from "node:crypto";
import { expect } from "chai";
describe("TokenEncryption", () => {
let keyPromise: Promise<Buffer>;
async function createTokenEncryption() {
return new TokenEncryption(await keyPromise);
}
before('generate RSA key', () => {
// Generate this once since it will take an age.
keyPromise = new Promise<Buffer>((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);
});
});