Browse Source

Add NIP-46 bunker service for remote signing with CAT support (v0.44.0)

- Add bunker-service.js: NIP-46 signer that handles signing requests from remote clients
- Add cashu-client.js: Cashu token minting for bunker authorization
- Update BunkerView.svelte: Add Start/Stop service toggle, CAT token generation, status indicator
- Update App.svelte: Pass userPrivkey to BunkerView for signing
- Add @noble/curves and @noble/hashes dependencies
- Include CAT token in bunker URL format: bunker://<pubkey>?relay=...&secret=...&cat=...
- Improve PWA manifest with maskable icons

Files modified:
- app/web/src/bunker-service.js: NEW - NIP-46 signer implementation
- app/web/src/cashu-client.js: NEW - Cashu token minting client
- app/web/src/BunkerView.svelte: Add service controls and CAT integration
- app/web/src/App.svelte: Add userPrivkey state and prop
- app/web/package.json: Add noble crypto dependencies
- app/web/public/manifest.json: Add maskable icon variants
- pkg/version/version: Bump to v0.44.0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
main
woikos 2 weeks ago
parent
commit
7ed1aea0f1
No known key found for this signature in database
  1. 10
      app/web/bun.lock
  2. 2
      app/web/dist/bundle.css
  3. 41
      app/web/dist/bundle.js
  4. 2
      app/web/dist/bundle.js.map
  5. 83
      app/web/package-lock.json
  6. 2
      app/web/package.json
  7. 21
      app/web/public/manifest.json
  8. 10
      app/web/src/App.svelte
  9. 436
      app/web/src/BunkerView.svelte
  10. 508
      app/web/src/bunker-service.js
  11. 252
      app/web/src/cashu-client.js
  12. 2
      pkg/version/version

10
app/web/bun.lock

@ -4,6 +4,8 @@ @@ -4,6 +4,8 @@
"": {
"name": "svelte-app",
"dependencies": {
"@noble/curves": "^1.4.0",
"@noble/hashes": "^1.4.0",
"applesauce-core": "^4.4.2",
"applesauce-signers": "^4.2.0",
"hash-wasm": "^4.12.0",
@ -37,7 +39,7 @@ @@ -37,7 +39,7 @@
"@noble/ciphers": ["@noble/ciphers@0.5.3", "", {}, "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="],
"@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="],
"@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="],
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
@ -343,8 +345,6 @@ @@ -343,8 +345,6 @@
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
"@noble/curves/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="],
"@scure/bip32/@noble/curves": ["@noble/curves@1.1.0", "", { "dependencies": { "@noble/hashes": "1.3.1" } }, "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA=="],
"@scure/bip32/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="],
@ -363,6 +363,8 @@ @@ -363,6 +363,8 @@
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"nostr-tools/@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="],
"nostr-tools/@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="],
"nostr-tools/@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="],
@ -375,6 +377,8 @@ @@ -375,6 +377,8 @@
"globby/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"nostr-tools/@noble/curves/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="],
"rollup-plugin-svelte/@rollup/pluginutils/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"globby/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],

2
app/web/dist/bundle.css vendored

File diff suppressed because one or more lines are too long

41
app/web/dist/bundle.js vendored

File diff suppressed because one or more lines are too long

2
app/web/dist/bundle.js.map vendored

File diff suppressed because one or more lines are too long

83
app/web/package-lock.json generated

@ -8,6 +8,8 @@ @@ -8,6 +8,8 @@
"name": "svelte-app",
"version": "1.0.0",
"dependencies": {
"@noble/curves": "^1.4.0",
"@noble/hashes": "^1.4.0",
"applesauce-core": "^4.4.2",
"applesauce-signers": "^4.2.0",
"hash-wasm": "^4.12.0",
@ -75,30 +77,27 @@ @@ -75,30 +77,27 @@
}
},
"node_modules/@noble/curves": {
"version": "1.2.0",
"version": "1.9.7",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.2"
"@noble/hashes": "1.8.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "1.3.2",
"license": "MIT",
"engines": {
"node": ">= 16"
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.3.1",
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": ">= 16"
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
@ -435,18 +434,6 @@ @@ -435,18 +434,6 @@
"url": "lightning:nostrudel@geyser.fund"
}
},
"node_modules/applesauce-core/node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/applesauce-core/node_modules/@scure/base": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
@ -476,18 +463,6 @@ @@ -476,18 +463,6 @@
"url": "lightning:nostrudel@geyser.fund"
}
},
"node_modules/applesauce-signers/node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/applesauce-signers/node_modules/@noble/secp256k1": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.2.tgz",
@ -1244,6 +1219,42 @@ @@ -1244,6 +1219,42 @@
}
}
},
"node_modules/nostr-tools/node_modules/@noble/curves": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.2"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@noble/hashes": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-wasm": {
"version": "0.1.0",
"license": "MIT"

2
app/web/package.json

