Skip to content

Conversation

brichet
Copy link
Collaborator

@brichet brichet commented Apr 23, 2025

This PR aims to isolate the secrets from others extensions.

It includes two main ideas to prevent other extensions from recovering or defining secrets that don't belong to them.

  1. Do not expose the SecretsConnector.
    Before this PR, the secrets connector was exposed by a token, that was consumed by the SecretsManager. This token could therefore be consumed by any other extension, which could fetch the secrets of a any namespace.
    With this PR, the secrets connector must be set to the secrets manager by the plugin itself. If several plugins try to set a connector, the manager is locked.

  2. Allow only one extension per namespace (credit to @afshin for the idea and its implementation).
    When a plugin want to use a namespace with the secrets manager, it has to 'sign in' and get a unique token. This token is required anytime the plugin needs to interact with the SecretsConnector (get, set, list or remove secrets).
    The secrets manager should be the only one able to access the Map objects that keep the relationship between the token and the namespace. To do so, it uses a private namespace that stores:

    • the namespace/token relationship
    • the locked status
    • the connector to use

Should fix #12

How to use it

const PLUGIN_ID = 'my-extension:my-plugin';
const testing: JupyterFrontEndPlugin<void> = SecretsManager.sign(
  PLUGIN_ID,
  token => ({
    id: PLUGIN_ID,
    requires: [ISecretsManager],
    autoStart: true,
    activate: (app: JupyterFrontEnd, manager: ISecretsManager): void => {
      // Set the secret
      manager.set(token, PLUGIN_ID, 'secretID', {
        namespace: PLUGIN_ID,
        id: 'secretID',
        value: 'secret-value'
      });

      // Get the secret
      const secret = await manager.get(token, PLUGIN_ID, 'secretID');
    }
  })
);

If the token is used in an object, for privacy it should also use a Private namespace to be stored.

cc. @afshin @jtpio @trungleduc

@brichet brichet added the enhancement New feature or request label Apr 23, 2025
src/manager.ts Outdated
Comment on lines 216 to 218
throw new Error(
`The secrets namespace ${namespace} is not available with the provided token`
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
throw new Error(
`The secrets namespace ${namespace} is not available with the provided token`
);
Private.lock(
`The secrets namespace ${namespace} is not available with the provided token`
);

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't know if we want to lock the manager on any failing attempt. It should not occurs, though.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't feel strongly about this, but the only times I expect this to happen are in development so I erred on the side of security. I'm happy to defer to your judgement.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could use a strict flag that can be set when starting jupyterlab.

@afshin
Copy link
Contributor

afshin commented Apr 23, 2025

I am a little unsure about the Promise<any> return types of some of the connector methods. Should they be void or unknown instead? Or do you think they should remain any?

@afshin
Copy link
Contributor

afshin commented Apr 23, 2025

(That doesn't need to be resolved in this PR, btw.)

Co-authored-by: Afshin Taylor Darian <git@darian.link>
@trungleduc
Copy link
Member

Thanks @brichet for working on it!
What is the relation between a namespace and the unique connector? If i have 2 extensions that sign with the SecretsManager with different PLUGIN_ID, can both call the fetch method of the connector?

@brichet
Copy link
Collaborator Author

brichet commented Apr 23, 2025

What is the relation between a namespace and the unique connector? If i have 2 extensions that sign with the SecretsManager with different PLUGIN_ID, can both call the fetch method of the connector?

The connector doesn't know the namespaces, it only associates an id to a secret.
The id is a string namespace:secretID, this is why the connector must be accessible only from the manager. Otherwise it would be easy to fetch the secrets.

So yes, the unique connector should handle several namespaces. And actually the current design doesn't allow registering several connectors.

@trungleduc
Copy link
Member

So, how can I prevent an extension from fetching my AI provider secret key? I want only jupyterlite-ai can fetch this secret

@trungleduc
Copy link
Member

Ah I understand now. I can have logic inside my connector.fetch to check the namespace of the secret ID and allow only the namespace claimed by jupyterlite-ai to do the real fetch.

@brichet
Copy link
Collaborator Author

brichet commented Apr 24, 2025

So, how can I prevent an extension from fetching my AI provider secret key? I want only jupyterlite-ai can fetch this secret

An extension can only use the manager to fetch a secret, it should not be able to use the connector directly. And the manager only allow signed in extension to fetch its namespace...

src/manager.ts Outdated
/**
* Actually fetch the secret from the connector.
*/
private async _get(id: string): Promise<ISecret | undefined> {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also worried about this kind of method, that doesn't check the token.
This method is still available in javascript, even if private in typescript context.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can move it into the Private namespace?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 I can make them static and move them to the Private namespace.

@afshin
Copy link
Contributor

afshin commented Apr 24, 2025

I would suggest making a:

get ready(): Promise<void> {
  return this._ready.promise;
}

And then in all the async methods, get, list, attach, etc., have await this.ready; near the top.

@afshin
Copy link
Contributor

afshin commented Apr 24, 2025

Also unless it's important not to, since attach and detachAll are async, perhaps detach should be as well?

@brichet
Copy link
Collaborator Author

brichet commented Apr 24, 2025

I would suggest making a:

get ready(): Promise<void> {
  return this._ready.promise;
}

And then in all the async methods, get, list, attach, etc., have await this.ready; near the top.

It's already the case. Actually there are 2 PromiseDelegate that can be awaited:

  • _ready which is resolved when the connector is set. This promise is awaited for any action related to the connector.
  • _storing which is set during data storage on the connector, and resolved when it's over. This one is awaited when getting the secrets.

@afshin
Copy link
Contributor

afshin commented Apr 24, 2025

Right, I see those promise delegates, I mean to not await the .promise attribute of a PromiseDelegate and just to have a public or protected promise called simply ready.

@brichet brichet marked this pull request as ready for review April 24, 2025 14:23
src/manager.ts Outdated
const attachedId = Private.buildSecretId(namespace, id);
Private.checkNamespace(token, namespace);
const attachedId = Private.buildConnectorId(namespace, id);
const attachedInput = this._attachedInputs.get(attachedId);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_attachedInputs should be in the private namespace, because i could do this in my extension

secretManager['_attachedInputs'].forEach(it => {
   it.addeventlistener(...)
})

and get the value of the input when the secret value is filled.

Copy link
Collaborator Author

@brichet brichet Apr 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I realized that as well, but your extension can also do

document.querySelector('[data-namespace]')

And I don't think that we can do anything about that.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't have to save namespace and secret id to the input, just reverse the _attachedInputs map is enough to find these value from input element

Copy link
Collaborator Author

@brichet brichet Apr 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I agree that it would still be better to not expose it, but the extension can still do

document.querySelectorAll('input[type=password]')

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, at this point we can't do anything

@brichet
Copy link
Collaborator Author

brichet commented Apr 25, 2025

Thanks @afshin and @trungleduc for your helpful comments and suggestions.

@brichet brichet merged commit 96bb498 into jupyterlab-contrib:main Apr 25, 2025
6 checks passed
@brichet brichet deleted the isolate_secrets_from_others_extensions branch April 25, 2025 10:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Isolate secrets from other extensions

3 participants