Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 61 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <your key>`, 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

```
Expand All @@ -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 │
└─────────────────────────────────────────┘
```

Expand Down
4 changes: 2 additions & 2 deletions manifest.json
Original file line number Diff line number Diff line change
@@ -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'"
Expand Down Expand Up @@ -52,4 +52,4 @@
]
}
]
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion popup/approve.css
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ body {
justify-content: center;
gap: 6px;
font-size: 11px;
color: var(--text-faint);
color: var(--text-muted);
margin-bottom: 7px;
}

Expand Down
2 changes: 1 addition & 1 deletion popup/approve.html
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span>Approving lets this site act with your key as if it were you.</span>
<span>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.</span>
</div>

<div class="btn-row">
Expand Down
75 changes: 67 additions & 8 deletions popup/popup.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
28 changes: 17 additions & 11 deletions popup/popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<!-- Passkey status: what to do with the authenticator now, or what went
wrong and a way to try again (set by popup.js showFlowStatus) -->
<div id="flowStatus" class="flow-status" role="status" aria-live="polite" hidden>
<p id="flowStatusText"></p>
<button id="flowRetryBtn" class="btn btn-secondary" type="button" hidden>Try again</button>
</div>

<!-- Setup Screen (shown when no key exists) -->
<div id="setupScreen" class="screen" style="display: none">
<div class="header center">
Expand All @@ -20,7 +27,7 @@
</div>
<h1>Welcome to Podkey</h1>
<p class="subtitle">
did:nostr and Solid authentication for the decentralized web
One key, kept on this device, for Nostr apps and Solid pods
</p>
</div>

Expand All @@ -43,19 +50,18 @@ <h1>Welcome to Podkey</h1>
</button>
<details class="advanced-choice">
<summary>Advanced: passkey identity</summary>
<p class="advanced-audience">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.</p>
<p class="advanced-audience">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.</p>
<button id="passkeyDerivedBtn" class="text-action" type="button">Create identity from a passkey</button>
<p>No passphrase. Your passkey recreates the same Nostr key. Export a backup in case the passkey is lost.</p>
<p>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.</p>
</details>
</div>

<div class="info-box">
<div class="info-box-title">What is Podkey?</div>
<p>
A browser extension for <strong>did:nostr</strong> and
<strong>Solid</strong> 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 <strong>Nostr</strong> key and signs for you when a
site asks — posts, messages, and sign-in to <strong>Solid</strong>
pods. You approve each new site. Your key never leaves this device.
</p>
</div>
</div>
Expand Down Expand Up @@ -134,15 +140,15 @@ <h1>Unlock Podkey</h1>
</div>
<div>
<h2>Import private key</h2>
<p class="subtitle">Enter your 64-character hex private key</p>
<p class="subtitle">Paste your nsec or 64-character hex key</p>
</div>
</div>

<div class="section">
<label for="privateKeyInput">Private key (hex)</label>
<label for="privateKeyInput">Private key</label>
<textarea
id="privateKeyInput"
placeholder="64-character hexadecimal private key…"
placeholder="nsec1… or 64 hex characters"
rows="3"
spellcheck="false"
autocomplete="off"
Expand Down Expand Up @@ -251,7 +257,7 @@ <h1>Podkey</h1>
</div>

<div class="identity-field">
<div class="identity-label">Public key (npub hex)</div>
<div class="identity-label">Public key (hex)</div>
<div class="identity-value" id="publicKey">Loading…</div>
</div>

Expand Down
Loading
Loading