This project was started the week of Feb 15, 2026 while I was on PTO from my job and using only my personal laptop.
Cross-browser WebExtension for SSH-key based web login.
This extension reads login challenge fields from a compatible /login form, asks a native host to sign with ssh-keygen -Y sign, writes the SSH signature into the form, and submits to your backend verifier.
- Extension (this repo): https://github.com/daniel-alexander4/sshkey-web-auth-extension
- Demos (web app + registry): https://github.com/daniel-alexander4/sshkey-web-auth-demos
- MediaSurfer: https://github.com/daniel-alexander4/mediasurfer
- Chrome
- Edge
- Firefox
- Safari (via Safari Web Extension packaging)
Use this path if you want to run the extension locally while developing.
- Node.js 20+
- npm
- Python 3
ssh-keygenavailable on yourPATH- A usable SSH private key (default expected path:
~/.ssh/id_ed25519)
npm install
npm run typecheck
npm run test
npm run build:allBuild output:
- Chromium browsers:
dist/chrome - Firefox:
dist/firefox
Chromium (Chrome/Edge/Brave/Chromium):
- Open the extensions page:
- Chrome:
chrome://extensions - Edge:
edge://extensions - Brave:
brave://extensions
- Chrome:
- Enable Developer mode
- Click Load unpacked and select
dist/chrome - Copy the extension ID (required for native host setup)
Firefox:
- Open
about:debugging#/runtime/this-firefox - Click Load Temporary Add-on
- Select
dist/firefox/manifest.json
From repo root, run one of the following.
Linux:
cd native-host
./install-linux.sh --chrome --extension-id <CHROMIUM_EXTENSION_ID> --firefoxmacOS:
cd native-host
./install-macos.sh --firefox
./install-macos.sh --chrome --extension-id <CHROMIUM_EXTENSION_ID>Windows (PowerShell):
cd native-host
.\install-windows.ps1 -Firefox
.\install-windows.ps1 -Chrome -ExtensionId <CHROMIUM_EXTENSION_ID>Firefox-only setup (any OS):
- Linux:
./install-linux.sh --firefox - macOS:
./install-macos.sh --firefox - Windows:
.\install-windows.ps1 -Firefox
Open the extension popup/options and set:
Native host name:com.pubkey.authSSH private key path: path to your private keyAllowed origins: one exact origin per lineOrigin mappings:<origin> <namespace>or<origin> <company> <namespace>
- Open your
/loginpage. - Submit with the extension enabled.
- Confirm
signatureis populated and/auth/verifysucceeds.
If it fails:
- Re-run the OS installer script.
- Confirm your Chromium extension ID in native host manifests.
- Rebuild with
DEBUG=true npm run build:alland inspect extension logs.
Optional environment overrides:
export PUBKEY_AUTH_KEY_PATH=~/.ssh/id_ed25519
export SSH_KEYGEN_PATH=/usr/bin/ssh-keygen
export PUBKEY_AUTH_SIGN_TIMEOUT_SECONDS=10npm install
npm run typecheck
npm run test
npm run dev:chromeDebug build logging:
DEBUG=true npm run build:allWith DEBUG=true, extension logs are emitted from popup/content/background with a [sshkey-web-auth:*] prefix.
Configure in popup/options:
Native host name(defaultcom.pubkey.auth)SSH private key pathAllowed origins(one exact origin per line)Origin mappings(one line:<origin> <namespace>or<origin> <company> <namespace>)
Both origin and namespace must exactly match what your website uses.
Extension triggers only when all are true:
- path:
/login - form id:
auth-verify-form - submit button id:
login-submit-btn - method:
POST - action:
/auth/verify(same origin)
Required form fields:
input[name="username"]textarea[name="signature"]input[name="ssh_challenge"]input[name="ssh_namespace"]- optional:
input[name="ssh_company"]
The strict auth-verify-form and login-submit-btn selectors are intentional. The extension only binds on this exact login structure as a security boundary.
Example:
<form id="auth-verify-form" method="post" action="/auth/verify">
<input name="username" autocomplete="username" />
<textarea name="signature" hidden></textarea>
<input type="hidden" name="ssh_challenge" value="BASE64URL_CHALLENGE" />
<input type="hidden" name="ssh_company" value="mediasurfer" />
<input type="hidden" name="ssh_namespace" value="mediasurfer" />
<button id="login-submit-btn" type="submit">Login</button>
</form>Backend requirements:
- issue one-time challenge per login
- enforce challenge format and expiration
- verify SSH signature
- verify username <-> public key association
- reject replayed challenges
- Native host signing timeout defaults to
10s(PUBKEY_AUTH_SIGN_TIMEOUT_SECONDS). - Signing requests from the content script wait for native host completion and therefore follow
PUBKEY_AUTH_SIGN_TIMEOUT_SECONDS. - Popup/options runtime requests use a
12sguard timeout for settings/status interactions.
- Native host not found:
- Verify native host manifests exist under browser-specific native messaging directories.
- Re-run the installer for your OS (
install-linux.sh,install-macos.sh, orinstall-windows.ps1).
- Signing times out:
- Confirm
ssh-keygenis available and executable. - Set
PUBKEY_AUTH_SIGN_TIMEOUT_SECONDSto a larger value and retry.
- Confirm
- Key permission failures:
- Run
chmod 600 <key-path>. - Ensure the key file is owned by your user.
- Run
- Debug extension logs:
- Rebuild with
DEBUG=true npm run build:all, reload extension, then inspect browser extension console logs.
- Rebuild with
Scripts are provided:
- macOS:
native-host/install-macos.sh - Windows:
native-host/install-windows.ps1
For production packaging checklists, see store-listings/shared/release-checklist.md.
Demo servers were moved to their own repo:
See CONTRIBUTING.md for local workflow and required checks.
- Signing is restricted by configured origins and namespace mapping.
- Use TLS in real deployments.
- Keep private keys and API tokens out of source control.
MIT (see LICENSE).
LOCAL CLIENT MACHINE
+--------------------------------------+
| +---------------------------+ |
| | Browser (/login page) | |
| +-------------+-------------+ |
| | reads challenge fields |
| v |
| +---------------------------+ |
| | Extension (content/popup) | |
| +-------------+-------------+ |
| | native messaging |
| v |
| +---------------------------+ |
| | Native host | |
| | com.pubkey.auth | |
| +-------------+-------------+ |
| | runs ssh-keygen -Y sign |
| v |
| +---------------------------+ |
| | ~/.ssh private key | |
| +---------------------------+ |
+----------------------+-----------------------+
|
| [GREEN] --> GET /login
| [WHITE] <-- HTML form + challenge
| [GREEN] --> POST /auth/verify (signed)
v
+------------------------+
| web-demo auth server |
| verifies sig/challenge |
+-----------+------------+
|
+----------+----------+
| |
v v
+------------------+ +----------------------+
| local users.json | | registry-demo API |
| (local mode) | | (registry mode) |
+------------------+ +----------------------+
Flow summary:
1) Browser loads web-demo login page.
2) Extension reads challenge/namespace fields and signs via native host + `ssh-keygen`.
3) Browser submits form with SSH signature to web-demo `/auth/verify`.
4) web-demo uses local `users.json` (local mode) or calls registry-demo API (registry mode).
5) web-demo verifies signature against returned public keys and authenticates.