Skip to main content

jupyterlab_passkey_extension

GitHub Actions npm version PyPI version Total PyPI downloads JupyterLab 4 Brought To You By KOLOMOLO Donate PayPal

A passkey bridge for JupyterLab that exposes the passkey (WebAuthn) capability of the user's browser or operating system to local clients with no browser of their own and returns the browser-side ceremony result to the requesting local process.

  • Purpose - the bridge commands perform no cryptography, each caller supplies its parameters, and a tool that needs a WebAuthn PRF value keeps all key handling to itself
  • Scope - the CLI (jupyterlab-passkey, installed with the package) sends every request over an authenticated HTTP API, and the password vault is the one part that encrypts and stores secrets

Standards

Standard Status What the vault does
NIST SP 800-38D, RFC 5116 ✅ Met AES-256-GCM for all entries under one random 32-byte data key
RFC 7914 scrypt ✅ Met the recovery and unlock-password slots, N=2^17, r=8, p=1, from the OWASP Password Storage Cheat Sheet
RFC 5869 HKDF-SHA256 ✅ Met the passkey slots, keyed from the WebAuthn PRF
RFC 6238 TOTP ✅ Met 6-digit codes of the authenticator app, a used code refused
W3C WebAuthn Level 3, PRF extension ✅ Met each passkey slot is bound to one hostname
NIST SP 800-63B, AAL2 idle limit ✅ Met 30-minute default unlock duration
NIST SP 800-57 key management ⚠️ Not met the data key is not rotated

Features

  • Passkeys - create registers and get asserts Windows Hello, Touch ID or a security key from a process with no browser
  • Approval - the user approves the ceremony in the open tab, and the call blocks until the result returns
  • PRF - get --prf-salt returns the same 32 bytes for the same credential and salt, so a vault can derive its key and store none
  • passphrase - prompts in the browser and prints only a reference to the staged value (keyctl:... or file:...)
  • No terminal copy - the value never appears in the terminal, the shell history, a process argument or the CLI
  • copy - places a value on the clipboard through a one-shot relay
  • show - renders a code as a distorted image absent from the DOM text and the accessibility tree

Working with an AI agent

passphrase and copy let an AI agent handle a secret without printing it.

  • Secret entry - passphrase --once --prompt "..." gives an agent a reference, never a secret
  • Pipe - pass-cli can send bytes into copy without printing them
  • Help and skill - --help says what each subcommand prints and whether it waits, and SKILL.md adds the rules it cannot state
  • Why link - the wheel installs the skill at <sys.prefix>/share/jupyter/agents/skills/jupyterlab-passkey-extension/SKILL.md, and no agent reads that directory, so one of the two links below makes it readable
mkdir -p ~/.agents/skills && ln -sfn "$(python -c 'import sys; print(sys.prefix)')/share/jupyter/agents/skills/jupyterlab-passkey-extension" ~/.agents/skills/jupyterlab-passkey-extension
ln -sfn "$PWD/.agents/skills/jupyterlab-passkey-extension" ~/.claude/skills/jupyterlab-passkey-extension

How it works

A browser page can send data only over HTTP, so the ceremony result returns through an authenticated endpoint that writes a relay - a keyctl key or an atomic 0600 file - the local client reads.

  • Ceremony - the browser sends the ceremony result to the local client
  • copy - the local client sends a secret to the browser, and the notification contains only a nonce (see Security)
flowchart LR
    subgraph BROWSER["Browser - JupyterLab page"]
        direction TB
        TRIG["consumer triggers<br/>passkey:run"]
        CER["navigator.credentials<br/>get / create"]
        OS(["OS / authenticator<br/>Windows Hello, security key"])
        TRIG --> CER
        CER <--> OS
    end
    subgraph SERVER["Jupyter server"]
        direction TB
        EP["POST /result<br/>authenticated"]
        RELAY[("relay<br/>keyctl key or 0600 file")]
        EP --> RELAY
    end
    LOCAL["local client<br/>terminal / CLI / API"]
    CER -->|"POST JSON result"| EP
    RELAY -->|"reads, then deletes"| LOCAL

    style BROWSER stroke:#6b7280,stroke-width:3px
    style SERVER stroke:#6b7280,stroke-width:3px
    style TRIG stroke:#f59e0b,stroke-width:2px
    style CER stroke:#10b981,stroke-width:2px
    style OS stroke:#0284c7,stroke-width:2px
    style EP stroke:#10b981,stroke-width:2px
    style RELAY stroke:#3b82f6,stroke-width:2px
    style LOCAL stroke:#10b981,stroke-width:2px
