From 823fe44eb83b1999a9cdb3c8aa686f9d9f5eb4fa Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Wed, 23 Apr 2025 14:16:23 +0200 Subject: [PATCH 01/12] Requires plugin to 'sign' to the secrets manager to be able to use a secrets namespace --- src/manager.ts | 151 +++++++++++++++++++++++++++++++++++++++++-------- src/token.ts | 32 +++++++++-- 2 files changed, 152 insertions(+), 31 deletions(-) diff --git a/src/manager.ts b/src/manager.ts index 7354fc9..7e84df3 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 { @@ -7,18 +9,6 @@ import { ISecretsManager } from './token'; -/** - * The secrets manager namespace. - */ -export namespace SecretsManager { - /** - * Secrets manager constructor's options. - */ - export interface IOptions { - connector: ISecretsConnector; - } -} - /** * The default secrets manager implementation. */ @@ -39,21 +29,36 @@ export class SecretsManager implements ISecretsManager { /** * 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 { + this._check(token, namespace); + return this._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 { + this._check(token, namespace); + return this._set(Private.buildConnectorId(namespace, id), secret); } /** * List the secrets for a namespace as a ISecretsList. */ - async list(namespace: string): Promise { + async list( + token: symbol, + namespace: string + ): Promise { + this._check(token, namespace); if (!this._connector.list) { return; } @@ -64,8 +69,9 @@ export class SecretsManager implements ISecretsManager { /** * 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 { + this._check(token, namespace); + return this._remove(Private.buildConnectorId(namespace, id)); } /** @@ -74,17 +80,19 @@ 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); + this._check(token, namespace); + const attachedId = Private.buildConnectorId(namespace, id); const attachedInput = this._attachedInputs.get(attachedId); // Detach the previous input. if (attachedInput) { - this.detach(namespace, id); + this.detach(token, namespace, id); } this._attachedInputs.set(attachedId, input); @@ -108,14 +116,16 @@ 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)); + detach(token: symbol, namespace: string, id: string): void { + this._check(token, namespace); + this._detach(Private.buildConnectorId(namespace, id)); } /** * Detach all attached input for a namespace. */ - async detachAll(namespace: string): Promise { + async detachAll(token: symbol, namespace: string): Promise { + this._check(token, namespace); for (const id of this._attachedInputs.keys()) { if (id.startsWith(`${namespace}:`)) { this._detach(id); @@ -184,16 +194,107 @@ export class SecretsManager implements ISecretsManager { this._attachedInputs.delete(attachedId); } + private _check(token: symbol, namespace: string): void { + if (Private.isLocked() || Private.namespace.get(token) !== namespace) { + throw new Error( + `The secrets namespace ${namespace} is not available with the provided token` + ); + } + } + private _connector: ISecretsConnector; private _attachedInputs = new Map(); private _ready: PromiseDelegate; } +/** + * The secrets manager namespace. + */ +export namespace SecretsManager { + /** + * Secrets manager constructor's options. + */ + export interface IOptions { + connector: ISecretsConnector; + } + + /** + * A function that protect the secrets namespaces 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, namespace: plugins, symbols } = Private; + const { isDisabled } = PageConfig.Extension; + if (isLocked()) { + throw new Error('Secret registry 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 namespace = 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; + } + /** * 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..df78bc1 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'; @@ -38,25 +39,35 @@ export interface ISecretsManager { /** * 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): void; /** * 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; } /** From 887fdd8f029843dbb4506b1bde9befe36da20076 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Wed, 23 Apr 2025 15:21:51 +0200 Subject: [PATCH 02/12] Connector must be set to the manager, instead of providing a token --- src/index.ts | 18 ++++++-------- src/manager.ts | 66 +++++++++++++++++++++++++++++++++++--------------- src/token.ts | 16 ++++++------ 3 files changed, 62 insertions(+), 38 deletions(-) diff --git a/src/index.ts b/src/index.ts index d643c3f..c9b61db 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,13 +28,9 @@ 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(); } }; diff --git a/src/manager.ts b/src/manager.ts index 7e84df3..2e3a8aa 100644 --- a/src/manager.ts +++ b/src/manager.ts @@ -10,18 +10,31 @@ import { } from './token'; /** - * The default secrets manager implementation. + * The default secrets manager. */ export class SecretsManager implements ISecretsManager { /** * the secrets manager constructor. */ - constructor(options: SecretsManager.IOptions) { - this._connector = options.connector; + constructor() { this._ready = new PromiseDelegate(); this._ready.resolve(); } + /** + * 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 malicious extensions to get passwords when they are saved. + */ + setConnector(value: ISecretsConnector): void { + if (Private.getConnector() !== null) { + Private.lock('A connector was already provided to the secrets manager'); + } + Private.setConnector(value); + } + get ready(): Promise { return this._ready.promise; } @@ -59,11 +72,12 @@ export class SecretsManager implements ISecretsManager { namespace: string ): Promise { this._check(token, namespace); - if (!this._connector.list) { + const connector = Private.getConnector(); + if (!connector?.list) { return; } await this._ready.promise; - return await this._connector.list(namespace); + return await connector.list(namespace); } /** @@ -137,31 +151,34 @@ export class SecretsManager implements ISecretsManager { * Actually fetch the secret from the connector. */ private async _get(id: string): Promise { - if (!this._connector.fetch) { + const connector = Private.getConnector(); + if (!connector?.fetch) { return; } await this._ready.promise; - return this._connector.fetch(id); + return connector.fetch(id); } /** * Actually save the secret using the connector. */ private async _set(id: string, secret: ISecret): Promise { - if (!this._connector.save) { + const connector = Private.getConnector(); + 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) { + const connector = Private.getConnector(); + if (!connector?.remove) { return; } - this._connector.remove(id); + connector.remove(id); } private _onInput = async (e: Event): Promise => { @@ -202,7 +219,6 @@ export class SecretsManager implements ISecretsManager { } } - private _connector: ISecretsConnector; private _attachedInputs = new Map(); private _ready: PromiseDelegate; } @@ -211,13 +227,6 @@ export class SecretsManager implements ISecretsManager { * The secrets manager namespace. */ export namespace SecretsManager { - /** - * Secrets manager constructor's options. - */ - export interface IOptions { - connector: ISecretsConnector; - } - /** * A function that protect the secrets namespaces from other plugins. * @@ -291,6 +300,25 @@ namespace Private { return locked; } + /** + * Connector used by the manager. + */ + let connector: ISecretsConnector | null = null; + + /** + * Set the connector. + */ + export function setConnector(value: ISecretsConnector) { + connector = value; + } + + /** + * Get the connector. + */ + export function getConnector(): ISecretsConnector | null { + return connector; + } + /** * Build the secret id from the namespace and id. */ diff --git a/src/token.ts b/src/token.ts index df78bc1..85a0829 100644 --- a/src/token.ts +++ b/src/token.ts @@ -24,18 +24,18 @@ 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 malicious extensions to get passwords when they are saved. + */ + setConnector(value: ISecretsConnector): void; /** * Get a secret given its namespace and ID. */ From 3787faddf4040b2761fc0155e76e8f0cd206fb65 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Wed, 23 Apr 2025 16:48:36 +0200 Subject: [PATCH 03/12] Add dependency to @jupyterlab/coreutils --- package.json | 1 + yarn.lock | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) 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/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 From 6cced74ee052d116edb245f4e9199c090f52a1d6 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Wed, 23 Apr 2025 16:49:18 +0200 Subject: [PATCH 04/12] Export the SecretsManager --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index c9b61db..99c1b84 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,5 +35,6 @@ const manager: JupyterFrontEndPlugin = { }; export * from './connectors'; +export * from './manager'; export * from './token'; export default [inMemoryConnector, manager]; From 51cd331ef425d6ecd89e76f082ed11f2644ca0d5 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet <32258950+brichet@users.noreply.github.com> Date: Wed, 23 Apr 2025 17:38:41 +0200 Subject: [PATCH 05/12] Apply suggestions from code review Co-authored-by: Afshin Taylor Darian --- src/manager.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/manager.ts b/src/manager.ts index 2e3a8aa..86c6f10 100644 --- a/src/manager.ts +++ b/src/manager.ts @@ -29,9 +29,6 @@ export class SecretsManager implements ISecretsManager { * This is to prevent malicious extensions to get passwords when they are saved. */ setConnector(value: ISecretsConnector): void { - if (Private.getConnector() !== null) { - Private.lock('A connector was already provided to the secrets manager'); - } Private.setConnector(value); } @@ -77,7 +74,7 @@ export class SecretsManager implements ISecretsManager { return; } await this._ready.promise; - return await connector.list(namespace); + return connector.list(namespace); } /** @@ -178,7 +175,7 @@ export class SecretsManager implements ISecretsManager { if (!connector?.remove) { return; } - connector.remove(id); + return connector.remove(id); } private _onInput = async (e: Event): Promise => { @@ -228,7 +225,8 @@ export class SecretsManager implements ISecretsManager { */ export namespace SecretsManager { /** - * A function that protect the secrets namespaces from other plugins. + * 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. @@ -243,7 +241,7 @@ export namespace SecretsManager { const { lock, isLocked, namespace: plugins, symbols } = Private; const { isDisabled } = PageConfig.Extension; if (isLocked()) { - throw new Error('Secret registry is locked, check errors.'); + throw new Error('Secrets manager is locked, check errors.'); } if (isDisabled('jupyter-secrets-manager:manager')) { lock('Secret registry is disabled.'); @@ -309,6 +307,9 @@ namespace Private { * Set the connector. */ export function setConnector(value: ISecretsConnector) { + if (connector !== null) { + lock('A secrets manager connector already exists.'); + } connector = value; } From f55089e870968cf786915759245bd510010ecb07 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Thu, 24 Apr 2025 14:40:00 +0200 Subject: [PATCH 06/12] Wait for the connector to be provided before any action --- src/manager.ts | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/manager.ts b/src/manager.ts index 86c6f10..ee02148 100644 --- a/src/manager.ts +++ b/src/manager.ts @@ -17,8 +17,8 @@ export class SecretsManager implements ISecretsManager { * the secrets manager constructor. */ constructor() { - this._ready = new PromiseDelegate(); - this._ready.resolve(); + this._storing = new PromiseDelegate(); + this._storing.resolve(); } /** @@ -30,6 +30,7 @@ export class SecretsManager implements ISecretsManager { */ setConnector(value: ISecretsConnector): void { Private.setConnector(value); + this._ready.resolve(); } get ready(): Promise { @@ -45,6 +46,7 @@ export class SecretsManager implements ISecretsManager { id: string ): Promise { this._check(token, namespace); + await Promise.all([this._ready.promise, await this._storing.promise]); return this._get(Private.buildConnectorId(namespace, id)); } @@ -58,6 +60,7 @@ export class SecretsManager implements ISecretsManager { secret: ISecret ): Promise { this._check(token, namespace); + await this._ready.promise; return this._set(Private.buildConnectorId(namespace, id), secret); } @@ -69,6 +72,7 @@ export class SecretsManager implements ISecretsManager { namespace: string ): Promise { this._check(token, namespace); + await Promise.all([this._ready.promise, await this._storing.promise]); const connector = Private.getConnector(); if (!connector?.list) { return; @@ -82,6 +86,7 @@ export class SecretsManager implements ISecretsManager { */ async remove(token: symbol, namespace: string, id: string): Promise { this._check(token, namespace); + await this._ready.promise; return this._remove(Private.buildConnectorId(namespace, id)); } @@ -119,6 +124,7 @@ export class SecretsManager implements ISecretsManager { } } else if (input.value && input.value !== secret?.value) { // Otherwise save the current input value using the data connector. + await this._ready.promise; this._set(attachedId, { namespace, id, value: input.value }); } input.addEventListener('input', this._onInput); @@ -152,7 +158,6 @@ export class SecretsManager implements ISecretsManager { if (!connector?.fetch) { return; } - await this._ready.promise; return connector.fetch(id); } @@ -179,10 +184,10 @@ export class SecretsManager implements ISecretsManager { } 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(); + // Wait for an hypothetic current password storing. + await this._storing.promise; + // Reset the storing status. + this._storing = new PromiseDelegate(); const target = e.target as HTMLInputElement; const attachedId = target.dataset.secretsId; if (attachedId) { @@ -190,11 +195,12 @@ export class SecretsManager implements ISecretsManager { const namespace = splitId.shift(); const id = splitId.join(':'); if (namespace && id) { + await this._ready.promise; await this._set(attachedId, { namespace, id, value: target.value }); } } - // resolve the ready status. - this._ready.resolve(); + // resolve the storing status. + this._storing.resolve(); }; /** @@ -217,7 +223,8 @@ export class SecretsManager implements ISecretsManager { } private _attachedInputs = new Map(); - private _ready: PromiseDelegate; + private _ready = new PromiseDelegate(); + private _storing: PromiseDelegate; } /** From e3c3a3eee41f767b7da8bbc2816baa64d3c17219 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Thu, 24 Apr 2025 14:47:21 +0200 Subject: [PATCH 07/12] Avoid confusion between namespace and id when retrieving the full secret ID ('namespace:id') --- src/manager.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/manager.ts b/src/manager.ts index ee02148..e87bc7e 100644 --- a/src/manager.ts +++ b/src/manager.ts @@ -112,7 +112,8 @@ export class SecretsManager implements ISecretsManager { } this._attachedInputs.set(attachedId, input); - input.dataset.secretsId = attachedId; + input.dataset.namespace = namespace; + input.dataset.secretsId = id; const secret = await this._get(attachedId); if (!input.value && secret) { // Fill the password if the input is empty and a value is fetched by the data @@ -189,15 +190,12 @@ export class SecretsManager implements ISecretsManager { // Reset the storing status. this._storing = 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._ready.promise; - await this._set(attachedId, { namespace, id, value: target.value }); - } + const namespace = target.dataset.namespace; + const id = target.dataset.secretsId; + if (namespace && id) { + const attachedId = Private.buildConnectorId(namespace, id); + await this._ready.promise; + await this._set(attachedId, { namespace, id, value: target.value }); } // resolve the storing status. this._storing.resolve(); From 5c5a2b5a9e6a85f9ac94807673fb553790ffff2f Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Thu, 24 Apr 2025 15:53:58 +0200 Subject: [PATCH 08/12] Move the connector getter/setter in private namespace --- src/manager.ts | 133 +++++++++++++++++++++++++------------------------ 1 file changed, 68 insertions(+), 65 deletions(-) diff --git a/src/manager.ts b/src/manager.ts index e87bc7e..6ed10a4 100644 --- a/src/manager.ts +++ b/src/manager.ts @@ -45,9 +45,9 @@ export class SecretsManager implements ISecretsManager { namespace: string, id: string ): Promise { - this._check(token, namespace); + Private.checkNamespace(token, namespace); await Promise.all([this._ready.promise, await this._storing.promise]); - return this._get(Private.buildConnectorId(namespace, id)); + return Private.get(Private.buildConnectorId(namespace, id)); } /** @@ -59,9 +59,9 @@ export class SecretsManager implements ISecretsManager { id: string, secret: ISecret ): Promise { - this._check(token, namespace); + Private.checkNamespace(token, namespace); await this._ready.promise; - return this._set(Private.buildConnectorId(namespace, id), secret); + return Private.set(Private.buildConnectorId(namespace, id), secret); } /** @@ -71,23 +71,18 @@ export class SecretsManager implements ISecretsManager { token: symbol, namespace: string ): Promise { - this._check(token, namespace); + Private.checkNamespace(token, namespace); await Promise.all([this._ready.promise, await this._storing.promise]); - const connector = Private.getConnector(); - if (!connector?.list) { - return; - } - await this._ready.promise; - return connector.list(namespace); + return Private.list(namespace); } /** * Remove a secret given its namespace and ID. */ async remove(token: symbol, namespace: string, id: string): Promise { - this._check(token, namespace); + Private.checkNamespace(token, namespace); await this._ready.promise; - return this._remove(Private.buildConnectorId(namespace, id)); + return Private.remove(Private.buildConnectorId(namespace, id)); } /** @@ -102,7 +97,7 @@ export class SecretsManager implements ISecretsManager { input: HTMLInputElement, callback?: (value: string) => void ): Promise { - this._check(token, namespace); + Private.checkNamespace(token, namespace); const attachedId = Private.buildConnectorId(namespace, id); const attachedInput = this._attachedInputs.get(attachedId); @@ -114,7 +109,7 @@ export class SecretsManager implements ISecretsManager { input.dataset.namespace = namespace; input.dataset.secretsId = id; - const secret = await this._get(attachedId); + 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. @@ -126,7 +121,7 @@ export class SecretsManager implements ISecretsManager { } else if (input.value && input.value !== secret?.value) { // Otherwise save the current input value using the data connector. await this._ready.promise; - this._set(attachedId, { namespace, id, value: input.value }); + Private.set(attachedId, { namespace, id, value: input.value }); } input.addEventListener('input', this._onInput); } @@ -135,7 +130,7 @@ export class SecretsManager implements ISecretsManager { * Detach the input previously attached with its namespace and ID. */ detach(token: symbol, namespace: string, id: string): void { - this._check(token, namespace); + Private.checkNamespace(token, namespace); this._detach(Private.buildConnectorId(namespace, id)); } @@ -143,7 +138,7 @@ export class SecretsManager implements ISecretsManager { * Detach all attached input for a namespace. */ async detachAll(token: symbol, namespace: string): Promise { - this._check(token, namespace); + Private.checkNamespace(token, namespace); for (const id of this._attachedInputs.keys()) { if (id.startsWith(`${namespace}:`)) { this._detach(id); @@ -151,39 +146,6 @@ export class SecretsManager implements ISecretsManager { } } - /** - * Actually fetch the secret from the connector. - */ - private async _get(id: string): Promise { - const connector = Private.getConnector(); - if (!connector?.fetch) { - return; - } - return connector.fetch(id); - } - - /** - * Actually save the secret using the connector. - */ - private async _set(id: string, secret: ISecret): Promise { - const connector = Private.getConnector(); - if (!connector?.save) { - return; - } - return connector.save(id, secret); - } - - /** - * Actually remove the secrets using the connector. - */ - async _remove(id: string): Promise { - const connector = Private.getConnector(); - if (!connector?.remove) { - return; - } - return connector.remove(id); - } - private _onInput = async (e: Event): Promise => { // Wait for an hypothetic current password storing. await this._storing.promise; @@ -195,7 +157,7 @@ export class SecretsManager implements ISecretsManager { if (namespace && id) { const attachedId = Private.buildConnectorId(namespace, id); await this._ready.promise; - await this._set(attachedId, { namespace, id, value: target.value }); + await Private.set(attachedId, { namespace, id, value: target.value }); } // resolve the storing status. this._storing.resolve(); @@ -212,19 +174,13 @@ export class SecretsManager implements ISecretsManager { this._attachedInputs.delete(attachedId); } - private _check(token: symbol, namespace: string): void { - if (Private.isLocked() || Private.namespace.get(token) !== namespace) { - throw new Error( - `The secrets namespace ${namespace} is not available with the provided token` - ); - } - } - private _attachedInputs = new Map(); private _ready = new PromiseDelegate(); private _storing: PromiseDelegate; } +Object.freeze(SecretsManager.prototype); + /** * The secrets manager namespace. */ @@ -243,7 +199,7 @@ export namespace SecretsManager { id: string, factory: ISecretsManager.PluginFactory ): JupyterFrontEndPlugin { - const { lock, isLocked, namespace: plugins, symbols } = Private; + const { lock, isLocked, namespaces: plugins, symbols } = Private; const { isDisabled } = PageConfig.Extension; if (isLocked()) { throw new Error('Secrets manager is locked, check errors.'); @@ -277,7 +233,7 @@ namespace Private { /** * The namespace associated to a symbol. */ - export const namespace = new Map(); + export const namespaces = new Map(); /** * The symbol associated to a namespace. @@ -303,6 +259,19 @@ namespace Private { 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. */ @@ -319,10 +288,44 @@ namespace Private { } /** - * Get the connector. + * Actually fetch the secret from the connector. + */ + export async function get(id: string): Promise { + if (!connector?.fetch) { + return; + } + 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. + */ + export async function set(id: string, secret: ISecret): Promise { + if (!connector?.save) { + return; + } + return connector.save(id, secret); + } + + /** + * Actually remove the secrets using the connector. */ - export function getConnector(): ISecretsConnector | null { - return connector; + export async function remove(id: string): Promise { + if (!connector?.remove) { + return; + } + return connector.remove(id); } /** From 309dd1ba3b4b2e8f2990a4f2691b4a432aaecddf Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Thu, 24 Apr 2025 15:57:22 +0200 Subject: [PATCH 09/12] Update comments --- src/manager.ts | 5 ++++- src/token.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/manager.ts b/src/manager.ts index 6ed10a4..1fadc0a 100644 --- a/src/manager.ts +++ b/src/manager.ts @@ -26,7 +26,7 @@ export class SecretsManager implements ISecretsManager { * * NOTE: * If several extensions try to set the connector, the manager will be locked. - * This is to prevent malicious extensions to get passwords when they are saved. + * This is to prevent misconfiguration of competing plugins or MITM attacks. */ setConnector(value: ISecretsConnector): void { Private.setConnector(value); @@ -179,6 +179,9 @@ export class SecretsManager implements ISecretsManager { private _storing: PromiseDelegate; } +/** + * Freeze the secrets manager methods, to prevent extensions from overwriting them. + */ Object.freeze(SecretsManager.prototype); /** diff --git a/src/token.ts b/src/token.ts index 85a0829..e89282f 100644 --- a/src/token.ts +++ b/src/token.ts @@ -33,7 +33,7 @@ export interface ISecretsManager { * * NOTE: * If several extensions try to set the connector, the manager will be locked. - * This is to prevent malicious extensions to get passwords when they are saved. + * This is to prevent misconfiguration of competing plugins or MITM attacks. */ setConnector(value: ISecretsConnector): void; /** From 6e4a82654e71f2f3ddda1efa3c36a369f5d2953b Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Thu, 24 Apr 2025 16:16:08 +0200 Subject: [PATCH 10/12] Set detach method as async --- src/manager.ts | 2 +- src/token.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/manager.ts b/src/manager.ts index 1fadc0a..4fb0eb8 100644 --- a/src/manager.ts +++ b/src/manager.ts @@ -129,7 +129,7 @@ export class SecretsManager implements ISecretsManager { /** * Detach the input previously attached with its namespace and ID. */ - detach(token: symbol, namespace: string, id: string): void { + async detach(token: symbol, namespace: string, id: string): Promise { Private.checkNamespace(token, namespace); this._detach(Private.buildConnectorId(namespace, id)); } diff --git a/src/token.ts b/src/token.ts index e89282f..94b018e 100644 --- a/src/token.ts +++ b/src/token.ts @@ -76,7 +76,7 @@ export interface ISecretsManager { /** * Detach the input previously attached with its namespace and ID. */ - detach(token: symbol, namespace: string, id: string): void; + detach(token: symbol, namespace: string, id: string): Promise; /** * Detach all attached input for a namespace. */ From 142a5f2eda12886108cec079c098dd1638018b24 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Thu, 24 Apr 2025 16:22:44 +0200 Subject: [PATCH 11/12] Better handling of the promise delegate --- src/manager.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/manager.ts b/src/manager.ts index 4fb0eb8..60acc85 100644 --- a/src/manager.ts +++ b/src/manager.ts @@ -37,6 +37,10 @@ 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. */ @@ -46,7 +50,7 @@ export class SecretsManager implements ISecretsManager { id: string ): Promise { Private.checkNamespace(token, namespace); - await Promise.all([this._ready.promise, await this._storing.promise]); + await Promise.all([this.ready, this.storing]); return Private.get(Private.buildConnectorId(namespace, id)); } @@ -60,7 +64,7 @@ export class SecretsManager implements ISecretsManager { secret: ISecret ): Promise { Private.checkNamespace(token, namespace); - await this._ready.promise; + await this.ready; return Private.set(Private.buildConnectorId(namespace, id), secret); } @@ -72,7 +76,7 @@ export class SecretsManager implements ISecretsManager { namespace: string ): Promise { Private.checkNamespace(token, namespace); - await Promise.all([this._ready.promise, await this._storing.promise]); + await Promise.all([this.ready, this.storing]); return Private.list(namespace); } @@ -81,7 +85,7 @@ export class SecretsManager implements ISecretsManager { */ async remove(token: symbol, namespace: string, id: string): Promise { Private.checkNamespace(token, namespace); - await this._ready.promise; + await this.ready; return Private.remove(Private.buildConnectorId(namespace, id)); } @@ -120,7 +124,7 @@ export class SecretsManager implements ISecretsManager { } } else if (input.value && input.value !== secret?.value) { // Otherwise save the current input value using the data connector. - await this._ready.promise; + await this.ready; Private.set(attachedId, { namespace, id, value: input.value }); } input.addEventListener('input', this._onInput); @@ -148,7 +152,7 @@ export class SecretsManager implements ISecretsManager { private _onInput = async (e: Event): Promise => { // Wait for an hypothetic current password storing. - await this._storing.promise; + await this.storing; // Reset the storing status. this._storing = new PromiseDelegate(); const target = e.target as HTMLInputElement; @@ -156,7 +160,7 @@ export class SecretsManager implements ISecretsManager { const id = target.dataset.secretsId; if (namespace && id) { const attachedId = Private.buildConnectorId(namespace, id); - await this._ready.promise; + await this.ready; await Private.set(attachedId, { namespace, id, value: target.value }); } // resolve the storing status. From d9fe0e74959dd29881de3ba92bb28614029d0983 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Fri, 25 Apr 2025 11:22:36 +0200 Subject: [PATCH 12/12] Make the inputs less discovarable --- src/manager.ts | 43 ++++++++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/src/manager.ts b/src/manager.ts index 60acc85..8ae0157 100644 --- a/src/manager.ts +++ b/src/manager.ts @@ -103,16 +103,14 @@ export class SecretsManager implements ISecretsManager { ): Promise { Private.checkNamespace(token, namespace); const attachedId = Private.buildConnectorId(namespace, id); - const attachedInput = this._attachedInputs.get(attachedId); + const attachedInput = Private.inputs.get(attachedId); // Detach the previous input. if (attachedInput) { this.detach(token, namespace, id); } - this._attachedInputs.set(attachedId, input); - - input.dataset.namespace = namespace; - input.dataset.secretsId = id; + 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 @@ -143,9 +141,9 @@ export class SecretsManager implements ISecretsManager { */ async detachAll(token: symbol, namespace: string): Promise { Private.checkNamespace(token, namespace); - for (const id of this._attachedInputs.keys()) { - if (id.startsWith(`${namespace}:`)) { - this._detach(id); + for (const path of Private.secretPath.values()) { + if (path.namespace === namespace) { + this._detach(Private.buildConnectorId(path.namespace, path.id)); } } } @@ -156,8 +154,7 @@ export class SecretsManager implements ISecretsManager { // Reset the storing status. this._storing = new PromiseDelegate(); const target = e.target as HTMLInputElement; - const namespace = target.dataset.namespace; - const id = target.dataset.secretsId; + const { namespace, id } = Private.secretPath.get(target) ?? {}; if (namespace && id) { const attachedId = Private.buildConnectorId(namespace, id); await this.ready; @@ -171,14 +168,15 @@ export class SecretsManager implements ISecretsManager { * Actually detach of an input. */ private _detach(attachedId: string): void { - const input = this._attachedInputs.get(attachedId); - if (input) { - input.removeEventListener('input', this._onInput); + const input = Private.inputs.get(attachedId); + if (!input) { + return; } - this._attachedInputs.delete(attachedId); + input.removeEventListener('input', this._onInput); + Private.secretPath.delete(input); + Private.inputs.delete(attachedId); } - private _attachedInputs = new Map(); private _ready = new PromiseDelegate(); private _storing: PromiseDelegate; } @@ -335,6 +333,21 @@ namespace Private { return connector.remove(id); } + export type SecretPath = { + namespace: string; + id: string; + }; + + /** + * The inputs elements attached to the manager. + */ + export const inputs = new Map(); + + /** + * The secret path associated to an input. + */ + export const secretPath = new Map(); + /** * Build the secret id from the namespace and id. */