diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19d4dd9..eefe807 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,28 @@ jobs: - name: Package extension (sideloadable unpacked zip) run: | mkdir -p dist - zip -r dist/podkey-extension.zip manifest.json src popup $([ -d icons ] && echo icons) -x '*.test.js' + # vendor/ holds the sidestr engine the spend window imports (MV3 forbids remote code) + zip -r dist/podkey-extension.zip manifest.json src popup vendor $([ -d icons ] && echo icons) -x '*.test.js' + + - name: Check the package holds every file the extension loads + run: | + python3 - <<'PY' + import json, re, zipfile + z = set(zipfile.ZipFile('dist/podkey-extension.zip').namelist()) + m = json.load(open('manifest.json')) + need = [m['background']['service_worker'], m['action']['default_popup'], *m['icons'].values()] + need += [f for c in m['content_scripts'] for f in c['js']] + need += [f for w in m.get('web_accessible_resources', []) for f in w['resources']] + for page in ['popup/popup.html', 'popup/approve.html', 'popup/spend.html']: + need += ['popup/' + r for r in re.findall(r'(?:src|href)="([^"#?:]+)"', open(page).read())] + # the spend window's engine: every vendored file PINS.json records + for pin in json.load(open('vendor/sidestr/PINS.json'))['pins']: + need += [f"vendor/sidestr/{pin['name']}/{f}" for f in pin['files']] + missing = sorted(set(need) - z) + if missing: + raise SystemExit('missing from the package: ' + ', '.join(missing)) + print(f'package holds all {len(set(need))} files the extension loads') + PY - name: Upload extension artifact uses: actions/upload-artifact@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index c7a8acd..cb14f91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,67 @@ All notable changes to Podkey will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.0.9] - 2026-09-24 + +### Added + +- **sidestr spends: `window.nostr.sidestr.signTransaction({ chain, tx })`.** + Podkey is the reference signer for sidestr's browser-signer proposal + (`proposals/browser-signer.md` in sidestr/spec). A page on any sidestr chain + asks for a spend and never holds the key. Podkey's own spend window reads the + chain from its id (the signer's announcement, a mirror it names, every block + validated), checks that every input is your own mature, unspent coin, computes + each sighash itself, and shows what leaves your wallet: each recipient (their + npub, and their profile name when found), what comes back, the fee, and any + asset (SPEC 12) the spend moves, destroys or burns. Destroying or burning needs + an explicit tick. Every spend asks, whatever the site is trusted for; the key + stays in the background and signs only what the window computed, once. + Refusals reach the page with a `code`: `rejected`, `unsupported`, + `not-yours`, `invalid` or `unavailable`. +- Chains beside a test network only for now. Each chain's signer is remembered + on first spend and a different one is refused, since a chain id is a name, + not a proof. +- The sidestr engine is vendored at pinned commits (`scripts/vendor-sidestr.js`, + `vendor/sidestr/PINS.json` with a SHA-256 per file): Manifest V3 forbids code + from the network. Chains that name the EVM rule are `unsupported` until it is + vendored too. + +### Fixed + +- Requests from a page wait up to three minutes instead of 30 seconds, so a site + no longer times out while you unlock Podkey with a security key's PIN and + touch. Each request's timer is cleared when its answer arrives. + +- **Security keys: passkey windows no longer fail straight away.** A passkey + window started its WebAuthn ceremony on `DOMContentLoaded`, often before the + new window had focus, and WebAuthn refuses an unfocused document with the same + `NotAllowedError` as a cancel. Ceremonies now wait for focus. +- **Security keys: the browser goes straight to the key.** The transports a + credential reports (`usb`, `nfc`, …) are stored and passed in + `allowCredentials`, so unlock asks to insert and touch the key instead of + opening the generic chooser that leads with a phone/QR option. Passkeys set up + before this keep working without the hint. +- **"Unlock with passkey" showed with no passkey set up.** `.btn` overrode the + `hidden` attribute; a global `[hidden]` rule restores it. +- Ceremony errors name the step and say what to do (PIN, touch, keep the window + in front); `InvalidStateError`, `NotSupportedError` and `SecurityError` get + their own messages. Failures show inline with **Try again** instead of an + `alert()` over a dead window, and set-up says which of its two touches is next. +- Ceremony timeouts are three minutes (time to find a key and set a first PIN); + EdDSA and RS256 are offered after ES256 for authenticators that lack ES256. +- A window opened only to unlock closes itself once unlocked, handing focus back + to the site that asked. +- The **Set up** passkey button no longer stays on "Waiting…" after a failure. + +### Changed + +- The first-approval prompt says that approving also trusts the site from then + on (it always did; the prompt did not say so). +- Export shows the key as `nsec`, the form other apps and Podkey's import take. +- Plainer copy on the welcome, import and passkey screens; import says it takes + `nsec` or hex. Stronger text contrast in light and dark, a primary-button fill + that passes contrast in dark mode, and keyboard focus rings on every control. + Debug logging removed from the popup. ### Added diff --git a/README.md b/README.md index 11d5988..8165c61 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,33 @@ const payload = await window.nostr.nip44.encrypt(peer, 'hello') const plaintext = await window.nostr.nip44.decrypt(peer, payload) ``` +#### `window.nostr.sidestr.signTransaction({ chain, tx })` + +Signs a spend on a [sidestr](https://sidestr.com) chain, per sidestr's +[browser-signer proposal](https://github.com/sidestr/spec/blob/gh-pages/proposals/browser-signer.md). +A sidestr coin pays `OP_1 `, so the key Podkey guards is the key that +spends your coins. `chain` is a chain id such as `sidestr:dreamlab`; `tx` is the +transaction as hex (any witness is ignored). It resolves `{ tx, txid }` with +every input signed, or rejects with an `Error` whose `code` is `rejected`, +`unsupported`, `not-yours`, `invalid` or `unavailable`. + +```javascript +if (window.nostr?.sidestr?.version >= 1) { + const { tx, txid } = await window.nostr.sidestr.signTransaction({ chain: 'sidestr:dreamlab', tx: unsignedHex }) + // publish `tx` as a kind 23500 event from any key: a transaction authorises itself +} +``` + +The page is trusted for nothing but the request. Podkey opens its own spend +window, reads the chain from its id (the signer's tip announcement, a mirror it +names, every block validated), checks that every input is your own mature, +unspent coin, computes each sighash itself, and shows what leaves your wallet: +each recipient, what comes back, the fee, and any asset the spend moves or +destroys. It asks every time, whatever else the site is trusted for, and signs +only after you confirm. It takes chains beside test networks only for now, and +remembers each chain's signer on the first spend so that a different signer +announcing the same id is refused. + ## Architecture ``` @@ -137,6 +164,13 @@ const plaintext = await window.nostr.nip44.decrypt(peer, payload) │ Page bridge (src/injected.js) │ │ injects window.nostr, relays requests │ │ to the worker, whitelists message types│ +│ │ +│ sidestr spend window (popup/spend.*, │ +│ src/sidestr/) │ +│ validates the chain with the vendored │ +│ engine (vendor/sidestr/), reviews and │ +│ shows the spend; the worker signs its │ +│ sighashes once, after you confirm │ └─────────────────────────────────────────┘ ``` diff --git a/manifest.json b/manifest.json index 0754b3f..76625aa 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Podkey", - "version": "0.0.8", + "version": "0.0.9", "description": "Nostr (NIP-07) signer and Solid (NIP-98) login. One did:nostr key, encrypted on your device.", "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" @@ -52,4 +52,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/package.json b/package.json index d5c12fb..99c2569 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "podkey", - "version": "0.0.8", + "version": "0.0.9", "description": "did:nostr and Solid authentication extension - NIP-07 provider for decentralized identity", "main": "src/index.js", "type": "module", diff --git a/popup/approve.css b/popup/approve.css index 3f042c9..e0e0992 100644 --- a/popup/approve.css +++ b/popup/approve.css @@ -220,7 +220,7 @@ body { justify-content: center; gap: 6px; font-size: 11px; - color: var(--text-faint); + color: var(--text-muted); margin-bottom: 7px; } diff --git a/popup/approve.html b/popup/approve.html index 29b0099..0136260 100644 --- a/popup/approve.html +++ b/popup/approve.html @@ -46,7 +46,7 @@ - Approving lets this site act with your key as if it were you. + Approving also trusts this site: from now on it can sign and read messages as you without asking. You can remove it under Trusted sites in Podkey.
diff --git a/popup/popup.css b/popup/popup.css index 89bab18..299d032 100644 --- a/popup/popup.css +++ b/popup/popup.css @@ -14,8 +14,9 @@ /* Text */ --text: #1e293b; - --text-muted: #64748b; - --text-faint: #94a3b8; + --text-muted: #5b6b80; + /* decorative text only (placeholders, separators); never body copy */ + --text-faint: #8391a5; /* Borders */ --border: #e2e8f0; @@ -26,6 +27,9 @@ --accent-hover: #1d4ed8; --accent-soft: #eff6ff; --accent-ring: rgba(37, 99, 235, 0.25); + /* fill behind white text: dark mode's lighter --accent fails contrast there */ + --accent-solid: #2563eb; + --accent-solid-hover: #1d4ed8; /* Semantic */ --safe: #16a34a; @@ -64,8 +68,8 @@ --surface-inset: #0f151d; --text: #e6eaf0; - --text-muted: #9aa6b6; - --text-faint: #6b7888; + --text-muted: #a3afbf; + --text-faint: #7b8898; --border: #2a3340; --border-strong: #3a4452; @@ -74,6 +78,8 @@ --accent-hover: #74a0f4; --accent-soft: #16223a; --accent-ring: rgba(91, 141, 239, 0.35); + --accent-solid: #2f63d4; + --accent-solid-hover: #3a6fe0; --safe: #4ade80; --safe-hover: #6ee79a; @@ -246,13 +252,13 @@ h2 { } .btn-primary { - background: var(--accent); + background: var(--accent-solid); color: #fff; box-shadow: var(--shadow-sm); } .btn-primary:hover { - background: var(--accent-hover); + background: var(--accent-solid-hover); } .btn-secondary { @@ -279,13 +285,66 @@ h2 { .advanced-choice p { margin-top: 7px; line-height: 1.45; } .advanced-choice .advanced-audience { color: var(--text-muted); border-left: 2px solid var(--border-strong); padding-left: 9px; } .text-action { width: 100%; margin-top: 10px; padding: 9px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); color: var(--text); font: inherit; font-weight: 600; cursor: pointer; } -.divider { display: flex; align-items: center; gap: 8px; margin: 12px 0; color: var(--text-faint); font-size: 10px; text-transform: uppercase; letter-spacing: .06em; } +.divider { display: flex; align-items: center; gap: 8px; margin: 12px 0; color: var(--text-muted); font-size: 10px; text-transform: uppercase; letter-spacing: .06em; } .divider::before, .divider::after { content: ''; height: 1px; flex: 1; background: var(--border); } .settings-separated { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); } .btn-small { flex: none; padding: 6px 10px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); color: var(--accent); font: inherit; font-size: 11px; font-weight: 700; cursor: pointer; } .backup-ack { display: flex; align-items: center; gap: 8px; margin: 12px 0 4px; font-size: 12px; color: var(--text); cursor: pointer; } .backup-ack input { accent-color: var(--accent); width: 15px; height: 15px; flex: none; cursor: pointer; } +/* Keyboard focus for every control that had none (small buttons, the + passkey summary, footer links, toggles). */ +.btn-small:focus-visible, +.text-action:focus-visible, +.btn-copy:focus-visible, +.btn-remove:focus-visible, +.advanced-choice summary:focus-visible, +.footer a:focus-visible, +.toggle input:focus-visible + .slider { + outline: none; + box-shadow: 0 0 0 3px var(--accent-ring); + border-radius: var(--radius-sm); +} + +.btn-small:hover, +.text-action:hover { + background: var(--surface-2); +} + +.btn-small[disabled], +.text-action[disabled] { + opacity: 0.55; + cursor: progress; +} + +/* ---- Passkey status line ---- */ +.flow-status { + margin: 12px 18px 0; + padding: 10px 12px; + border: 1px solid var(--accent-ring); + border-radius: var(--radius-sm); + background: var(--accent-soft); + color: var(--text); + font-size: 12.5px; + line-height: 1.45; +} + +.flow-status.error { + border-color: var(--danger-border); + background: var(--danger-soft); + color: var(--danger); +} + +.flow-status .btn { + margin: 10px 0 0; + padding: 8px 12px; + font-size: 13px; +} + +[hidden] { + display: none !important; +} + /* ---- Identity ---- */ .identity-section .section-title { margin-bottom: 10px; @@ -584,7 +643,7 @@ input:focus-visible + .slider { .empty-state { text-align: center; padding: 22px 16px; - color: var(--text-faint); + color: var(--text-muted); font-size: 12.5px; } diff --git a/popup/popup.html b/popup/popup.html index b816efe..11da8f4 100644 --- a/popup/popup.html +++ b/popup/popup.html @@ -7,6 +7,13 @@ + + + @@ -43,19 +50,18 @@

Welcome to Podkey

Advanced: passkey identity -

For managing agents or working under compliance rules: bind your key to a FIDO2 passkey — hardware-backed, phishing-resistant, and unlocked with biometrics or a security key.

+

For managing agents or working under compliance rules: tie your key to a passkey — a security key such as a YubiKey, or your phone or computer's own passkey.

-

No passphrase. Your passkey recreates the same Nostr key. Export a backup in case the passkey is lost.

+

No passphrase: your passkey recreates the same key each time. You'll touch a security key twice while setting up, and it needs a PIN (your browser helps you set one). Save the backup Podkey shows you, in case the passkey is lost.

What is Podkey?

- A browser extension for did:nostr and - Solid authentication. It holds your cryptographic - identity and signs in to Solid pods using your did:nostr identity — - your keys never leave this device. + Podkey holds your Nostr key and signs for you when a + site asks — posts, messages, and sign-in to Solid + pods. You approve each new site. Your key never leaves this device.

@@ -134,15 +140,15 @@

Unlock Podkey

Import private key

-

Enter your 64-character hex private key

+

Paste your nsec or 64-character hex key

- +