flowchart LR
    LOCAL2["local client<br/>CLI / agent"]
    subgraph SERVER2["Jupyter server"]
        direction TB
        ING["notifications ingest<br/>separate extension"]
        RELAY2[("relay<br/>keyctl key or 0600 file")]
        EP2["POST /secret<br/>authenticated"]
        RELAY2 -->|"reads, then unlinks"| EP2
    end
    subgraph BROWSER2["Browser - JupyterLab page"]
        direction TB
        BTN["notification button<br/>passkey:copy"]
        CLIP(["user's clipboard"])
        BTN --> CLIP
    end
    LOCAL2 -->|"stages the secret"| RELAY2
    LOCAL2 -->|"posts the nonce only"| ING
    ING -.->|"sends to the page"| BTN
    BTN -->|"collects, once"| EP2
    EP2 -->|"the value"| BTN

    style BROWSER2 stroke:#6b7280,stroke-width:3px
    style SERVER2 stroke:#6b7280,stroke-width:3px
    style LOCAL2 stroke:#10b981,stroke-width:2px
    style ING stroke:#f59e0b,stroke-width:2px
    style RELAY2 stroke:#3b82f6,stroke-width:2px
    style EP2 stroke:#10b981,stroke-width:2px
    style BTN stroke:#f59e0b,stroke-width:2px
    style CLIP stroke:#0284c7,stroke-width:2px

Install

pip install jupyterlab_passkey_extension

Command line

jupyterlab-passkey is installed with the package and posts the notification with the request, waits for your click, and prints the result.

  • Caller - every notification starts with Asked by <name>:, set with --caller NAME on any subcommand or JLAB_PASSKEY_CALLER from Python
  • Caller default - without a name the caller is the project directory, which tells the person who clicks which project asks and proves nothing
  • Server - the CLI posts only to the Jupyter server its environment names, and stops when it cannot identify which running server is the lab (flags: cli-reference.md)
  • Exit status - 0 succeeded, 1 refused, timed out or could not reach the server, 2 an argument the parser rejects
  • Stdout - only the result goes to stdout, so $(...) captures only the result
  • copy - returns once posted, --block waits for collection only, and a terminal stdin is refused
  • show - reports nothing back
