diff --git a/package.json b/package.json index 5841237..b5cb171 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ }, "dependencies": { "@jupyterlab/application": "^4.0.0", + "@jupyterlab/coreutils": "^6.0.0", "@jupyterlab/statedb": "^4.0.0", "@lumino/algorithm": "^2.0.0", "@lumino/coreutils": "^2.1.2" diff --git a/src/index.ts b/src/index.ts index d643c3f..99c1b84 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,20 +3,20 @@ import { JupyterFrontEndPlugin } from '@jupyterlab/application'; import { SecretsManager } from './manager'; -import { ISecretsConnector, ISecretsManager } from './token'; +import { ISecretsManager } from './token'; import { InMemoryConnector } from './connectors'; /** * A basic secret connector extension, that should be disabled to provide a new * connector. */ -const inMemoryConnector: JupyterFrontEndPlugin = { +const inMemoryConnector: JupyterFrontEndPlugin = { id: 'jupyter-secrets-manager:connector', description: 'A JupyterLab extension to manage secrets.', autoStart: true, - provides: ISecretsConnector, - activate: (app: JupyterFrontEnd): ISecretsConnector => { - return new InMemoryConnector(); + requires: [ISecretsManager], + activate: (app: JupyterFrontEnd, manager: ISecretsManager): void => { + manager.setConnector(new InMemoryConnector()); } }; @@ -28,16 +28,13 @@ const manager: JupyterFrontEndPlugin = { description: 'A JupyterLab extension to manage secrets.', autoStart: true, provides: ISecretsManager, - requires: [ISecretsConnector], - activate: ( - app: JupyterFrontEnd, - connector: ISecretsConnector - ): ISecretsManager => { + activate: (app: JupyterFrontEnd): ISecretsManager => { console.log('JupyterLab extension jupyter-secrets-manager is activated!'); - return new SecretsManager({ connector }); + return new SecretsManager(); } }; export * from './connectors'; +export * from './manager'; export * from './token'; export default [inMemoryConnector, manager]; diff --git a/src/manager.ts b/src/manager.ts index 7354fc9..8ae0157 100644 --- a/src/manager.ts +++ b/src/manager.ts @@ -1,3 +1,5 @@ +import { JupyterFrontEndPlugin } from '@jupyterlab/application'; +import { PageConfig } from '@jupyterlab/coreutils'; import { PromiseDelegate } from '@lumino/coreutils'; import { @@ -8,27 +10,26 @@ import { } from './token'; /** - * The secrets manager namespace. + * The default secrets manager. */ -export namespace SecretsManager { +export class SecretsManager implements ISecretsManager { /** - * Secrets manager constructor's options. + * the secrets manager constructor. */ - export interface IOptions { - connector: ISecretsConnector; + constructor() { + this._storing = new PromiseDelegate(); + this._storing.resolve(); } -} -/** - * The default secrets manager implementation. - */ -export class SecretsManager implements ISecretsManager { /** - * the secrets manager constructor. + * Set the connector to use with the manager. + * + * NOTE: + * If several extensions try to set the connector, the manager will be locked. + * This is to prevent misconfiguration of competing plugins or MITM attacks. */ - constructor(options: SecretsManager.IOptions) { - this._connector = options.connector; - this._ready = new PromiseDelegate(); + setConnector(value: ISecretsConnector): void { + Private.setConnector(value); this._ready.resolve(); } @@ -36,36 +37,56 @@ export class SecretsManager implements ISecretsManager { return this._ready.promise; } + protected get storing(): Promise { + return this._storing.promise; + } + /** * Get a secret given its namespace and ID. */ - async get(namespace: string, id: string): Promise { - return this._get(Private.buildSecretId(namespace, id)); + async get( + token: symbol, + namespace: string, + id: string + ): Promise { + Private.checkNamespace(token, namespace); + await Promise.all([this.ready, this.storing]); + return Private.get(Private.buildConnectorId(namespace, id)); } /** * Set a secret given its namespace and ID. */ - async set(namespace: string, id: string, secret: ISecret): Promise { - return this._set(Private.buildSecretId(namespace, id), secret); + async set( + token: symbol, + namespace: string, + id: string, + secret: ISecret + ): Promise { + Private.checkNamespace(token, namespace); + await this.ready; + return Private.set(Private.buildConnectorId(namespace, id), secret); } /** * List the secrets for a namespace as a ISecretsList. */ - async list(namespace: string): Promise { - if (!this._connector.list) { - return; - } - await this._ready.promise; - return await this._connector.list(namespace); + async list( + token: symbol, + namespace: string + ): Promise { + Private.checkNamespace(token, namespace); + await Promise.all([this.ready, this.storing]); + return Private.list(namespace); } /** * Remove a secret given its namespace and ID. */ - async remove(namespace: string, id: string): Promise { - return this._remove(Private.buildSecretId(namespace, id)); + async remove(token: symbol, namespace: string, id: string): Promise { + Private.checkNamespace(token, namespace); + await this.ready; + return Private.remove(Private.buildConnectorId(namespace, id)); } /** @@ -74,22 +95,23 @@ export class SecretsManager implements ISecretsManager { * is programmatically filled. */ async attach( + token: symbol, namespace: string, id: string, input: HTMLInputElement, callback?: (value: string) => void ): Promise { - const attachedId = Private.buildSecretId(namespace, id); - const attachedInput = this._attachedInputs.get(attachedId); + Private.checkNamespace(token, namespace); + const attachedId = Private.buildConnectorId(namespace, id); + const attachedInput = Private.inputs.get(attachedId); // Detach the previous input. if (attachedInput) { - this.detach(namespace, id); + this.detach(token, namespace, id); } - this._attachedInputs.set(attachedId, input); - - input.dataset.secretsId = attachedId; - const secret = await this._get(attachedId); + Private.inputs.set(attachedId, input); + Private.secretPath.set(input, { namespace, id }); + const secret = await Private.get(attachedId); if (!input.value && secret) { // Fill the password if the input is empty and a value is fetched by the data // connector. @@ -100,7 +122,8 @@ export class SecretsManager implements ISecretsManager { } } else if (input.value && input.value !== secret?.value) { // Otherwise save the current input value using the data connector. - this._set(attachedId, { namespace, id, value: input.value }); + await this.ready; + Private.set(attachedId, { namespace, id, value: input.value }); } input.addEventListener('input', this._onInput); } @@ -108,92 +131,227 @@ export class SecretsManager implements ISecretsManager { /** * Detach the input previously attached with its namespace and ID. */ - detach(namespace: string, id: string): void { - this._detach(Private.buildSecretId(namespace, id)); + async detach(token: symbol, namespace: string, id: string): Promise { + Private.checkNamespace(token, namespace); + this._detach(Private.buildConnectorId(namespace, id)); } /** * Detach all attached input for a namespace. */ - async detachAll(namespace: string): Promise { - for (const id of this._attachedInputs.keys()) { - if (id.startsWith(`${namespace}:`)) { - this._detach(id); + async detachAll(token: symbol, namespace: string): Promise { + Private.checkNamespace(token, namespace); + for (const path of Private.secretPath.values()) { + if (path.namespace === namespace) { + this._detach(Private.buildConnectorId(path.namespace, path.id)); } } } + private _onInput = async (e: Event): Promise => { + // Wait for an hypothetic current password storing. + await this.storing; + // Reset the storing status. + this._storing = new PromiseDelegate(); + const target = e.target as HTMLInputElement; + const { namespace, id } = Private.secretPath.get(target) ?? {}; + if (namespace && id) { + const attachedId = Private.buildConnectorId(namespace, id); + await this.ready; + await Private.set(attachedId, { namespace, id, value: target.value }); + } + // resolve the storing status. + this._storing.resolve(); + }; + + /** + * Actually detach of an input. + */ + private _detach(attachedId: string): void { + const input = Private.inputs.get(attachedId); + if (!input) { + return; + } + input.removeEventListener('input', this._onInput); + Private.secretPath.delete(input); + Private.inputs.delete(attachedId); + } + + private _ready = new PromiseDelegate(); + private _storing: PromiseDelegate; +} + +/** + * Freeze the secrets manager methods, to prevent extensions from overwriting them. + */ +Object.freeze(SecretsManager.prototype); + +/** + * The secrets manager namespace. + */ +export namespace SecretsManager { + /** + * A function that protects the secrets namespace of the signed plugin from + * other plugins. + * + * @param id - the secrets namespace, which must match the plugin ID to prevent an + * extension to use an other extension namespace. + * @param factory - a plugin factory, taking a symbol as argument and returning a + * plugin. + * @returns - the plugin to activate. + */ + export function sign( + id: string, + factory: ISecretsManager.PluginFactory + ): JupyterFrontEndPlugin { + const { lock, isLocked, namespaces: plugins, symbols } = Private; + const { isDisabled } = PageConfig.Extension; + if (isLocked()) { + throw new Error('Secrets manager is locked, check errors.'); + } + if (isDisabled('jupyter-secrets-manager:manager')) { + lock('Secret registry is disabled.'); + } + if (isDisabled(id)) { + lock(`Sign error: plugin ${id} is disabled.`); + } + if (symbols.has(id)) { + lock(`Sign error: another plugin signed as "${id}".`); + } + const token = Symbol(id); + const plugin = factory(token); + if (id !== plugin.id) { + lock(`Sign error: plugin ID mismatch "${plugin.id}"≠"${id}".`); + } + plugins.set(token, id); + symbols.set(id, token); + return plugin; + } +} + +namespace Private { + /** + * Internal 'locked' status. + */ + let locked: boolean = false; + + /** + * The namespace associated to a symbol. + */ + export const namespaces = new Map(); + + /** + * The symbol associated to a namespace. + */ + export const symbols = new Map(); + + /** + * Lock the manager. + * + * @param message - the error message to throw. + */ + export function lock(message: string) { + locked = true; + throw new Error(message); + } + + /** + * Check if the manager is locked. + * + * @returns - whether the manager is locked or not. + */ + export function isLocked(): boolean { + return locked; + } + + /** + * + * @param token - the token associated to the extension when signin. + * @param namespace - the namespace to check with this token. + */ + export function checkNamespace(token: symbol, namespace: string): void { + if (isLocked() || namespaces.get(token) !== namespace) { + throw new Error( + `The secrets namespace ${namespace} is not available with the provided token` + ); + } + } + + /** + * Connector used by the manager. + */ + let connector: ISecretsConnector | null = null; + + /** + * Set the connector. + */ + export function setConnector(value: ISecretsConnector) { + if (connector !== null) { + lock('A secrets manager connector already exists.'); + } + connector = value; + } + /** * Actually fetch the secret from the connector. */ - private async _get(id: string): Promise { - if (!this._connector.fetch) { + export async function get(id: string): Promise { + if (!connector?.fetch) { return; } - await this._ready.promise; - return this._connector.fetch(id); + return connector.fetch(id); } + /** + * Actually list the secret from the connector. + */ + export async function list( + namespace: string + ): Promise { + if (!connector?.list) { + return; + } + return connector.list(namespace); + } /** * Actually save the secret using the connector. */ - private async _set(id: string, secret: ISecret): Promise { - if (!this._connector.save) { + export async function set(id: string, secret: ISecret): Promise { + if (!connector?.save) { return; } - return this._connector.save(id, secret); + return connector.save(id, secret); } /** * Actually remove the secrets using the connector. */ - async _remove(id: string): Promise { - if (!this._connector.remove) { + export async function remove(id: string): Promise { + if (!connector?.remove) { return; } - this._connector.remove(id); + return connector.remove(id); } - private _onInput = async (e: Event): Promise => { - // Wait for an hypothetic current password saving. - await this._ready.promise; - // Reset the ready status. - this._ready = new PromiseDelegate(); - const target = e.target as HTMLInputElement; - const attachedId = target.dataset.secretsId; - if (attachedId) { - const splitId = attachedId.split(':'); - const namespace = splitId.shift(); - const id = splitId.join(':'); - if (namespace && id) { - await this._set(attachedId, { namespace, id, value: target.value }); - } - } - // resolve the ready status. - this._ready.resolve(); + export type SecretPath = { + namespace: string; + id: string; }; /** - * Actually detach of an input. + * The inputs elements attached to the manager. */ - private _detach(attachedId: string): void { - const input = this._attachedInputs.get(attachedId); - if (input) { - input.removeEventListener('input', this._onInput); - } - this._attachedInputs.delete(attachedId); - } + export const inputs = new Map(); - private _connector: ISecretsConnector; - private _attachedInputs = new Map(); - private _ready: PromiseDelegate; -} + /** + * The secret path associated to an input. + */ + export const secretPath = new Map(); -namespace Private { /** * Build the secret id from the namespace and id. */ - export function buildSecretId(namespace: string, id: string): string { + export function buildConnectorId(namespace: string, id: string): string { return `${namespace}:${id}`; } } diff --git a/src/token.ts b/src/token.ts index 3a5b388..94b018e 100644 --- a/src/token.ts +++ b/src/token.ts @@ -1,3 +1,4 @@ +import { JupyterFrontEndPlugin } from '@jupyterlab/application'; import { IDataConnector } from '@jupyterlab/statedb'; import { Token } from '@lumino/coreutils'; @@ -23,40 +24,50 @@ export interface ISecretsList { values: T[]; } -/** - * The secrets connector token. - */ -export const ISecretsConnector = new Token( - 'jupyter-secret-manager:connector', - 'The secrets connector' -); - /** * The secrets manager interface. */ export interface ISecretsManager { + /** + * Set the connector to use with the manager. + * + * NOTE: + * If several extensions try to set the connector, the manager will be locked. + * This is to prevent misconfiguration of competing plugins or MITM attacks. + */ + setConnector(value: ISecretsConnector): void; /** * Get a secret given its namespace and ID. */ - get(namespace: string, id: string): Promise; + get( + token: symbol, + namespace: string, + id: string + ): Promise; /** * Set a secret given its namespace and ID. */ - set(namespace: string, id: string, secret: ISecret): Promise; + set( + token: symbol, + namespace: string, + id: string, + secret: ISecret + ): Promise; /** * Remove a secret given its namespace and ID. */ - remove(namespace: string, id: string): Promise; + remove(token: symbol, namespace: string, id: string): Promise; /** * List the secrets for a namespace as a ISecretsList. */ - list(namespace: string): Promise; + list(token: symbol, namespace: string): Promise; /** * Attach an input to the secrets manager, with its namespace and ID values. * An optional callback function can be attached too, which be called when the input * is programmatically filled. */ attach( + token: symbol, namespace: string, id: string, input: HTMLInputElement, @@ -65,11 +76,20 @@ export interface ISecretsManager { /** * Detach the input previously attached with its namespace and ID. */ - detach(namespace: string, id: string): void; + detach(token: symbol, namespace: string, id: string): Promise; /** * Detach all attached input for a namespace. */ - detachAll(namespace: string): Promise; + detachAll(token: symbol, namespace: string): Promise; +} + +export namespace ISecretsManager { + /** + * The plugin factory. + * The argument of the factory is a symbol (unique identifier), and it returns a + * plugin. + */ + export type PluginFactory = (token: symbol) => JupyterFrontEndPlugin; } /** diff --git a/yarn.lock b/yarn.lock index 4432f3e..d188893 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2229,7 +2229,7 @@ __metadata: languageName: node linkType: hard -"@jupyterlab/coreutils@npm:^6.3.5": +"@jupyterlab/coreutils@npm:^6.0.0, @jupyterlab/coreutils@npm:^6.3.5": version: 6.3.5 resolution: "@jupyterlab/coreutils@npm:6.3.5" dependencies: @@ -6801,6 +6801,7 @@ __metadata: dependencies: "@jupyterlab/application": ^4.0.0 "@jupyterlab/builder": ^4.0.0 + "@jupyterlab/coreutils": ^6.0.0 "@jupyterlab/statedb": ^4.0.0 "@jupyterlab/testutils": ^4.0.0 "@lumino/algorithm": ^2.0.0