@ -22,6 +22,8 @@ @@ -22,6 +22,8 @@
"svelte": "^3.55.0"
},
"dependencies": {
"@noble/curves": "^1.4.0",
"@noble/hashes": "^1.4.0",
"applesauce-core": "^4.4.2",
"applesauce-signers": "^4.2.0",
"hash-wasm": "^4.12.0",

21
app/web/public/manifest.json

@ -1,22 +1,39 @@ @@ -1,22 +1,39 @@
{
"id": "/",
"name": "ORLY Nostr Relay",
"short_name": "ORLY",
"description": "High-performance Nostr relay",
"display": "standalone",
"orientation": "any",
"start_url": "/",
"scope": "/",
"theme_color": "#000000",
"background_color": "#000000",
"categories": ["utilities", "social"],
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

10
app/web/src/App.svelte

@ -56,6 +56,7 @@ @@ -56,6 +56,7 @@
let userProfile = null;
let userRole = "";
let userSigner = null;
let userPrivkey = null; // User's private key for bunker service (only set for nsec login)
let showSettingsDrawer = false;
let selectedTab = localStorage.getItem("selectedTab") || "export";
let showFilterBuilder = false; // Show filter builder in events view
@ -1749,6 +1750,13 @@ @@ -1749,6 +1750,13 @@
userSigner = signer;
showLoginModal = false;
// Store private key for bunker service (only for nsec login)
if (method === "nsec" && privateKey) {
userPrivkey = privateKey;
} else {
userPrivkey = null;
}
// Initialize Nostr client and fetch profile
try {
await initializeNostrClient();
@ -1781,6 +1789,7 @@ @@ -1781,6 +1789,7 @@
userProfile = null;
userRole = "";
userSigner = null;
userPrivkey = null;
showSettingsDrawer = false;
// Clear events
@ -2820,6 +2829,7 @@ @@ -2820,6 +2829,7 @@
{isLoggedIn}
{userPubkey}
{userSigner}
{userPrivkey}
{currentEffectiveRole}
on:openLoginModal={openLoginModal}
/>

436
app/web/src/BunkerView.svelte

@ -1,11 +1,15 @@ @@ -1,11 +1,15 @@
<script>
import { createEventDispatcher, onMount } from "svelte";
import { createEventDispatcher, onMount, onDestroy } from "svelte";
import QRCode from "qrcode";
import { getBunkerInfo } from "./api.js";
import { getBunkerInfo, createNIP98Auth } from "./api.js";
import { BunkerService } from "./bunker-service.js";
import { requestToken, encodeToken, TokenScope, getMintInfo } from "./cashu-client.js";
import { hexToBytes } from "@noble/hashes/utils";
export let isLoggedIn = false;
export let userPubkey = "";
export let userSigner = null;
export let userPrivkey = null; // User's private key for signing
export let currentEffectiveRole = "";
const dispatch = createEventDispatcher();
@ -19,6 +23,14 @@ @@ -19,6 +23,14 @@
let copiedItem = "";
let bunkerSecret = "";
// Bunker service state
let bunkerService = null;
let isServiceActive = false;
let isStartingService = false;
let connectedClients = [];
let catToken = null;
let catTokenEncoded = "";
$: canAccess = isLoggedIn && userPubkey && (
currentEffectiveRole === "write" ||
currentEffectiveRole === "admin" ||
@ -27,7 +39,7 @@ @@ -27,7 +39,7 @@
// Generate bunker URLs when bunkerInfo and userPubkey are available
$: clientBunkerURL = bunkerInfo && userPubkey ?
`bunker://${userPubkey}?relay=${encodeURIComponent(bunkerInfo.relay_url)}${bunkerSecret ? `&secret=${bunkerSecret}` : ''}` : "";
`bunker://${userPubkey}?relay=${encodeURIComponent(bunkerInfo.relay_url)}${bunkerSecret ? `&secret=${bunkerSecret}` : ''}${catTokenEncoded ? `&cat=${catTokenEncoded}` : ''}` : "";
$: signerBunkerURL = bunkerInfo ?
`nostr+connect://${bunkerInfo.relay_url}` : "";
@ -36,6 +48,113 @@ @@ -36,6 +48,113 @@
await loadBunkerInfo();
});
onDestroy(() => {
// Stop bunker service on component unmount
if (bunkerService) {
bunkerService.disconnect();
bunkerService = null;
isServiceActive = false;
}
});
// Start the bunker service
async function startBunkerService() {
if (!userPrivkey || !userPubkey || !bunkerInfo) {
error = "Missing private key or bunker info";
return;
}
isStartingService = true;
error = "";
try {
// Check if CAT is required and mint one
if (bunkerInfo.cashu_enabled) {
console.log("CAT required, minting token...");
const mintInfo = await getMintInfo(bunkerInfo.relay_url);
if (mintInfo) {
// Create NIP-98 auth function
const signHttpAuth = async (url, method) => {
const header = await createNIP98Auth(userSigner, userPubkey, method, url);
return `Nostr ${header}`;
};
// Request NIP-46 scoped token
catToken = await requestToken(
mintInfo.mintUrl,
TokenScope.NIP46,
hexToBytes(userPubkey),
signHttpAuth,
[24133]
);
catTokenEncoded = encodeToken(catToken);
console.log("CAT token acquired, expires:", new Date(catToken.expiry * 1000).toISOString());
}
}
// Create and start bunker service
bunkerService = new BunkerService(
bunkerInfo.relay_url,
userPubkey,
userPrivkey
);
// Add the current secret
if (bunkerSecret) {
bunkerService.addAllowedSecret(bunkerSecret);
}
// Set CAT token if available
if (catToken) {
bunkerService.setCatToken(catToken);
}
// Set up callbacks
bunkerService.onClientConnected = (pubkey) => {
connectedClients = bunkerService.getConnectedClients();
};
bunkerService.onStatusChange = (status) => {
isServiceActive = status === 'connected';
if (status === 'disconnected') {
connectedClients = [];
}
};
// Connect to relay
await bunkerService.connect();
isServiceActive = true;
// Regenerate QR codes with CAT token
await generateQRCodes();
console.log("Bunker service started successfully");
} catch (err) {
console.error("Failed to start bunker service:", err);
error = err.message || "Failed to start bunker service";
bunkerService = null;
isServiceActive = false;
catToken = null;
catTokenEncoded = "";
} finally {
isStartingService = false;
}
}
// Stop the bunker service
function stopBunkerService() {
if (bunkerService) {
bunkerService.disconnect();
bunkerService = null;
}
isServiceActive = false;
connectedClients = [];
catToken = null;
catTokenEncoded = "";
// Regenerate QR codes without CAT token
generateQRCodes();
}
async function loadBunkerInfo() {
isLoading = true;
error = "";
@ -137,15 +256,69 @@ @@ -137,15 +256,69 @@
<div class="loading">Loading bunker information...</div>
{:else if bunkerInfo}
<div class="instructions">
<p><strong>How it works:</strong> Both your signing app (Amber) and your client app connect to this relay.
The relay acts as a secure middleman for NIP-46 remote signing.</p>
<p><strong>How it works:</strong> Start the bunker service to allow remote apps (like Smesh) to request signatures from your ORLY account.
Share the QR code or bunker URL with your client app.</p>
</div>
<!-- Service Control -->
<div class="service-control">
<div class="service-header">
<h4>Bunker Service</h4>
<div class="service-status" class:active={isServiceActive}>
<span class="status-dot"></span>
{isServiceActive ? 'Active' : 'Inactive'}
</div>
</div>
{#if !userPrivkey}
<div class="no-privkey-warning">
Bunker service requires nsec login. Please log in with your private key to enable remote signing.
</div>
{:else}
<div class="service-actions">
{#if isServiceActive}
<button class="stop-btn" on:click={stopBunkerService}>
Stop Service
</button>
{:else}
<button class="start-btn" on:click={startBunkerService} disabled={isStartingService}>
{isStartingService ? 'Starting...' : 'Start Service'}
</button>
{/if}
</div>
{#if isServiceActive && connectedClients.length > 0}
<div class="connected-clients">
<h5>Connected Clients ({connectedClients.length})</h5>
{#each connectedClients as client}
<div class="client-entry">
<code>{client.pubkey.substring(0, 16)}...</code>
<span class="client-time">Connected {new Date(client.connectedAt).toLocaleTimeString()}</span>
</div>
{/each}
</div>
{/if}
{#if catToken}
<div class="cat-info">
<span class="cat-badge">CAT Token Active</span>
<span class="cat-expiry">Expires: {new Date(catToken.expiry * 1000).toLocaleString()}</span>
</div>
{/if}
{/if}
</div>
<div class="qr-sections">
<!-- Client QR Code -->
<section class="qr-section">
<h4>For Client App</h4>
<p class="section-desc">Scan with your Nostr client to request signatures from Amber:</p>
<h4>Bunker URL for Client Apps</h4>
<p class="section-desc">
{#if isServiceActive}
Scan or copy this URL in your Nostr client (e.g., Smesh) to connect:
{:else}
Start the bunker service above to generate a connection URL.
{/if}
</p>
<div
class="qr-container clickable"
@ -171,34 +344,6 @@ @@ -171,34 +344,6 @@
<div class="copy-hint">Click QR code to copy</div>
</section>
<!-- Signer QR Code (Amber) -->
<section class="qr-section">
<h4>For Signer (Amber)</h4>
<p class="section-desc">Scan with <a href="https://github.com/greenart7c3/Amber" target="_blank" rel="noopener noreferrer">Amber</a> to connect as a signer:</p>
<div
class="qr-container clickable"
on:click={() => copyToClipboard(signerBunkerURL, "signer")}
on:keypress={(e) => e.key === 'Enter' && copyToClipboard(signerBunkerURL, "signer")}
role="button"
tabindex="0"
title="Click to copy connection URL"
>
{#if signerQrDataUrl}
<img src={signerQrDataUrl} alt="Signer Connection QR Code" class="qr-code" />
<div class="qr-overlay" class:visible={copiedItem === "signer"}>
Copied!
</div>
{:else}
<div class="qr-placeholder">Generating QR...</div>
{/if}
</div>
<div class="url-display">
<code class="bunker-url">{signerBunkerURL}</code>
</div>
<div class="copy-hint">Click QR code to copy</div>
</section>
</div>
<!-- Connection Info -->
@ -221,23 +366,6 @@ @@ -221,23 +366,6 @@
<button class="copy-btn" on:click={regenerateSecret}>Regenerate</button>
</div>
</div>
<!-- Amber links -->
<section class="amber-section">
<h4>Get Amber (NIP-46 Signer)</h4>
<p class="section-desc">Amber is an Android app for secure remote signing:</p>
<div class="client-links">
<a href="https://play.google.com/store/apps/details?id=com.greenart7c3.nostrsigner" target="_blank" rel="noopener noreferrer" class="client-link">
<span class="client-icon">Amber</span>
<span class="client-store">Google Play</span>
</a>
<a href="https://github.com/greenart7c3/Amber/releases" target="_blank" rel="noopener noreferrer" class="client-link">
<span class="client-icon">Amber</span>
<span class="client-store">GitHub APK</span>
</a>
</div>
</section>
{/if}
</div>
{:else if isLoggedIn}
@ -328,6 +456,152 @@ @@ -328,6 +456,152 @@
color: var(--text-color);
}
/* Service Control Styles */
.service-control {
background-color: var(--card-bg);
padding: 1.25em;
border-radius: 8px;
margin-bottom: 1.5em;
}
.service-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1em;
}
.service-header h4 {
margin: 0;
color: var(--text-color);
}
.service-status {
display: flex;
align-items: center;
gap: 0.5em;
font-size: 0.9em;
color: var(--text-color);
opacity: 0.7;
}
.service-status.active {
opacity: 1;
color: #4ade80;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #6b7280;
}
.service-status.active .status-dot {
background-color: #4ade80;
box-shadow: 0 0 8px rgba(74, 222, 128, 0.5);
}
.service-actions {
margin-bottom: 1em;
}
.start-btn, .stop-btn {
padding: 0.75em 1.5em;
border: none;
border-radius: 6px;
font-size: 1em;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.start-btn {
background-color: #4ade80;
color: #0a0a0a;
}
.start-btn:hover:not(:disabled) {
background-color: #22c55e;
}
.start-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.stop-btn {
background-color: #ef4444;
color: white;
}
.stop-btn:hover {
background-color: #dc2626;
}
.no-privkey-warning {
background-color: rgba(255, 193, 7, 0.15);
border: 1px solid rgba(255, 193, 7, 0.5);
color: var(--text-color);
padding: 0.75em 1em;
border-radius: 4px;
font-size: 0.95em;
}
.connected-clients {
margin-top: 1em;
padding-top: 1em;
border-top: 1px solid var(--border-color);
}
.connected-clients h5 {
margin: 0 0 0.5em 0;
color: var(--text-color);
font-size: 0.9em;
}
.client-entry {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5em;
background-color: var(--bg-color);
border-radius: 4px;
margin-bottom: 0.5em;
}
.client-entry code {
font-size: 0.85em;
}
.client-time {
font-size: 0.8em;
opacity: 0.7;
}
.cat-info {
display: flex;
align-items: center;
gap: 1em;
margin-top: 1em;
padding-top: 1em;
border-top: 1px solid var(--border-color);
}
.cat-badge {
background-color: rgba(74, 222, 128, 0.2);
color: #4ade80;
padding: 0.25em 0.75em;
border-radius: 4px;
font-size: 0.85em;
font-weight: 500;
}
.cat-expiry {
font-size: 0.85em;
opacity: 0.7;
}
.qr-sections {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
@ -353,10 +627,6 @@ @@ -353,10 +627,6 @@
font-size: 0.95em;
}
.section-desc a {
color: var(--primary);
}
.qr-container {
display: flex;
justify-content: center;
@ -497,52 +767,6 @@ @@ -497,52 +767,6 @@
background-color: var(--accent-hover-color);
}
.amber-section {
background-color: var(--card-bg);
padding: 1.25em;
border-radius: 8px;
}
.amber-section h4 {
margin: 0 0 0.5em 0;
color: var(--text-color);
}
.client-links {
display: flex;
flex-wrap: wrap;
gap: 0.75em;
}
.client-link {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75em 1em;
background-color: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 6px;
text-decoration: none;
color: var(--text-color);
transition: border-color 0.2s, background-color 0.2s;
min-width: 100px;
}
.client-link:hover {
border-color: var(--primary);
background-color: var(--sidebar-bg);
}
.client-icon {
font-weight: 500;
margin-bottom: 0.25em;
}
.client-store {
font-size: 0.8em;
opacity: 0.7;
}
.unavailable-message, .access-denied {
text-align: center;
padding: 2em;
@ -602,14 +826,6 @@ @@ -602,14 +826,6 @@
grid-template-columns: 1fr;
}
.client-links {
flex-direction: column;
}
.client-link {
width: 100%;
}
.bunker-url {
font-size: 0.65em;
}

508
app/web/src/bunker-service.js

@ -0,0 +1,508 @@ @@ -0,0 +1,508 @@
/**
* BunkerService - NIP-46 Remote Signer
*
* Implements the signer side of NIP-46 protocol.
* Listens for signing requests from remote clients and responds using
* the user's private key stored in ORLY.
*
* Protocol:
* - Kind 24133 events for request/response
* - NIP-04 encryption for payloads
* - Methods: connect, get_public_key, sign_event, nip04_encrypt, nip04_decrypt, ping
*/
import { nip04 } from 'nostr-tools';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
import { secp256k1 } from '@noble/curves/secp256k1';
import { encodeToken } from './cashu-client.js';
// NIP-46 methods
const NIP46_METHOD = {
CONNECT: 'connect',
GET_PUBLIC_KEY: 'get_public_key',
SIGN_EVENT: 'sign_event',
NIP04_ENCRYPT: 'nip04_encrypt',
NIP04_DECRYPT: 'nip04_decrypt',
PING: 'ping'
};
/**
* Generate a random hex string.
*/
function generateRandomHex(bytes = 16) {
const arr = new Uint8Array(bytes);
crypto.getRandomValues(arr);
return bytesToHex(arr);
}
/**
* BunkerService class - implements NIP-46 signer protocol.
*/
export class BunkerService {
/**
* @param {string} relayUrl - WebSocket URL of the relay
* @param {string} userPubkey - User's public key (hex)
* @param {Uint8Array} userPrivkey - User's private key (32 bytes)
*/
constructor(relayUrl, userPubkey, userPrivkey) {
this.relayUrl = relayUrl;
this.userPubkey = userPubkey;
this.userPrivkey = userPrivkey;
this.ws = null;
this.connected = false;
this.allowedSecrets = new Set();
this.connectedClients = new Map(); // pubkey -> { connectedAt, lastActivity }
this.requestLog = [];
this.heartbeatInterval = null;
this.subscriptionId = null;
this.catToken = null;
// Callbacks
this.onClientConnected = null;
this.onClientDisconnected = null;
this.onRequest = null;
this.onStatusChange = null;
}
/**
* Add an allowed connection secret.
*/
addAllowedSecret(secret) {
this.allowedSecrets.add(secret);
}
/**
* Remove an allowed secret.
*/
removeAllowedSecret(secret) {
this.allowedSecrets.delete(secret);
}
/**
* Set CAT token for WebSocket connection.
*/
setCatToken(token) {
this.catToken = token;
}
/**
* Connect to the relay and start listening for NIP-46 requests.
*/
async connect() {
return new Promise((resolve, reject) => {
// Build WebSocket URL
let wsUrl = this.relayUrl;
if (wsUrl.startsWith('http://')) {
wsUrl = 'ws://' + wsUrl.slice(7);
} else if (wsUrl.startsWith('https://')) {
wsUrl = 'wss://' + wsUrl.slice(8);
} else if (!wsUrl.startsWith('ws://') && !wsUrl.startsWith('wss://')) {
wsUrl = 'wss://' + wsUrl;
}
// Add CAT token if available
if (this.catToken) {
const tokenEncoded = encodeToken(this.catToken);
const url = new URL(wsUrl);
url.searchParams.set('token', tokenEncoded);
wsUrl = url.toString();
}
console.log('[BunkerService] Connecting to:', wsUrl.split('?')[0]);
const ws = new WebSocket(wsUrl);
const timeout = setTimeout(() => {
ws.close();
reject(new Error('Connection timeout'));
}, 10000);
ws.onopen = () => {
clearTimeout(timeout);
this.ws = ws;
this.connected = true;
console.log('[BunkerService] Connected to relay');
// Subscribe to NIP-46 events for our pubkey
this.subscriptionId = generateRandomHex(8);
const sub = JSON.stringify([
'REQ',
this.subscriptionId,
{
kinds: [24133],
'#p': [this.userPubkey],
since: Math.floor(Date.now() / 1000) - 60
}
]);
ws.send(sub);
console.log('[BunkerService] Subscribed to NIP-46 events');
// Start heartbeat
this.startHeartbeat();
if (this.onStatusChange) {
this.onStatusChange('connected');
}
resolve();
};
ws.onerror = (error) => {
clearTimeout(timeout);
console.error('[BunkerService] WebSocket error:', error);
reject(new Error('WebSocket error'));
};
ws.onclose = () => {
this.connected = false;
this.ws = null;
this.stopHeartbeat();
console.log('[BunkerService] Disconnected from relay');
if (this.onStatusChange) {
this.onStatusChange('disconnected');
}
};
ws.onmessage = (event) => {
this.handleMessage(event.data);
};
});
}
/**
* Start WebSocket heartbeat to keep connection alive.
*/
startHeartbeat(intervalMs = 30000) {
this.stopHeartbeat();
this.heartbeatInterval = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
// Send a ping via Nostr protocol (re-subscribe)
const sub = JSON.stringify([
'REQ',
this.subscriptionId,
{
kinds: [24133],
'#p': [this.userPubkey],
since: Math.floor(Date.now() / 1000) - 60
}
]);
this.ws.send(sub);
}
}, intervalMs);
}
/**
* Stop WebSocket heartbeat.
*/
stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
/**
* Disconnect from the relay.
*/
disconnect() {
this.stopHeartbeat();
if (this.ws) {
// Close subscription
if (this.subscriptionId) {
this.ws.send(JSON.stringify(['CLOSE', this.subscriptionId]));
}
this.ws.close();
this.ws = null;
}
this.connected = false;
this.connectedClients.clear();
}
/**
* Handle incoming WebSocket messages.
*/
async handleMessage(data) {
try {
const msg = JSON.parse(data);
if (!Array.isArray(msg)) return;
const [type, ...rest] = msg;
if (type === 'EVENT') {
const [, event] = rest;
if (event.kind === 24133) {
await this.handleNIP46Request(event);
}
} else if (type === 'OK') {
// Event published confirmation
console.log('[BunkerService] Event published:', rest[0]?.substring(0, 8));
} else if (type === 'NOTICE') {
console.warn('[BunkerService] Relay notice:', rest[0]);
}
} catch (err) {
console.error('[BunkerService] Failed to parse message:', err);
}
}
/**
* Handle NIP-46 request event.
*/
async handleNIP46Request(event) {
try {
// Decrypt the content with NIP-04
const privkeyHex = bytesToHex(this.userPrivkey);
const decrypted = await nip04.decrypt(privkeyHex, event.pubkey, event.content);
const request = JSON.parse(decrypted);
console.log('[BunkerService] Received request:', request.method, 'from:', event.pubkey.substring(0, 8));
// Log the request
this.requestLog.push({
id: request.id,
method: request.method,
from: event.pubkey,
timestamp: Date.now()
});
if (this.requestLog.length > 100) {
this.requestLog.shift();
}
if (this.onRequest) {
this.onRequest(request, event.pubkey);
}
// Handle the request
let result = null;
let error = null;
try {
switch (request.method) {
case NIP46_METHOD.CONNECT:
result = await this.handleConnect(request, event.pubkey);
break;
case NIP46_METHOD.GET_PUBLIC_KEY:
result = await this.handleGetPublicKey(request, event.pubkey);
break;
case NIP46_METHOD.SIGN_EVENT:
result = await this.handleSignEvent(request, event.pubkey);
break;
case NIP46_METHOD.NIP04_ENCRYPT:
result = await this.handleNip04Encrypt(request, event.pubkey);
break;
case NIP46_METHOD.NIP04_DECRYPT:
result = await this.handleNip04Decrypt(request, event.pubkey);
break;
case NIP46_METHOD.PING:
result = 'pong';
break;
default:
error = `Unknown method: ${request.method}`;
}
} catch (err) {
console.error('[BunkerService] Error handling request:', err);
error = err.message;
}
// Send response
await this.sendResponse(request.id, result, error, event.pubkey);
} catch (err) {
console.error('[BunkerService] Failed to handle NIP-46 request:', err);
}
}
/**
* Handle connect request.
*/
async handleConnect(request, senderPubkey) {
const [clientPubkey, secret] = request.params;
// Validate secret if required
if (this.allowedSecrets.size > 0) {
if (!secret || !this.allowedSecrets.has(secret)) {
throw new Error('Invalid or missing connection secret');
}
}
// Register connected client
this.connectedClients.set(senderPubkey, {
clientPubkey: clientPubkey || senderPubkey,
connectedAt: Date.now(),
lastActivity: Date.now()
});
console.log('[BunkerService] Client connected:', senderPubkey.substring(0, 8));
if (this.onClientConnected) {
this.onClientConnected(senderPubkey);
}
return 'ack';
}
/**
* Handle get_public_key request.
*/
async handleGetPublicKey(request, senderPubkey) {
// Update last activity
if (this.connectedClients.has(senderPubkey)) {
this.connectedClients.get(senderPubkey).lastActivity = Date.now();
}
return this.userPubkey;
}
/**
* Handle sign_event request.
*/
async handleSignEvent(request, senderPubkey) {
// Check if client is connected
if (!this.connectedClients.has(senderPubkey)) {
throw new Error('Not connected');
}
// Update last activity
this.connectedClients.get(senderPubkey).lastActivity = Date.now();
const [eventJson] = request.params;
const event = JSON.parse(eventJson);
// Ensure pubkey matches
if (event.pubkey && event.pubkey !== this.userPubkey) {
throw new Error('Event pubkey does not match signer pubkey');
}
// Set pubkey if not set
event.pubkey = this.userPubkey;
// Calculate event ID
const serialized = JSON.stringify([
0,
event.pubkey,
event.created_at,
event.kind,
event.tags,
event.content
]);
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(serialized));
event.id = bytesToHex(new Uint8Array(hash));
// Sign the event
const sig = secp256k1.sign(hexToBytes(event.id), this.userPrivkey);
event.sig = sig.toCompactHex();
console.log('[BunkerService] Signed event:', event.id.substring(0, 8), 'kind:', event.kind);
return JSON.stringify(event);
}
/**
* Handle nip04_encrypt request.
*/
async handleNip04Encrypt(request, senderPubkey) {
// Check if client is connected
if (!this.connectedClients.has(senderPubkey)) {
throw new Error('Not connected');
}
// Update last activity
this.connectedClients.get(senderPubkey).lastActivity = Date.now();
const [pubkey, plaintext] = request.params;
const privkeyHex = bytesToHex(this.userPrivkey);
const ciphertext = await nip04.encrypt(privkeyHex, pubkey, plaintext);
return ciphertext;
}
/**
* Handle nip04_decrypt request.
*/
async handleNip04Decrypt(request, senderPubkey) {
// Check if client is connected
if (!this.connectedClients.has(senderPubkey)) {
throw new Error('Not connected');
}
// Update last activity
this.connectedClients.get(senderPubkey).lastActivity = Date.now();
const [pubkey, ciphertext] = request.params;
const privkeyHex = bytesToHex(this.userPrivkey);
const plaintext = await nip04.decrypt(privkeyHex, pubkey, ciphertext);
return plaintext;
}
/**
* Send NIP-46 response to client.
*/
async sendResponse(requestId, result, error, recipientPubkey) {
if (!this.ws || !this.connected) {
console.error('[BunkerService] Cannot send response: not connected');
return;
}
const response = {
id: requestId,
result: result !== null ? result : undefined,
error: error !== null ? error : undefined
};
// Encrypt response with NIP-04
const privkeyHex = bytesToHex(this.userPrivkey);
const encrypted = await nip04.encrypt(privkeyHex, recipientPubkey, JSON.stringify(response));
// Create response event
const event = {
kind: 24133,
pubkey: this.userPubkey,
created_at: Math.floor(Date.now() / 1000),
content: encrypted,
tags: [['p', recipientPubkey]]
};
// Calculate event ID
const serialized = JSON.stringify([
0,
event.pubkey,
event.created_at,
event.kind,
event.tags,
event.content
]);
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(serialized));
event.id = bytesToHex(new Uint8Array(hash));
// Sign the event
const sig = secp256k1.sign(hexToBytes(event.id), this.userPrivkey);
event.sig = sig.toCompactHex();
// Send to relay
this.ws.send(JSON.stringify(['EVENT', event]));
console.log('[BunkerService] Sent response for:', requestId);
}
/**
* Check if the service is connected.
*/
isConnected() {
return this.connected;
}
/**
* Get list of connected clients.
*/
getConnectedClients() {
return Array.from(this.connectedClients.entries()).map(([pubkey, info]) => ({
pubkey,
...info
}));
}
/**
* Get request log.
*/
getRequestLog() {
return [...this.requestLog];
}
}

252
app/web/src/cashu-client.js

@ -0,0 +1,252 @@ @@ -0,0 +1,252 @@
/**
* Cashu Token Client
*
* Manages Cashu access tokens for bunker authentication.
* Handles token issuance using blind signature protocol.
*
* Token flow:
* 1. Generate random secret and blinding factor
* 2. Compute blinded message B_ = hash_to_curve(secret) + r*G
* 3. Submit B_ to mint with NIP-98 auth
* 4. Receive blinded signature C_
* 5. Unblind: C = C_ - r*K (where K is mint's pubkey)
* 6. Encode token for transmission
*/
import { secp256k1 } from '@noble/curves/secp256k1';
import { sha256 } from '@noble/hashes/sha256';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
// Token scopes matching ORLY's token.Scope
export const TokenScope = {
RELAY: 'relay',
NIP46: 'nip46',
BLOSSOM: 'blossom',
API: 'api'
};
/**
* Convert bytes to big-endian number.
*/
function bytesToNumberBE(bytes) {
let n = 0n;
for (const b of bytes) {
n = (n << 8n) | BigInt(b);
}
return n;
}
/**
* Hash a message to a point on secp256k1 using try-and-increment.
* Matches ORLY's Go implementation exactly.
*/
function hashToCurve(message) {
const domainSeparator = new TextEncoder().encode('Secp256k1_HashToCurve_Cashu_');
const msgHash = sha256(new Uint8Array([...domainSeparator, ...message]));
// Try incrementing counter until we get a valid point
for (let counter = 0; counter < 65536; counter++) {
// 4-byte little-endian counter
const counterBytes = new Uint8Array(4);
new DataView(counterBytes.buffer).setUint32(0, counter, true);
const toHash = new Uint8Array([...msgHash, ...counterBytes]);
const hash = sha256(toHash);
// Try 0x02 prefix (even Y coordinate)
const compressed = new Uint8Array([0x02, ...hash]);
try {
const point = secp256k1.ProjectivePoint.fromHex(compressed);
if (!point.equals(secp256k1.ProjectivePoint.ZERO)) {
return compressed;
}
} catch {
// Not a valid point, continue
}
}
throw new Error('Failed to hash to curve after 65536 attempts');
}
/**
* Create a blinded message from a secret.
* B_ = Y + r*G where Y = hash_to_curve(secret)
*/
function blind(secret) {
// Generate random blinding factor r
const r = secp256k1.utils.randomPrivateKey();
// Y = hash_to_curve(secret)
const Y = secp256k1.ProjectivePoint.fromHex(hashToCurve(secret));
// r*G
const rG = secp256k1.ProjectivePoint.BASE.multiply(bytesToNumberBE(r));
// B_ = Y + r*G
const B_ = Y.add(rG);
return {
B_: B_.toRawBytes(true), // Compressed format
secret,
r
};
}
/**
* Unblind the signature to get the final signature.
* C = C_ - r*K where K is the mint's public key
*/
function unblind(C_, r, K) {
const C_point = secp256k1.ProjectivePoint.fromHex(C_);
const K_point = secp256k1.ProjectivePoint.fromHex(K);
// r*K
const rK = K_point.multiply(bytesToNumberBE(r));
// C = C_ - r*K
const C = C_point.subtract(rK);
return C.toRawBytes(true);
}
/**
* Encode a token to the Cashu format (cashuA prefix + base64url).
*/
export function encodeToken(token) {
const tokenData = {
k: token.keysetId,
s: bytesToHex(token.secret),
c: bytesToHex(token.signature),
p: bytesToHex(token.pubkey),
e: token.expiry,
sc: token.scope,
kinds: token.kinds,
kind_ranges: token.kindRanges
};
const json = JSON.stringify(tokenData);
// Use base64url encoding
const base64 = btoa(json)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return 'cashuA' + base64;
}
/**
* Decode a token from the Cashu format.
*/
export function decodeToken(encoded) {
if (!encoded.startsWith('cashuA')) {
throw new Error('Invalid token prefix, expected cashuA');
}
const base64url = encoded.slice(6);
// Convert base64url to base64
let base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
// Add padding if needed
while (base64.length % 4 !== 0) {
base64 += '=';
}
const json = atob(base64);
const data = JSON.parse(json);
return {
keysetId: data.k,
secret: hexToBytes(data.s),
signature: hexToBytes(data.c),
pubkey: hexToBytes(data.p),
expiry: data.e,
scope: data.sc,
kinds: data.kinds,
kindRanges: data.kind_ranges
};
}
/**
* Request a new token from the mint.
* @param {string} mintUrl - The mint URL (e.g., https://relay.example.com)
* @param {string} scope - Token scope (relay, nip46, blossom, api)
* @param {Uint8Array} userPubkey - User's public key (32 bytes)
* @param {Function} signHttpAuth - Function to create NIP-98 auth header
* @param {number[]} [kinds] - Permitted event kinds
* @param {[number, number][]} [kindRanges] - Permitted kind ranges
* @returns {Promise<Object>} - The token object
*/
export async function requestToken(mintUrl, scope, userPubkey, signHttpAuth, kinds, kindRanges) {
// Generate secret and blind it
const secret = crypto.getRandomValues(new Uint8Array(32));
const blindResult = blind(secret);
// Create request
const requestBody = {
blinded_message: bytesToHex(blindResult.B_),
scope,
kinds,
kind_ranges: kindRanges
};
// Get NIP-98 auth header
const authUrl = `${mintUrl}/cashu/mint`;
const authHeader = await signHttpAuth(authUrl, 'POST');
// Submit to mint
const response = await fetch(authUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': authHeader
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Mint request failed: ${error}`);
}
const result = await response.json();
// Unblind the signature
const C_ = hexToBytes(result.blinded_signature);
const K = hexToBytes(result.mint_pubkey);
const signature = unblind(C_, blindResult.r, K);
return {
keysetId: result.keyset_id,
secret: blindResult.secret,
signature,
pubkey: userPubkey,
expiry: result.expiry,
scope,
kinds,
kindRanges
};
}
/**
* Check if relay requires CAT and fetch mint info.
* @param {string} relayUrl - The relay URL
* @returns {Promise<Object|null>} - Mint info or null if CAT not required
*/
export async function getMintInfo(relayUrl) {
// Convert to HTTP URL
let mintUrl = relayUrl
.replace('wss://', 'https://')
.replace('ws://', 'http://')
.replace(/\/$/, '');
try {
const response = await fetch(`${mintUrl}/cashu/info`);
if (!response.ok) {
return null;
}
const info = await response.json();
info.mintUrl = mintUrl;
return info;
} catch {
return null;
}
}

2
pkg/version/version

@ -1 +1 @@ @@ -1 +1 @@
v0.43.1
v0.44.0

Loading…
Cancel
Save