Command Does Prints
create registers a new passkey its cred_id
get asserts a passkey the PRF (with --prf-salt) or the cred_id
passphrase prompts you for a secret in the browser a reference (keyctl:... or file:...), never the value
copy puts a secret on your clipboard nothing - it posts a button and returns
show shows a code as a scraper-resistant image nothing - it posts a button and returns
cred_id=$(jupyterlab-passkey create --rp-id lab.example.com)
salt=$(head -c32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=')
prf=$(jupyterlab-passkey get --rp-id lab.example.com --cred-id "$cred_id" --prf-salt "$salt")

pass-cli get github/api --field password --quiet --no-clipboard \
  | jupyterlab-passkey copy --label "GitHub token"

Vault

The Jupyter server holds one encrypted file that the panel, the CLI and Python use through its authenticated API, unlocked with a passkey, an unlock password plus authenticator code, or the recovery passphrase; the design is in docs/design-vault.md.

  • Encryption - every entry, names included, is one AES-256-GCM ciphertext under a random 32-byte data key, which is wrapped once per keyslot with HKDF(PRF) or Scrypt (n=2^17, r=8, p=1)
  • Unlock - any slot decrypts the data key, and the recovery passphrase is mandatory
  • Authenticator code - the code is a server check that takes no part in encryption, so the file and the unlock password alone decrypt
  • File - $XDG_DATA_HOME/jupyterlab-passkey/vault.json (~/.local/share/...), mode 0600, path set by JLAB_PASSKEY_VAULT
  • Hostnames - a passkey opens the vault only from its hostname or a subdomain
  • Other hosts - unlock with the unlock password and an authenticator code, or the recovery passphrase
  • Settings - unlockMinutes (default 30, range 1 to 1440, the NIST SP 800-63B AAL2 idle limit) and revealSeconds (default 60, range 0 to 3600, the no-proof window after a proof, with 0 asking every time)

Panel

The Vault panel is in the right sidebar. The sidebar setting places it on the left.

  • Unlock and list - creates and unlocks the vault, lists entries by category, shows nothing while locked, and opens a row read-only with Edit, Delete and Close
  • Password and proof - the value shows dots until you click the eye button, which asks a passkey PRF or, without one, a code, password or recovery passphrase
  • revealSeconds - after a proof the panel skips the proof for revealSeconds seconds (default 60), or until the tab locks the vault or the page reloads
  • Security view - the cog shows the key holder and unlock methods, with Remove or Change, and adds a passkey or a password plus authenticator app (passwordMinLength, default 12)
  • Sensitive changes - changing the recovery passphrase or adding a method needs a proof in the same step, so an unlocked vault is not enough

Command line and Python

jupyterlab-passkey vault init                      # recovery passphrase, then a passkey
jupyterlab-passkey vault add github/api -u me      # password prompted, piped, or --generate
jupyterlab-passkey vault get github/api            # the password on stdout; unlocks if needed
jupyterlab-passkey vault exec --env GITHUB_TOKEN=github/api -- gh repo list
jupyterlab-passkey vault status                    # state, time left, key holder, each protection explained
from jupyterlab_passkey_extension.vault import Vault
token = Vault().get("github/api")  # unlocks with the passkey (a notification) if locked
  • No secret options - no option holds a secret
  • Import - import FILE or import --pass-cli adds entries
  • Reference - cli-reference.md lists every subcommand

Where the unlocked key is kept

While the vault is unlocked the server holds its data key in a key holder - the first of these that works on the host:

Holder Where Needs
keyctl the kernel keyring, with a kernel-enforced expiry the keyctl command (keyutils), and a seccomp profile that allows add_key, keyctl and request_key - Docker's default profile blocks them; no root
gpg-agent a gpg-agent of the vault's own, with its own GnuPG home; expiry from its max-cache-ttl GnuPG (apt install gnupg)
memory the Jupyter server process: memfd_secret, else mlock with MADV_DONTDUMP, else plain memory nothing - this is the extension's own code
  • Reports - each holder reports five properties: never in swap, never in crash dumps, expiry, lock on restart and container isolation
  • Server copy - the server also keeps the key in ordinary memory per request, which Python cannot wipe, so the key can be written to swap until reused even after a lock
  • Debug - vault status and --debug show all five properties, and the panel shows three: never in swap, never in crash dumps and lock on restart
  • Pin - JLAB_PASSKEY_VAULT_HOLDER=keyctl|gpg-agent|memory selects one holder, and a pinned holder that does not work is an error
  • Fallback - the server logs the fallback reason only when it chooses the holder automatically
  • Before changing - run vault lock before changing the holder, JLAB_PASSKEY_VAULT or the vault file, because a key held before a restart can outlive the change until its unlock duration ends

Server API

All endpoints are under the server base URL and require Jupyter authentication (@tornado.web.authenticated - a caller needs the Jupyter token or session).

  • Relay endpoints - POST <base_url>/jupyterlab-passkey-extension/{result,passphrase,secret,render} write or read one relay, and answer 204 or a JSON body
  • Vault endpoint - GET, POST and PATCH <base_url>/jupyterlab-passkey-extension/vault/<action> answer 423 while locked
  • Bad nonce - answers 400 and creates no file, and the nonce matches [A-Za-z0-9_-]{16,128}
  • POST for reads - secret and render are POSTs to keep the nonce out of the access log

The relay contract

  • Directory - /dev/shm/jlab-passkey-$(id -u), mode 0700, ownership checked, override JLAB_PASSKEY_RELAY_DIR
  • Files - <nonce>.json, .pass, .secret, .code, .cancel
  • Lifecycle - mode 0600, written with mkstemp-then-os.replace
  • Deletion - the consumer deletes .json and .pass, while .secret and .code are one-shot enforced by the server
{ "nonce": "...", "ok": true, "cred_id": "<b64url>", "prf_enabled": false } // create success
{ "nonce": "...", "ok": true, "cred_id": "<b64url>", "prf": "<b64url>" } // get success, prf only when prf_salt was supplied and evaluated
{ "nonce": "...", "ok": false, "error": "no-prf" | "not-allowed" | "rp-id-mismatch" | "error: <name>" | "error" } // failure
  • create - returns cred_id and prf_enabled
  • Errors - not-allowed hides three causes, and rp-id-mismatch is a separate SecurityError

For extension authors: the frontend commands

Six JupyterLab commands POST to the API above. Use them only when an extension triggers a ceremony itself, since everything else is better served by the CLI.

  • Commands - passkey:run, passkey:passphrase, passkey:copy, passkey:show, passkey:vault-unlock and passkey:vault-register (reference)
  • Gesture - passkey:run reaches navigator.credentials.* before any await so the click's gesture survives
  • Challenge - the frontend generates the 32-byte challenge, and a notify button supplies the gesture
jupyterlab-notify --now --no-auto-close -t info -m "Approve passkey" --action "Approve" --cmd "passkey:run" \
  --command-args '{"op":"get","nonce":"<16-128 url-safe chars>","rp_id":"lab.example.com","cred_id":"<b64url>","prf_salt":"<b64url>"}'

Security

Every endpoint requires Jupyter authentication, and the bridge stores no secret. The vault is the one part that encrypts and stores secrets.

  • Auth and backends - every endpoint needs a Jupyter token or session, and a secret stages in a keyctl key or a /dev/shm 0600 file set by JLAB_PASSKEY_RELAY_BACKEND=keyctl|shm|auto
  • Same-uid exposure - neither backend isolates a secret from your processes, and keyctl gives no access control
  • Notification and shm - a notification contains a nonce, never a secret, and the shm directory must be a real uid-owned directory
  • Logs and cryptography - bodies, passphrases and PRFs are never logged, and keyctl payloads are sent on stdin
  • Clipboard and screen - a clipboard value is OS-wide until overwritten, and show returns only image bytes with empty alt, which limits OCR but not a person seeing the screen
  • SIGHUP - commands ignore SIGHUP, so a detached run waits like an attached one
  • Detached vault - vault exec under nohup or setsid outlives the terminal, and a hangup during its unlock wait does not stop it

Requirements

  • Server and gesture - JupyterLab >= 4.0.0 over HTTPS or localhost (a secure context), and an open tab supplies the gesture
  • Hostname - a passkey belongs to a hostname, never an IP
  • Dependencies - the notifications extension is installed with the package
  • Passkeys - create and get need a passkey, while passphrase, copy and show need none
  • GnuPG - the vault needs it (apt install gnupg) so gpg-agent can hold the unlocked key where the kernel keyring is not allowed
  • Without GnuPG - the vault still works with the key in server memory and reports this

Development install

pip install -e "."
jupyter labextension develop . --overwrite
jlpm build

Rebuild after changes with jlpm build, or run jlpm watch in one terminal alongside JupyterLab. See CONTRIBUTING.md for the full development, testing, and release workflow.

Uninstall

pip uninstall jupyterlab_passkey_extension

License

BSD-3-Clause; see LICENSE.

Metadata

Release files for jupyterlab-passkey-extension 1.1.55

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for jupyterlab-passkey-extension 1.1.55
File Size Uploaded Provenance
jupyterlab_passkey_extension-1.1.55.tar.gz 791.6 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for jupyterlab-passkey-extension 1.1.55
File Interpreter ABI Platform
jupyterlab_passkey_extension-1.1.55-py3-none-any.whl Python 3 none any Details

Total release size: 1.0 MB

Release files / jupyterlab_passkey_extension-1.1.55.tar.gz

Download URL jupyterlab_passkey_extension-1.1.55.tar.gz
Size 791.6 kB
Tags Source
SHA-256 checksum
How to use checksums
d3f123a2e309fb0516975097a48ff1b9cbe186d666699034ef4c41536d2ca7c7
BLAKE2b-256 checksum
How to use checksums
4c22ab332bc3398acf8571c6fa6c29e149742d3f1a27a1f3985f12b12736fd24
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.8

Release files / jupyterlab_passkey_extension-1.1.55-py3-none-any.whl

Download URL jupyterlab_passkey_extension-1.1.55-py3-none-any.whl
Size 232.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
df316d96e8ef2cfc9d7b7e876b09f867b24185e941ac34af2426d6d9b43f4d61
BLAKE2b-256 checksum
How to use checksums
cf9256076d3a1d3c06fd0ead51dbaf093f9087bafac00e66cf8fdf08b313a82f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.8

Security for jupyterlab-passkey-extension 1.1.55

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Release history Release notifications | RSS feed

This release

1.1.55 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page