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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,28 @@ 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).

## [0.0.10] - 2026-09-25

### Changed

- **Sidechain spends are opt-in.** They are off until you turn them on, in the
popup's settings or in the spend window the first time a site asks; declining
answers the site with `code: 'unsupported'`. The settings list the chains in
use with the signer Podkey settled on for each, and **Forget** drops a chain's
signer and its saved state; turning spends off forgets every chain. Signer
pins from 0.0.9 carry over and count as turned on.
- `window.nostr.sidestr.enabled` says whether spends are on, so a page can word
its own buttons. `signTransaction` is present either way.

### Added

- **Spends open quickly.** Podkey keeps each chain's validated state (coins, the
last headers and what each coin carries) and the next spend checks only the
blocks since: on `sidestr:dreamlab`, 545 blocks took 5.9 s to validate the
first time and 0.3 s after. A saved state whose block hash no longer matches
the mirror (a reset chain) is dropped and every block is checked again; the
spend window says which it did.

## [0.0.9] - 2026-09-24

### Added
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> Browser extension for **did:nostr** and **Solid** authentication

[![Version](https://img.shields.io/badge/version-0.0.9-blue.svg)](https://github.com/JavaScriptSolidServer/podkey/actions/workflows/ci.yml?query=branch%3Amain+is%3Asuccess)
[![Version](https://img.shields.io/badge/version-0.0.10-blue.svg)](https://github.com/JavaScriptSolidServer/podkey/actions/workflows/ci.yml?query=branch%3Amain+is%3Asuccess)
[![License](https://img.shields.io/badge/license-AGPL--3.0-green.svg)](LICENSE)
[![NIP-07](https://img.shields.io/badge/NIP--07-compatible-purple.svg)](https://github.com/nostr-protocol/nips/blob/master/07.md)
[![Test Page](https://img.shields.io/badge/test--page-live-brightgreen)](https://javascriptsolidserver.github.io/podkey/test-page/)
Expand Down Expand Up @@ -154,6 +154,14 @@ 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.

Sidechain spends are **off until you turn them on**, in Podkey's settings or in
the spend window the first time a site asks (declining answers the site with
`code: 'unsupported'`). `window.nostr.sidestr.enabled` tells a page which state
it is in, so it can word its own buttons; the method is there either way.
Podkey keeps each chain's validated state, so the next spend checks only the
blocks since; the settings list the chains in use, and **Forget** drops a
chain's signer and its saved state. Turning spends off forgets every chain.

## Architecture

```
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Podkey",
"version": "0.0.9",
"version": "0.0.10",
"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
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.9",
"version": "0.0.10",
"description": "did:nostr and Solid authentication extension - NIP-07 provider for decentralized identity",
"main": "src/index.js",
"type": "module",
Expand Down
5 changes: 5 additions & 0 deletions popup/popup.css
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,11 @@ input:focus-visible + .slider {
color: var(--text-faint);
}

/* ---- Sidechain chains in use (under the setting) ---- */
.sidestr-chains {
margin-top: 8px;
}

/* ---- Version: quiet, on every screen ---- */
.version {
text-align: center;
Expand Down
11 changes: 11 additions & 0 deletions popup/popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,17 @@ <h1>Podkey</h1>
</div>
<button id="enablePasskeyBtn" class="btn-small" type="button">Set up</button>
</div>
<div class="settings-item settings-separated">
<div class="settings-text">
<div class="settings-label">Sidechain spends</div>
<div class="settings-desc">Let sites ask Podkey to sign spends on sidestr chains (test coins). Podkey shows every spend and asks each time. Turning this off forgets every chain.</div>
</div>
<label class="toggle">
<input type="checkbox" id="sidestrToggle" aria-label="Sidechain spends" />
<span class="slider"></span>
</label>
</div>
<div id="sidestrChains" class="trusted-list sidestr-chains" hidden></div>
</div>

<div class="section">
Expand Down
43 changes: 43 additions & 0 deletions popup/popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
wrapPrivateKey
} from '../src/passkey.js';
import { hexToNsec } from '../src/keyformat.js';
import * as sidestrSettings from '../src/sidestr/settings.js';

// Set DEBUG=true to log identity material (public key / DID) for local
// debugging. Off by default so the popup never prints the user's pubkey.
Expand Down Expand Up @@ -246,6 +247,8 @@ async function showMainScreen(status) {
const { podkey_auto_sign: autoSign = false } = await chrome.storage.local.get(['podkey_auto_sign']);
document.getElementById('autoSignToggle').checked = autoSign;

await loadSidestrSettings();

const config = await getPasskeyConfig();
const passkeyBtn = document.getElementById('enablePasskeyBtn');
passkeyBtn.hidden = config?.mode === 'derived';
Expand Down Expand Up @@ -294,6 +297,7 @@ function setupEventListeners() {
// Main screen
document.getElementById('copyBtn').addEventListener('click', handleCopy);
document.getElementById('autoSignToggle').addEventListener('change', handleAutoSignToggle);
document.getElementById('sidestrToggle').addEventListener('change', handleSidestrToggle);
document.getElementById('exportBtn').addEventListener('click', handleExport);
document.getElementById('lockBtn').addEventListener('click', handleLock);
// Same wipe-and-return-to-setup action as the unlock screen's link, surfaced
Expand Down Expand Up @@ -650,6 +654,45 @@ async function handleAutoSignToggle(event) {
console.log('[Podkey] Auto-sign:', enabled);
}

/**
* Sidechain spends (sidestr): off until turned on, here or in the spend window
* when a site first asks. Lists the chains in use with the signer Podkey
* settled on for each; Forget drops a chain's pin and cached state.
*/
async function loadSidestrSettings() {
const s = await sidestrSettings.load(chrome.storage.local);
document.getElementById('sidestrToggle').checked = s.enabled;
const listEl = document.getElementById('sidestrChains');
const ids = Object.keys(s.chains).sort();
listEl.replaceChildren();
listEl.hidden = !s.enabled || ids.length === 0;
for (const chainId of ids) {
const row = document.createElement('div');
row.className = 'trusted-item';
const label = document.createElement('span');
label.className = 'trusted-origin';
const signer = s.chains[chainId].signer;
label.textContent = `${chainId} · signer ${signer.slice(0, 8)}…`;
label.title = `${chainId}, signer ${signer}`;
const btn = document.createElement('button');
btn.className = 'btn-remove';
btn.textContent = 'Forget';
btn.addEventListener('click', async () => {
await sidestrSettings.forgetChain(chrome.storage.local, chainId);
await loadSidestrSettings();
});
row.appendChild(label);
row.appendChild(btn);
listEl.appendChild(row);
}
}

async function handleSidestrToggle(event) {
if (event.target.checked) await sidestrSettings.enable(chrome.storage.local);
else await sidestrSettings.disable(chrome.storage.local);
await loadSidestrSettings();
}

/**
* Handle export private key
*/
Expand Down
13 changes: 13 additions & 0 deletions popup/spend.css
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ body {
margin: 8px 0 10px;
}

/* ---- Opt-in ---- */
.opt-in-list {
margin: 4px 0 12px;
padding-left: 18px;
font-size: 12.5px;
line-height: 1.5;
color: var(--text);
}

.opt-in-list li + li {
margin-top: 4px;
}

/* ---- Loading steps ---- */
.steps {
list-style: none;
Expand Down
15 changes: 15 additions & 0 deletions popup/spend.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@
<code class="origin-badge" id="origin">…</code>
</section>

<!-- 0. Sidechain spends are off until the person turns them on -->
<section id="optIn" class="spend-card" hidden>
<div class="spend-eyebrow">Sidechain spends are off</div>
<p class="failure-text"><code id="optInOrigin"></code> wants Podkey to sign a spend on a sidestr chain. Turn sidechain spends on to see it.</p>
<ul class="opt-in-list">
<li>Sites can then ask Podkey to sign spends. Podkey shows you every spend and asks each time; nothing is signed without you.</li>
<li>Podkey reads and checks each chain itself, and keeps what it checked so the next spend opens quickly.</li>
<li>Test coins only for now. You can turn this off in Podkey's settings, which forgets every chain.</li>
</ul>
<div class="btn-row">
<button class="btn btn-deny" id="optInNo" type="button">Not now</button>
<button class="btn btn-primary" id="optInYes" type="button">Turn on and review</button>
</div>
</section>

<!-- 1. Podkey reads the chain itself -->
<section id="loading" class="spend-card" aria-live="polite">
<div class="spend-eyebrow">Podkey is checking <span id="loadingChain">the chain</span> itself</div>
Expand Down
72 changes: 56 additions & 16 deletions popup/spend.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
*/

import * as core from '../src/sidestr/core.js';
import * as settings from '../src/sidestr/settings.js';

const PINS_KEY = 'podkey_sidestr_signers';
const storage = chrome.storage.local;
const cache = settings.cacheStore(storage);
const PROFILE_KIND = 0;
const $ = (id) => document.getElementById(id);
const id = new URLSearchParams(location.search).get('id');
Expand Down Expand Up @@ -116,7 +118,11 @@ function render ({ req, opened, reviewed, pinned, lib }) {

$('chainId').textContent = ex.chain.id;
$('network').textContent = parent.mainnet ? parent.label : `Test coins · ${parent.label}`;
$('chainCheck').textContent = `✓ ${(ex.tip().height + 1).toLocaleString('en-GB')} blocks checked by Podkey · ${mirrorNote}`;
const blocks = ex.tip().height + 1;
const resumed = opened.fromCache;
$('chainCheck').textContent = resumed == null
? `✓ ${blocks.toLocaleString('en-GB')} blocks checked by Podkey · ${mirrorNote}`
: `✓ ${(blocks - resumed - 1).toLocaleString('en-GB')} new blocks checked by Podkey, ${(resumed + 1).toLocaleString('en-GB')} from your last visit · ${mirrorNote}`;
$('signer').textContent = short(signer, 8, 8);
$('signer').title = signer;
const pin = $('signerPin');
Expand Down Expand Up @@ -211,11 +217,7 @@ async function doSign ({ req, opened, reviewed, pinned, lib }) {
try {
const { signatures } = await send({ type: 'SIDESTR_SIGN_DIGESTS', id, digests: reviewed.sighashes.map((s) => s.digest) });
const result = core.finish({ ex: opened.ex, lib, reviewed, signatures, pub: req.pub });
if (!pinned) {
const pins = (await chrome.storage.local.get(PINS_KEY))[PINS_KEY] ?? {};
pins[req.chain] = opened.signer;
await chrome.storage.local.set({ [PINS_KEY]: pins });
}
if (!pinned) await settings.pinChain(storage, req.chain, opened.signer);
finishWith({ result });
$('review').hidden = true; $('countdownWrap').hidden = true; $('signed').hidden = false;
setTimeout(() => window.close(), 1200);
Expand Down Expand Up @@ -247,9 +249,7 @@ function fail (e) {
$('forgetAck').addEventListener('change', () => { $('forget').disabled = !$('forgetAck').checked; });
$('forget').addEventListener('click', async () => {
const req = await send({ type: 'SIDESTR_REQUEST', id });
const pins = (await chrome.storage.local.get(PINS_KEY))[PINS_KEY] ?? {};
delete pins[req.chain];
await chrome.storage.local.set({ [PINS_KEY]: pins });
await settings.forgetChain(storage, req.chain);
$('failure').hidden = true; $('loading').hidden = false;
main();
});
Expand All @@ -259,23 +259,63 @@ $('reject').addEventListener('click', () => {
window.close();
});

// ---- opt-in ---------------------------------------------------------------

// Spends are off until the person turns them on. A site that asks while they
// are off gets this, in Podkey's own window, rather than a dead end.
function askToTurnOn () {
return new Promise((resolve) => {
$('loading').hidden = true; $('optIn').hidden = false;
$('optInNo').focus(); // the safe choice is the default, as Deny is elsewhere
$('optInYes').onclick = () => { $('optIn').hidden = true; $('loading').hidden = false; resolve(true); };
$('optInNo').onclick = () => resolve(false);
});
}

// ---- main -----------------------------------------------------------------

// Open the chain, from the saved state where there is one; a saved asset view
// that does not match the explorer's saved state means both are dropped and
// every block is checked again.
async function openWithCache (lib, chainId, pinned) {
const progress = (m) => { if (/^Checking/.test(m)) step('stepCheck'); };
let opened = await core.openChain({ lib, chainId, pinned, store: cache, onProgress: progress });
let view;
try {
const raw = opened.fromCache == null ? null : await cache.get(core.assetCacheKey(chainId));
view = core.assetView(opened.ex, lib, { cached: raw ? JSON.parse(raw) : null });
} catch (e) {
if (!(e instanceof core.CacheMismatch) && !(e instanceof SyntaxError)) throw e;
await settings.clearCaches(storage, chainId);
opened = await core.openChain({ lib, chainId, pinned, store: cache, onProgress: progress });
view = core.assetView(opened.ex, lib);
}
const saved = core.serializeView(view, opened.ex);
if (saved && opened.ex.cacheable) await cache.set(core.assetCacheKey(chainId), saved);
return { opened, view };
}

async function main () {
try {
const req = await send({ type: 'SIDESTR_REQUEST', id });
$('origin').textContent = req.origin;
$('loadingChain').textContent = req.chain;
startCountdown(req.expiresAt);
let s = await settings.load(storage);
if (!s.enabled) {
$('optInOrigin').textContent = req.origin;
if (!(await askToTurnOn())) {
finishWith({ error: { code: 'unsupported', message: 'Sidechain spends are turned off in Podkey' } });
window.close();
return;
}
s = await settings.enable(storage);
}
step('stepFind');
const lib = await core.loadLib();
const pinned = ((await chrome.storage.local.get(PINS_KEY))[PINS_KEY] ?? {})[req.chain] ?? null;
const opened = await core.openChain({
lib, chainId: req.chain, pinned,
onProgress: (m) => { if (/^Checking/.test(m)) step('stepCheck'); }
});
const pinned = s.chains[req.chain]?.signer ?? null;
const { opened, view } = await openWithCache(lib, req.chain, pinned);
step('stepReview');
const view = core.assetView(opened.ex, lib);
const reviewed = core.review({ ex: opened.ex, lib, view, pub: req.pub, txHex: req.tx });
render({ req, opened, reviewed, pinned, lib });
} catch (e) {
Expand Down
18 changes: 18 additions & 0 deletions src/injected.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,24 @@ script.onerror = function () {
};
(document.head || document.documentElement).appendChild(script);

// Mirror the sidechain-spends setting onto <html data-podkey-sidestr="on|off">,
// which window.nostr.sidestr.enabled reads, and keep it current. Guarded: an
// orphaned context or a test stub may have no storage API.
function mirrorSidestrSetting (settingsValue) {
const on = settingsValue && typeof settingsValue === 'object' && settingsValue.enabled === true;
document.documentElement?.setAttribute?.('data-podkey-sidestr', on ? 'on' : 'off');
}
try {
chrome.storage?.local?.get?.(['podkey_sidestr', 'podkey_sidestr_signers']).then((got) => {
// 0.0.9 pins count as turned on (src/sidestr/settings.js normalize)
const legacy = got?.podkey_sidestr_signers && Object.keys(got.podkey_sidestr_signers).length > 0;
mirrorSidestrSetting(got?.podkey_sidestr ?? (legacy ? { enabled: true } : null));
}).catch(() => {});
chrome.storage?.onChanged?.addListener?.((changes, area) => {
if (area === 'local' && changes.podkey_sidestr) mirrorSidestrSetting(changes.podkey_sidestr.newValue);
});
} catch { /* no storage here: the attribute stays unset, which reads as off */ }

// Allowed message types that can be forwarded to the background script
const ALLOWED_TYPES = new Set([
'GET_PUBLIC_KEY',
Expand Down
11 changes: 11 additions & 0 deletions src/nostr-provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,17 @@
// what a page calls this signer in its own copy ("Podkey will show you this spend")
name: 'Podkey',

/**
* Whether sidechain spends are turned on in Podkey. They are off until the
* person turns them on; while off, signTransaction still works, and its
* first call asks, in Podkey's own window. Read live: the content script
* mirrors the setting onto <html data-podkey-sidestr>. A page that forges
* the attribute only misleads itself; the spend window checks the setting.
*/
get enabled () {
return globalThis.document?.documentElement?.dataset?.podkeySidestr === 'on';
},

/**
* @param {{chain: string, tx: string}} request - chain id and the
* transaction as hex (any witness is ignored and replaced)
Expand Down
Loading
Loading