jupyterlab_passkey_extension
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 -
createregisters andgetasserts 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-saltreturns 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:...orfile:...)- 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 relayshow- 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-clican send bytes intocopywithout printing them - Help and skill -
--helpsays what each subcommand prints and whether it waits, andSKILL.mdadds 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 NAMEon any subcommand orJLAB_PASSKEY_CALLERfrom 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 -
0succeeded,1refused, timed out or could not reach the server,2an argument the parser rejects - Stdout - only the result goes to stdout, so
$(...)captures only the result copy- returns once posted,--blockwaits for collection only, and a terminal stdin is refusedshow- 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)orScrypt(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/...), mode0600, path set byJLAB_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) andrevealSeconds(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 forrevealSecondsseconds (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 FILEorimport --pass-cliadds 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 statusand--debugshow 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|memoryselects 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 lockbefore changing the holder,JLAB_PASSKEY_VAULTor 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 answer204or a JSON body - Vault endpoint -
GET,POSTandPATCH <base_url>/jupyterlab-passkey-extension/vault/<action>answer423while locked - Bad nonce - answers
400and creates no file, and the nonce matches[A-Za-z0-9_-]{16,128} - POST for reads -
secretandrenderarePOSTs to keep the nonce out of the access log
The relay contract
- Directory -
/dev/shm/jlab-passkey-$(id -u), mode0700, ownership checked, overrideJLAB_PASSKEY_RELAY_DIR - Files -
<nonce>.json,.pass,.secret,.code,.cancel - Lifecycle - mode
0600, written withmkstemp-then-os.replace - Deletion - the consumer deletes
.jsonand.pass, while.secretand.codeare 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- returnscred_idandprf_enabled- Errors -
not-allowedhides three causes, andrp-id-mismatchis a separateSecurityError
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-unlockandpasskey:vault-register(reference) - Gesture -
passkey:runreachesnavigator.credentials.*before anyawaitso 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/shm0600file set byJLAB_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
showreturns only image bytes with emptyalt, 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 execundernohuporsetsidoutlives 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 -
createandgetneed a passkey, whilepassphrase,copyandshowneed 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)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| jupyterlab_passkey_extension-1.1.55.tar.gz | 791.6 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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.