Compare commits

...

6 Commits

Author SHA1 Message Date
woikos
e6fa2f15e4 Add persistent keyset storage for Cashu tokens (v0.44.4)
Some checks failed
Go / build-and-release (push) Has been cancelled
- Add FileStore implementation for keyset persistence
- Keysets now survive server restarts
- Store keysets in JSON file at $ORLY_DATA_DIR/cashu-keysets.json
- Tokens issued before restart remain valid

Files modified:
- pkg/cashu/keyset/file_store.go: New file-based keyset store
- app/main.go: Use FileStore instead of MemoryStore

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 15:37:16 +01:00
woikos
e28ab948b0 Add multi-token support for bunker client connections (v0.44.3)
Some checks failed
Go / build-and-release (push) Has been cancelled
- Each client device now gets its own CAT token
- Tokens can be individually named (editable, defaults to cute names like "jolly-jellyfish")
- Tokens can be individually revoked
- Expandable table rows show QR code and full bunker URL per token
- Separate service token for ORLY's own relay connection
- Add Token button to create additional client tokens

Files modified:
- app/web/src/BunkerView.svelte: Token list UI with expandable details

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 15:02:09 +01:00
woikos
3f34eb288d Update nostr lib v1.0.12 with TLS URL scheme fix for NIP-98 2025-12-29 14:33:12 +01:00
woikos
8424f0ca44 Add debugging for NIP-98 auth in cashu mint 2025-12-29 14:17:50 +01:00
woikos
48c6739d25 Enable Cashu access tokens automatically when ACL is active (v0.44.2)
Some checks failed
Go / build-and-release (push) Has been cancelled
- Add automatic Cashu issuer/verifier initialization when ACL mode is not 'none'
- Use memory store for keyset management with proper TTL configuration
- Import cashuiface package for AllowAllChecker implementation
- ACL handles authorization; CAT provides token-based authentication

Files modified:
- app/main.go: Add Cashu system initialization when ACL active
- pkg/version/version: Bump to v0.44.2

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 14:01:54 +01:00
woikos
b837dcb5f0 Fix UTF-8 encoding error in compact event tag marshaling (v0.44.1)
Some checks failed
Go / build-and-release (push) Has been cancelled
- Fix binary pubkey/event ID values not being detected by tag.Marshal
- Compact event decoder now returns 33-byte values with null terminator
- This allows tag.Marshal to detect and hex-encode binary values correctly
- Fixes "Could not decode a text frame as UTF-8" WebSocket errors

Files modified:
- pkg/database/compact_event.go: Return 33-byte binary with null terminator

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 13:39:49 +01:00
12 changed files with 746 additions and 95 deletions

View File

@@ -6,7 +6,6 @@ import (
"net/http"
"time"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"git.mleku.dev/mleku/nostr/httpauth"
@@ -35,13 +34,24 @@ type CashuMintResponse struct {
func (s *Server) handleCashuMint(w http.ResponseWriter, r *http.Request) {
// Check if Cashu is enabled
if s.CashuIssuer == nil {
log.W.F("Cashu mint request but issuer not initialized")
http.Error(w, "Cashu tokens not enabled", http.StatusNotImplemented)
return
}
// Require NIP-98 authentication
valid, pubkey, err := httpauth.CheckAuth(r)
if chk.E(err) || !valid {
if err != nil {
authHeader := r.Header.Get("Authorization")
if len(authHeader) > 100 {
authHeader = authHeader[:100] + "..."
}
log.W.F("Cashu mint NIP-98 auth error: %v (valid=%v, authHeader=%q)", err, valid, authHeader)
http.Error(w, "NIP-98 authentication required", http.StatusUnauthorized)
return
}
if !valid {
log.W.F("Cashu mint NIP-98 auth invalid signature")
http.Error(w, "NIP-98 authentication required", http.StatusUnauthorized)
return
}

View File

@@ -23,6 +23,10 @@ import (
"next.orly.dev/pkg/protocol/nip43"
"next.orly.dev/pkg/protocol/publish"
"next.orly.dev/pkg/bunker"
"next.orly.dev/pkg/cashu/issuer"
"next.orly.dev/pkg/cashu/keyset"
"next.orly.dev/pkg/cashu/verifier"
cashuiface "next.orly.dev/pkg/interfaces/cashu"
"next.orly.dev/pkg/ratelimit"
"next.orly.dev/pkg/spider"
dsync "next.orly.dev/pkg/sync"
@@ -162,6 +166,32 @@ func Run(
}
}
// Initialize Cashu access token system when ACL is active
if cfg.ACLMode != "none" {
// Create keyset manager with file-based store (keysets persist across restarts)
keysetPath := filepath.Join(cfg.DataDir, "cashu-keysets.json")
keysetStore, err := keyset.NewFileStore(keysetPath)
if err != nil {
log.E.F("failed to create Cashu keyset store at %s: %v", keysetPath, err)
} else {
keysetManager := keyset.NewManager(keysetStore, keyset.DefaultActiveWindow, keyset.DefaultVerifyWindow)
// Initialize keyset manager (loads existing keysets or creates new one)
if err := keysetManager.Init(); err != nil {
log.E.F("failed to initialize Cashu keyset manager: %v", err)
} else {
// Create issuer with permissive checker (ACL handles authorization)
issuerCfg := issuer.DefaultConfig()
l.CashuIssuer = issuer.New(keysetManager, cashuiface.AllowAllChecker{}, issuerCfg)
// Create verifier for validating tokens
l.CashuVerifier = verifier.New(keysetManager, cashuiface.AllowAllChecker{}, verifier.DefaultConfig())
log.I.F("Cashu access token system enabled (ACL mode: %s, keysets: %s)", cfg.ACLMode, keysetPath)
}
}
}
// Initialize spider manager based on mode (only for Badger backend)
if badgerDB, ok := db.(*database.D); ok && cfg.SpiderMode != "none" {
if l.spiderManager, err = spider.New(ctx, badgerDB, l.publishers, cfg.SpiderMode); chk.E(err) {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -28,8 +28,110 @@
let isServiceActive = false;
let isStartingService = false;
let connectedClients = [];
let catToken = null;
let catTokenEncoded = "";
let serviceCatToken = null; // Token for ORLY's own relay connection
// Client tokens list - each device gets its own token
let clientTokens = []; // [{id, name, token, encoded, createdAt, isEditing}]
let selectedTokenId = null; // Currently selected token for the QR code
// Two-word name generator
const adjectives = ["brave", "calm", "clever", "cosmic", "cozy", "daring", "eager", "fancy", "gentle", "happy", "jolly", "keen", "lively", "merry", "nimble", "peppy", "quick", "rustic", "shiny", "swift", "tender", "vivid", "witty", "zesty"];
const nouns = ["badger", "bunny", "coral", "dolphin", "falcon", "gecko", "heron", "iguana", "jaguar", "koala", "lemur", "mango", "narwhal", "otter", "panda", "quail", "rabbit", "salmon", "turtle", "urchin", "viper", "walrus", "yak", "zebra"];
function generateTokenName() {
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
return `${adj}-${noun}`;
}
function generateTokenId() {
return crypto.randomUUID().split('-')[0];
}
// Add a new client token
async function addClientToken(mintInfo, signHttpAuth) {
const token = await requestToken(
mintInfo.mintUrl,
TokenScope.NIP46,
hexToBytes(userPubkey),
signHttpAuth,
[24133]
);
const encoded = encodeToken(token);
const id = generateTokenId();
const newToken = {
id,
name: generateTokenName(),
token,
encoded,
createdAt: Date.now(),
isExpanded: false
};
clientTokens = [...clientTokens, newToken];
// Select the new token if none selected
if (!selectedTokenId) {
selectedTokenId = id;
}
console.log(`Client token "${newToken.name}" created, expires:`, new Date(token.expiry * 1000).toISOString());
return newToken;
}
// Add a new token (called from UI)
async function handleAddToken() {
if (!bunkerInfo?.cashu_enabled) return;
try {
const mintInfo = await getMintInfo(bunkerInfo.relay_url);
if (!mintInfo) return;
const signHttpAuth = async (url, method) => {
const header = await createNIP98Auth(userSigner, userPubkey, method, url);
return `Nostr ${header}`;
};
await addClientToken(mintInfo, signHttpAuth);
// Regenerate QR for newly selected token
await generateQRCodes();
} catch (err) {
console.error("Failed to add token:", err);
error = err.message || "Failed to add token";
}
}
// Revoke/remove a client token
function revokeToken(tokenId) {
clientTokens = clientTokens.filter(t => t.id !== tokenId);
// If we removed the selected token, select another
if (selectedTokenId === tokenId) {
selectedTokenId = clientTokens.length > 0 ? clientTokens[0].id : null;
}
generateQRCodes();
}
// Toggle token details expansion
function toggleTokenExpand(tokenId) {
clientTokens = clientTokens.map(t =>
t.id === tokenId ? { ...t, isExpanded: !t.isExpanded } : t
);
}
// Update token name
function updateTokenName(tokenId, newName) {
clientTokens = clientTokens.map(t =>
t.id === tokenId ? { ...t, name: newName } : t
);
}
// Generate QR code for a specific token
async function generateTokenQR(token) {
if (!bunkerInfo || !userPubkey) return null;
const url = `bunker://${userPubkey}?relay=${encodeURIComponent(bunkerInfo.relay_url)}${bunkerSecret ? `&secret=${bunkerSecret}` : ''}&cat=${token.encoded}`;
return await QRCode.toDataURL(url, {
width: 200,
margin: 2,
color: { dark: "#000000", light: "#ffffff" }
});
}
$: canAccess = isLoggedIn && userPubkey && (
currentEffectiveRole === "write" ||
@@ -38,8 +140,10 @@
);
// Generate bunker URLs when bunkerInfo and userPubkey are available
$: clientBunkerURL = bunkerInfo && userPubkey ?
`bunker://${userPubkey}?relay=${encodeURIComponent(bunkerInfo.relay_url)}${bunkerSecret ? `&secret=${bunkerSecret}` : ''}${catTokenEncoded ? `&cat=${catTokenEncoded}` : ''}` : "";
// Get selected token for the bunker URL
$: selectedToken = clientTokens.find(t => t.id === selectedTokenId);
$: clientBunkerURL = bunkerInfo && userPubkey && selectedToken ?
`bunker://${userPubkey}?relay=${encodeURIComponent(bunkerInfo.relay_url)}${bunkerSecret ? `&secret=${bunkerSecret}` : ''}&cat=${selectedToken.encoded}` : "";
$: signerBunkerURL = bunkerInfo ?
`nostr+connect://${bunkerInfo.relay_url}` : "";
@@ -68,9 +172,9 @@
error = "";
try {
// Check if CAT is required and mint one
// Check if CAT is required and mint tokens
if (bunkerInfo.cashu_enabled) {
console.log("CAT required, minting token...");
console.log("CAT required, minting tokens...");
const mintInfo = await getMintInfo(bunkerInfo.relay_url);
if (mintInfo) {
// Create NIP-98 auth function
@@ -79,16 +183,18 @@
return `Nostr ${header}`;
};
// Request NIP-46 scoped token
catToken = await requestToken(
// 1. Token for ORLY's BunkerService relay connection
serviceCatToken = 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());
console.log("Service CAT token acquired, expires:", new Date(serviceCatToken.expiry * 1000).toISOString());
// 2. Create first client token
await addClientToken(mintInfo, signHttpAuth);
}
}
@@ -104,9 +210,9 @@
bunkerService.addAllowedSecret(bunkerSecret);
}
// Set CAT token if available
if (catToken) {
bunkerService.setCatToken(catToken);
// Set CAT token for service connection
if (serviceCatToken) {
bunkerService.setCatToken(serviceCatToken);
}
// Set up callbacks
@@ -149,8 +255,9 @@
}
isServiceActive = false;
connectedClients = [];
catToken = null;
catTokenEncoded = "";
serviceCatToken = null;
clientTokens = [];
selectedTokenId = null;
// Regenerate QR codes without CAT token
generateQRCodes();
}
@@ -299,52 +406,99 @@
</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>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>
<!-- Client Tokens Table -->
{#if isServiceActive && clientTokens.length > 0}
<div class="tokens-section">
<div class="tokens-header">
<h4>Client Tokens</h4>
<button class="add-token-btn" on:click={handleAddToken}>+ Add Token</button>
</div>
<p class="tokens-desc">Each device/app gets its own token. Tokens can be individually revoked.</p>
<div
class="qr-container clickable"
on:click={() => copyToClipboard(clientBunkerURL, "client")}
on:keypress={(e) => e.key === 'Enter' && copyToClipboard(clientBunkerURL, "client")}
role="button"
tabindex="0"
title="Click to copy bunker URL"
>
{#if clientQrDataUrl}
<img src={clientQrDataUrl} alt="Client Bunker QR Code" class="qr-code" />
<div class="qr-overlay" class:visible={copiedItem === "client"}>
Copied!
<div class="tokens-table">
{#each clientTokens as tokenEntry (tokenEntry.id)}
<div class="token-row" class:expanded={tokenEntry.isExpanded}>
<div class="token-main" on:click={() => toggleTokenExpand(tokenEntry.id)} on:keypress={(e) => e.key === 'Enter' && toggleTokenExpand(tokenEntry.id)} role="button" tabindex="0">
<span class="expand-icon">{tokenEntry.isExpanded ? '▼' : '▶'}</span>
<input
type="text"
class="token-name-input"
value={tokenEntry.name}
on:input={(e) => updateTokenName(tokenEntry.id, e.target.value)}
on:click|stopPropagation
placeholder="Token name"
/>
<span class="token-created">
{new Date(tokenEntry.createdAt).toLocaleDateString()}
</span>
<span class="token-expiry">
Expires: {new Date(tokenEntry.token.expiry * 1000).toLocaleDateString()}
</span>
<button
class="revoke-btn"
on:click|stopPropagation={() => revokeToken(tokenEntry.id)}
title="Revoke this token"
>
Revoke
</button>
</div>
{#if tokenEntry.isExpanded}
<div class="token-details">
{#await generateTokenQR(tokenEntry)}
<div class="qr-placeholder small">Loading QR...</div>
{:then qrDataUrl}
<div class="token-detail-content">
<div
class="qr-container small clickable"
on:click={() => {
const url = `bunker://${userPubkey}?relay=${encodeURIComponent(bunkerInfo.relay_url)}${bunkerSecret ? `&secret=${bunkerSecret}` : ''}&cat=${tokenEntry.encoded}`;
copyToClipboard(url, tokenEntry.id);
}}
on:keypress={(e) => {
if (e.key === 'Enter') {
const url = `bunker://${userPubkey}?relay=${encodeURIComponent(bunkerInfo.relay_url)}${bunkerSecret ? `&secret=${bunkerSecret}` : ''}&cat=${tokenEntry.encoded}`;
copyToClipboard(url, tokenEntry.id);
}
}}
role="button"
tabindex="0"
title="Click to copy bunker URL"
>
<img src={qrDataUrl} alt="Token QR Code" class="qr-code small" />
<div class="qr-overlay" class:visible={copiedItem === tokenEntry.id}>
Copied!
</div>
</div>
<div class="token-info">
<div class="info-item">
<span class="label">Created:</span>
<span>{new Date(tokenEntry.createdAt).toLocaleString()}</span>
</div>
<div class="info-item">
<span class="label">Expires:</span>
<span>{new Date(tokenEntry.token.expiry * 1000).toLocaleString()}</span>
</div>
<div class="info-item url-item">
<span class="label">Bunker URL:</span>
<code class="bunker-url small">{`bunker://${userPubkey}?relay=${encodeURIComponent(bunkerInfo.relay_url)}${bunkerSecret ? `&secret=${bunkerSecret}` : ''}&cat=${tokenEntry.encoded}`}</code>
</div>
<div class="copy-hint">Click QR code to copy URL</div>
</div>
</div>
{:catch}
<div class="error-message">Failed to generate QR</div>
{/await}
</div>
{/if}
</div>
{:else}
<div class="qr-placeholder">Generating QR...</div>
{/if}
{/each}
</div>
<div class="url-display">
<code class="bunker-url">{clientBunkerURL}</code>
</div>
<div class="copy-hint">Click QR code to copy</div>
</section>
</div>
</div>
{/if}
<!-- Connection Info -->
<div class="connection-info">
@@ -821,6 +975,187 @@
background-color: var(--accent-hover-color);
}
/* Token table styles */
.tokens-section {
background-color: var(--card-bg);
padding: 1.25em;
border-radius: 8px;
margin-bottom: 1.5em;
}
.tokens-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5em;
}
.tokens-header h4 {
margin: 0;
color: var(--text-color);
}
.tokens-desc {
margin: 0 0 1em 0;
font-size: 0.9em;
color: var(--text-color);
opacity: 0.7;
}
.add-token-btn {
background-color: var(--primary);
color: var(--text-color);
border: none;
padding: 0.4em 0.8em;
border-radius: 4px;
cursor: pointer;
font-size: 0.85em;
}
.add-token-btn:hover {
background-color: var(--accent-hover-color);
}
.tokens-table {
display: flex;
flex-direction: column;
gap: 0.5em;
}
.token-row {
background-color: var(--bg-color);
border-radius: 6px;
overflow: hidden;
}
.token-row.expanded {
border: 1px solid var(--border-color);
}
.token-main {
display: flex;
align-items: center;
gap: 0.75em;
padding: 0.75em;
cursor: pointer;
transition: background-color 0.15s;
}
.token-main:hover {
background-color: var(--card-bg);
}
.expand-icon {
font-size: 0.7em;
color: var(--text-color);
opacity: 0.6;
width: 1em;
}
.token-name-input {
flex: 1;
min-width: 100px;
max-width: 180px;
background-color: transparent;
border: 1px solid transparent;
border-radius: 4px;
padding: 0.3em 0.5em;
font-size: 0.95em;
font-weight: 500;
color: var(--text-color);
}
.token-name-input:hover {
border-color: var(--border-color);
}
.token-name-input:focus {
outline: none;
border-color: var(--primary);
background-color: var(--card-bg);
}
.token-created, .token-expiry {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.7;
}
.token-expiry {
margin-left: auto;
}
.revoke-btn {
background-color: #ef4444;
color: white;
border: none;
padding: 0.3em 0.6em;
border-radius: 4px;
font-size: 0.8em;
cursor: pointer;
}
.revoke-btn:hover {
background-color: #dc2626;
}
.token-details {
padding: 1em;
border-top: 1px solid var(--border-color);
background-color: var(--card-bg);
}
.token-detail-content {
display: flex;
gap: 1.5em;
align-items: flex-start;
}
.qr-container.small {
flex-shrink: 0;
}
.qr-code.small {
width: 150px;
height: 150px;
}
.qr-placeholder.small {
width: 150px;
height: 150px;
font-size: 0.85em;
}
.token-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5em;
}
.info-item {
display: flex;
gap: 0.5em;
font-size: 0.9em;
}
.info-item .label {
color: var(--text-color);
opacity: 0.7;
min-width: 70px;
}
.info-item.url-item {
flex-direction: column;
gap: 0.25em;
}
.bunker-url.small {
font-size: 0.7em;
padding: 0.5em;
word-break: break-all;
}
@media (max-width: 600px) {
.qr-sections {
grid-template-columns: 1fr;
@@ -834,5 +1169,42 @@
flex-direction: column;
align-items: flex-start;
}
.token-main {
flex-wrap: wrap;
gap: 0.5em;
}
.token-name-input {
order: 1;
flex: 1 1 100%;
max-width: none;
}
.expand-icon {
order: 0;
}
.token-created {
order: 2;
}
.token-expiry {
order: 3;
margin-left: 0;
}
.revoke-btn {
order: 4;
}
.token-detail-content {
flex-direction: column;
align-items: center;
}
.token-info {
width: 100%;
}
}
</style>

View File

@@ -12,7 +12,7 @@
*/
export async function createNIP98Auth(signer, pubkey, method, url) {
if (!signer || !pubkey) {
console.log("No signer or pubkey available");
console.log("createNIP98Auth: No signer or pubkey available", { hasSigner: !!signer, hasPubkey: !!pubkey });
return null;
}
@@ -23,17 +23,30 @@ export async function createNIP98Auth(signer, pubkey, method, url) {
created_at: Math.floor(Date.now() / 1000),
tags: [
["u", url],
["method", method],
["method", method.toUpperCase()],
],
content: "",
};
console.log("createNIP98Auth: Signing event for", method, url);
// Sign using the signer
const signedEvent = await signer.signEvent(authEvent);
console.log("createNIP98Auth: Signed event:", {
id: signedEvent.id,
pubkey: signedEvent.pubkey,
kind: signedEvent.kind,
created_at: signedEvent.created_at,
tags: signedEvent.tags,
hasSig: !!signedEvent.sig
});
// Use URL-safe base64 encoding (replace + with -, / with _)
return btoa(JSON.stringify(signedEvent)).replace(/\+/g, '-').replace(/\//g, '_');
const json = JSON.stringify(signedEvent);
const base64 = btoa(json).replace(/\+/g, '-').replace(/\//g, '_');
return base64;
} catch (error) {
console.error("Error creating NIP-98 auth:", error);
console.error("createNIP98Auth: Error:", error);
return null;
}
}

2
go.mod
View File

@@ -3,7 +3,7 @@ module next.orly.dev
go 1.25.3
require (
git.mleku.dev/mleku/nostr v1.0.11
git.mleku.dev/mleku/nostr v1.0.12
github.com/adrg/xdg v0.5.3
github.com/alexflint/go-arg v1.6.1
github.com/aperturerobotics/go-indexeddb v0.2.3

4
go.sum
View File

@@ -1,5 +1,5 @@
git.mleku.dev/mleku/nostr v1.0.11 h1:xQ+rKPzTblerX/kRLDimOsH3rQK7/n9wYdG4DBKGcsg=
git.mleku.dev/mleku/nostr v1.0.11/go.mod h1:kJwSMmLRnAJ7QJtgXDv2wGgceFU0luwVqrgAL3MI93M=
git.mleku.dev/mleku/nostr v1.0.12 h1:bjsFUh1Q3fGpU7qsqxggGgrGGUt2OBdu1w8hjDM4gJE=
git.mleku.dev/mleku/nostr v1.0.12/go.mod h1:kJwSMmLRnAJ7QJtgXDv2wGgceFU0luwVqrgAL3MI93M=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg=

View File

@@ -0,0 +1,218 @@
package keyset
import (
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
)
// keysetData is the JSON-serializable form of a Keyset.
type keysetData struct {
ID string `json:"id"`
PrivateKey string `json:"private_key"` // hex-encoded
CreatedAt int64 `json:"created_at"`
ActiveAt int64 `json:"active_at"`
ExpiresAt int64 `json:"expires_at"`
VerifyEnd int64 `json:"verify_end"`
Active bool `json:"active"`
}
// fileStoreData is the top-level JSON structure.
type fileStoreData struct {
Version int `json:"version"`
Keysets []keysetData `json:"keysets"`
UpdatedAt int64 `json:"updated_at"`
}
// FileStore persists keysets to a JSON file.
type FileStore struct {
path string
mu sync.RWMutex
keysets map[string]*Keyset
}
// NewFileStore creates a new file-based keyset store.
// The directory will be created if it doesn't exist.
func NewFileStore(path string) (*FileStore, error) {
// Ensure directory exists
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, fmt.Errorf("keyset: failed to create directory: %w", err)
}
store := &FileStore{
path: path,
keysets: make(map[string]*Keyset),
}
// Load existing keysets if file exists
if err := store.load(); err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("keyset: failed to load keysets: %w", err)
}
return store, nil
}
// load reads keysets from the file.
func (s *FileStore) load() error {
data, err := os.ReadFile(s.path)
if err != nil {
return err
}
var fileData fileStoreData
if err := json.Unmarshal(data, &fileData); err != nil {
return fmt.Errorf("keyset: failed to parse file: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
for _, kd := range fileData.Keysets {
keyset, err := s.fromData(kd)
if err != nil {
// Log but continue - don't fail on single corrupt keyset
continue
}
s.keysets[keyset.ID] = keyset
}
return nil
}
// save writes all keysets to the file.
func (s *FileStore) save() error {
s.mu.RLock()
defer s.mu.RUnlock()
keysets := make([]keysetData, 0, len(s.keysets))
for _, k := range s.keysets {
keysets = append(keysets, s.toData(k))
}
fileData := fileStoreData{
Version: 1,
Keysets: keysets,
UpdatedAt: time.Now().Unix(),
}
data, err := json.MarshalIndent(fileData, "", " ")
if err != nil {
return fmt.Errorf("keyset: failed to marshal: %w", err)
}
// Write atomically using temp file
tmpPath := s.path + ".tmp"
if err := os.WriteFile(tmpPath, data, 0600); err != nil {
return fmt.Errorf("keyset: failed to write temp file: %w", err)
}
if err := os.Rename(tmpPath, s.path); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("keyset: failed to rename temp file: %w", err)
}
return nil
}
// toData converts a Keyset to its JSON form.
func (s *FileStore) toData(k *Keyset) keysetData {
return keysetData{
ID: k.ID,
PrivateKey: hex.EncodeToString(k.SerializePrivateKey()),
CreatedAt: k.CreatedAt.Unix(),
ActiveAt: k.ActiveAt.Unix(),
ExpiresAt: k.ExpiresAt.Unix(),
VerifyEnd: k.VerifyEnd.Unix(),
Active: k.Active,
}
}
// fromData reconstructs a Keyset from its JSON form.
func (s *FileStore) fromData(kd keysetData) (*Keyset, error) {
privKeyBytes, err := hex.DecodeString(kd.PrivateKey)
if err != nil {
return nil, fmt.Errorf("keyset: invalid private key hex: %w", err)
}
if len(privKeyBytes) != 32 {
return nil, fmt.Errorf("keyset: private key must be 32 bytes")
}
privKey := secp256k1.PrivKeyFromBytes(privKeyBytes)
pubKey := privKey.PubKey()
return &Keyset{
ID: kd.ID,
PrivateKey: privKey,
PublicKey: pubKey,
CreatedAt: time.Unix(kd.CreatedAt, 0),
ActiveAt: time.Unix(kd.ActiveAt, 0),
ExpiresAt: time.Unix(kd.ExpiresAt, 0),
VerifyEnd: time.Unix(kd.VerifyEnd, 0),
Active: kd.Active,
}, nil
}
// SaveKeyset persists a keyset.
func (s *FileStore) SaveKeyset(k *Keyset) error {
s.mu.Lock()
s.keysets[k.ID] = k
s.mu.Unlock()
return s.save()
}
// LoadKeyset loads a keyset by ID.
func (s *FileStore) LoadKeyset(id string) (*Keyset, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if k, ok := s.keysets[id]; ok {
return k, nil
}
return nil, nil
}
// ListActiveKeysets returns all keysets that can be used for signing.
func (s *FileStore) ListActiveKeysets() ([]*Keyset, error) {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]*Keyset, 0)
for _, k := range s.keysets {
if k.IsActiveForSigning() {
result = append(result, k)
}
}
return result, nil
}
// ListVerificationKeysets returns all keysets that can be used for verification.
func (s *FileStore) ListVerificationKeysets() ([]*Keyset, error) {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]*Keyset, 0)
for _, k := range s.keysets {
if k.IsValidForVerification() {
result = append(result, k)
}
}
return result, nil
}
// DeleteKeyset removes a keyset from storage.
func (s *FileStore) DeleteKeyset(id string) error {
s.mu.Lock()
delete(s.keysets, id)
s.mu.Unlock()
return s.save()
}

View File

@@ -355,7 +355,7 @@ func decodeTagElement(r io.Reader, resolver SerialResolver) (elem []byte, err er
return elem, nil
case TagElementPubkeySerial:
// Pubkey serial: 5 bytes -> lookup full pubkey -> return as 32-byte binary
// Pubkey serial: 5 bytes -> lookup full pubkey -> return as 33-byte binary
serial, err := readUint40(r)
if err != nil {
return nil, err
@@ -364,11 +364,14 @@ func decodeTagElement(r io.Reader, resolver SerialResolver) (elem []byte, err er
if err != nil {
return nil, err
}
// Return as 32-byte binary (nostr library optimized format)
return pubkey, nil
// Return as 33-byte binary (32 bytes + null terminator) for tag.Marshal detection
result := make([]byte, 33)
copy(result, pubkey)
result[32] = 0 // null terminator
return result, nil
case TagElementEventSerial:
// Event serial: 5 bytes -> lookup full event ID -> return as 32-byte binary
// Event serial: 5 bytes -> lookup full event ID -> return as 33-byte binary
serial, err := readUint40(r)
if err != nil {
return nil, err
@@ -377,15 +380,20 @@ func decodeTagElement(r io.Reader, resolver SerialResolver) (elem []byte, err er
if err != nil {
return nil, err
}
// Return as 32-byte binary
return eventId, nil
// Return as 33-byte binary (32 bytes + null terminator) for tag.Marshal detection
result := make([]byte, 33)
copy(result, eventId)
result[32] = 0 // null terminator
return result, nil
case TagElementEventIdFull:
// Full event ID: 32 bytes (for unknown/forward references)
elem = make([]byte, 32)
if _, err = io.ReadFull(r, elem); err != nil {
// Return as 33-byte binary (32 bytes + null terminator) for tag.Marshal detection
elem = make([]byte, 33)
if _, err = io.ReadFull(r, elem[:32]); err != nil {
return nil, err
}
elem[32] = 0 // null terminator
return elem, nil
default:

View File

@@ -1 +1 @@
v0.44.0
v0.44.4