Browse Source

Remove Cashu Access Token (CAT) system entirely (v0.52.3)

- Delete pkg/cashu/ package (BDHKE, issuer, verifier, keyset, token)
- Delete pkg/interfaces/cashu/ interface definitions
- Delete pkg/bunker/acl_adapter.go CAT authorization checker
- Delete app/handle-cashu.go HTTP handlers for mint endpoints
- Delete docs/NIP-XX-CASHU-ACCESS-TOKENS.md specification
- Remove Cashu config fields from app/config/config.go
- Remove CashuIssuer/CashuVerifier from app/server.go
- Remove CAT initialization and NRC Cashu verifier from app/main.go
- Remove token extraction from app/handle-websocket.go
- Remove CAT permission checks from app/handle-event.go
- Remove CashuEnabled from bunker info response
- Remove UseCashu field from NRC connections
- Remove AuthModeCAT from NRC protocol
- Remove CAT UI from BunkerView.svelte and RelayConnectView.svelte
- Remove cashu-client.js from web UI
- Add missing bunker worker stores to stores.js

Files modified:
- app/config/config.go: Removed Cashu config fields
- app/server.go: Removed Cashu issuer/verifier
- app/main.go: Removed Cashu initialization
- app/handle-*.go: Removed CAT checks and handlers
- app/listener.go: Removed cashuToken field
- pkg/database/nrc.go: Removed UseCashu field
- pkg/protocol/nrc/: Removed CAT auth mode and handling
- pkg/event/authorization/: Removed CAT import
- app/web/src/: Removed CAT UI components and logic
- main.go: Removed CAT help text

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
main v0.52.3
woikos 4 months ago
parent
commit
6a38779794
No known key found for this signature in database
  1. 63
      app/config/config.go
  2. 15
      app/handle-bunker.go
  3. 188
      app/handle-cashu.go
  4. 50
      app/handle-event.go
  5. 64
      app/handle-nrc.go
  6. 63
      app/handle-websocket.go
  7. 2
      app/listener.go
  8. 41
      app/main.go
  9. 23
      app/server.go
  10. 2
      app/web/dist/bundle.css
  11. 20
      app/web/dist/bundle.js
  12. 2
      app/web/dist/bundle.js.map
  13. 500
      app/web/src/BunkerView.svelte
  14. 61
      app/web/src/RelayConnectView.svelte
  15. 5
      app/web/src/api.js
  16. 19
      app/web/src/bunker-service.js
  17. 11
      app/web/src/bunker-worker.js
  18. 251
      app/web/src/cashu-client.js
  19. 10
      app/web/src/nostr.js
  20. 84
      app/web/src/stores.js
  21. 390
      docs/NIP-XX-CASHU-ACCESS-TOKENS.md
  22. 9
      main.go
  23. 101
      pkg/bunker/acl_adapter.go
  24. 293
      pkg/cashu/bdhke/bdhke.go
  25. 348
      pkg/cashu/bdhke/bdhke_test.go
  26. 295
      pkg/cashu/issuer/issuer.go
  27. 296
      pkg/cashu/issuer/issuer_test.go
  28. 218
      pkg/cashu/keyset/file_store.go
  29. 338
      pkg/cashu/keyset/keyset.go
  30. 278
      pkg/cashu/keyset/keyset_test.go
  31. 74
      pkg/cashu/keyset/store.go
  32. 346
      pkg/cashu/token/token.go
  33. 336
      pkg/cashu/token/token_test.go
  34. 138
      pkg/cashu/verifier/middleware.go
  35. 186
      pkg/cashu/verifier/verifier.go
  36. 396
      pkg/cashu/verifier/verifier_test.go
  37. 25
      pkg/database/nrc.go
  38. 15
      pkg/event/authorization/authorization.go
  39. 106
      pkg/interfaces/cashu/cashu.go
  40. 35
      pkg/protocol/nrc/bridge.go
  41. 17
      pkg/protocol/nrc/nrc_test.go
  42. 113
      pkg/protocol/nrc/uri.go
  43. 2
      pkg/version/version

63
app/config/config.go

@ -56,6 +56,7 @@ type C struct { @@ -56,6 +56,7 @@ type C struct {
ACLMode string `env:"ORLY_ACL_MODE" usage:"ACL mode: follows, managed (nip-86), curating, none" default:"none"`
AuthRequired bool `env:"ORLY_AUTH_REQUIRED" usage:"require authentication for all requests (works with managed ACL)" default:"false"`
AuthToWrite bool `env:"ORLY_AUTH_TO_WRITE" usage:"require authentication only for write operations (EVENT), allow REQ/COUNT without auth" default:"false"`
NIP46BypassAuth bool `env:"ORLY_NIP46_BYPASS_AUTH" usage:"allow NIP-46 bunker events (kind 24133) through without authentication even when auth is required" default:"false"`
BootstrapRelays []string `env:"ORLY_BOOTSTRAP_RELAYS" usage:"comma-separated list of bootstrap relay URLs for initial sync"`
NWCUri string `env:"ORLY_NWC_URI" usage:"NWC (Nostr Wallet Connect) connection string for Lightning payments"`
SubscriptionEnabled bool `env:"ORLY_SUBSCRIPTION_ENABLED" default:"false" usage:"enable subscription-based access control requiring payment for non-directory events"`
@ -184,19 +185,10 @@ type C struct { @@ -184,19 +185,10 @@ type C struct {
TorBinary string `env:"ORLY_TOR_BINARY" default:"tor" usage:"path to tor binary (default: search in PATH)"`
TorSOCKS int `env:"ORLY_TOR_SOCKS" default:"0" usage:"SOCKS port for outbound Tor connections (0=disabled)"`
// Cashu access token configuration (NIP-XX)
CashuEnabled bool `env:"ORLY_CASHU_ENABLED" default:"false" usage:"enable Cashu blind signature tokens for access control"`
CashuTokenTTL string `env:"ORLY_CASHU_TOKEN_TTL" default:"168h" usage:"token validity duration (default: 1 week)"`
CashuKeysetTTL string `env:"ORLY_CASHU_KEYSET_TTL" default:"168h" usage:"keyset active signing period (default: 1 week)"`
CashuVerifyTTL string `env:"ORLY_CASHU_VERIFY_TTL" default:"504h" usage:"keyset verification period (default: 3 weeks)"`
CashuScopes string `env:"ORLY_CASHU_SCOPES" default:"relay,nip46" usage:"comma-separated list of allowed token scopes"`
CashuReauthorize bool `env:"ORLY_CASHU_REAUTHORIZE" default:"true" usage:"re-check ACL on each token verification for stateless revocation"`
// Nostr Relay Connect (NRC) configuration - tunnel private relay through public relay
NRCEnabled bool `env:"ORLY_NRC_ENABLED" default:"false" usage:"enable NRC bridge to expose this relay through a public rendezvous relay"`
NRCRendezvousURL string `env:"ORLY_NRC_RENDEZVOUS_URL" usage:"WebSocket URL of the public relay to use as rendezvous point (e.g., wss://relay.example.com)"`
NRCAuthorizedKeys string `env:"ORLY_NRC_AUTHORIZED_KEYS" usage:"comma-separated list of authorized client pubkeys (hex) for secret-based auth"`
NRCUseCashu bool `env:"ORLY_NRC_USE_CASHU" default:"false" usage:"use Cashu access tokens for NRC authentication instead of static secrets"`
NRCSessionTimeout string `env:"ORLY_NRC_SESSION_TIMEOUT" default:"30m" usage:"inactivity timeout for NRC sessions"`
// Cluster replication configuration
@ -718,57 +710,6 @@ func (cfg *C) GetWireGuardConfigValues() ( @@ -718,57 +710,6 @@ func (cfg *C) GetWireGuardConfigValues() (
cfg.BunkerPort
}
// GetCashuConfigValues returns the Cashu access token configuration values.
// This avoids circular imports with pkg/cashu while allowing main.go to construct
// the Cashu issuer/verifier configuration.
func (cfg *C) GetCashuConfigValues() (
enabled bool,
tokenTTL time.Duration,
keysetTTL time.Duration,
verifyTTL time.Duration,
scopes []string,
reauthorize bool,
) {
// Parse token TTL
tokenTTL = 168 * time.Hour // Default: 1 week
if cfg.CashuTokenTTL != "" {
if d, err := time.ParseDuration(cfg.CashuTokenTTL); err == nil {
tokenTTL = d
}
}
// Parse keyset TTL
keysetTTL = 168 * time.Hour // Default: 1 week
if cfg.CashuKeysetTTL != "" {
if d, err := time.ParseDuration(cfg.CashuKeysetTTL); err == nil {
keysetTTL = d
}
}
// Parse verify TTL
verifyTTL = 504 * time.Hour // Default: 3 weeks
if cfg.CashuVerifyTTL != "" {
if d, err := time.ParseDuration(cfg.CashuVerifyTTL); err == nil {
verifyTTL = d
}
}
// Parse scopes
if cfg.CashuScopes != "" {
scopes = strings.Split(cfg.CashuScopes, ",")
for i := range scopes {
scopes[i] = strings.TrimSpace(scopes[i])
}
}
return cfg.CashuEnabled,
tokenTTL,
keysetTTL,
verifyTTL,
scopes,
cfg.CashuReauthorize
}
// GetArchiveConfigValues returns the archive relay configuration values.
// This avoids circular imports with pkg/archive while allowing main.go to construct
// the archive manager configuration.
@ -868,7 +809,6 @@ func (cfg *C) GetNRCConfigValues() ( @@ -868,7 +809,6 @@ func (cfg *C) GetNRCConfigValues() (
enabled bool,
rendezvousURL string,
authorizedKeys []string,
useCashu bool,
sessionTimeout time.Duration,
) {
// Parse session timeout
@ -893,7 +833,6 @@ func (cfg *C) GetNRCConfigValues() ( @@ -893,7 +833,6 @@ func (cfg *C) GetNRCConfigValues() (
return cfg.NRCEnabled,
cfg.NRCRendezvousURL,
authorizedKeys,
cfg.NRCUseCashu,
sessionTimeout
}

15
app/handle-bunker.go

@ -18,7 +18,6 @@ type BunkerInfoResponse struct { @@ -18,7 +18,6 @@ type BunkerInfoResponse struct {
RelayNpub string `json:"relay_npub"` // Relay's npub
RelayPubkey string `json:"relay_pubkey"` // Relay's hex pubkey
ACLMode string `json:"acl_mode"` // Current ACL mode
CashuEnabled bool `json:"cashu_enabled"` // Whether CAT is required
Available bool `json:"available"` // Whether bunker is available
}
@ -63,19 +62,15 @@ func (s *Server) handleBunkerInfo(w http.ResponseWriter, r *http.Request) { @@ -63,19 +62,15 @@ func (s *Server) handleBunkerInfo(w http.ResponseWriter, r *http.Request) {
wsURL := strings.Replace(serviceURL, "https://", "wss://", 1)
wsURL = strings.Replace(wsURL, "http://", "ws://", 1)
// Check if Cashu is enabled
cashuEnabled := s.CashuIssuer != nil
// Bunker is available when ACL mode is not "none"
available := s.Config.ACLMode != "none"
resp := BunkerInfoResponse{
RelayURL: wsURL,
RelayNpub: relayNpub,
RelayPubkey: relayPubkeyHex,
ACLMode: s.Config.ACLMode,
CashuEnabled: cashuEnabled,
Available: available,
RelayURL: wsURL,
RelayNpub: relayNpub,
RelayPubkey: relayPubkeyHex,
ACLMode: s.Config.ACLMode,
Available: available,
}
w.Header().Set("Content-Type", "application/json")

188
app/handle-cashu.go

@ -1,188 +0,0 @@ @@ -1,188 +0,0 @@
package app
import (
"encoding/hex"
"encoding/json"
"net/http"
"time"
"lol.mleku.dev/log"
"git.mleku.dev/mleku/nostr/httpauth"
"next.orly.dev/pkg/cashu/issuer"
"next.orly.dev/pkg/cashu/keyset"
"next.orly.dev/pkg/cashu/token"
)
// CashuMintRequest is the request body for token issuance.
type CashuMintRequest struct {
BlindedMessage string `json:"blinded_message"` // Hex-encoded blinded point B_
Scope string `json:"scope"` // Token scope (e.g., "relay", "nip46")
Kinds []int `json:"kinds,omitempty"` // Permitted event kinds
KindRanges [][]int `json:"kind_ranges,omitempty"` // Permitted kind ranges
}
// CashuMintResponse is the response body for token issuance.
// Field names match NIP-XX Cashu Access Tokens spec.
type CashuMintResponse struct {
BlindedSignature string `json:"blinded_signature"` // Hex-encoded blinded signature C_
KeysetID string `json:"keyset_id"` // Keyset ID used
Expiry int64 `json:"expiry"` // Token expiration timestamp
MintPubkey string `json:"pubkey"` // Hex-encoded mint public key (spec: "pubkey")
}
// handleCashuMint handles POST /cashu/mint - issues a new token.
func (s *Server) handleCashuMint(w http.ResponseWriter, r *http.Request) {
// CORS headers for browser-based CAT token requests
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Accept, Authorization")
// Handle preflight
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
// 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 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
}
// Parse request body
var req CashuMintRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Decode blinded message from hex
blindedMsg, err := hex.DecodeString(req.BlindedMessage)
if err != nil {
http.Error(w, "Invalid blinded_message: must be hex", http.StatusBadRequest)
return
}
// Default scope
if req.Scope == "" {
req.Scope = token.ScopeRelay
}
// Issue token
issueReq := &issuer.IssueRequest{
BlindedMessage: blindedMsg,
Pubkey: pubkey,
Scope: req.Scope,
Kinds: req.Kinds,
KindRanges: req.KindRanges,
}
resp, err := s.CashuIssuer.Issue(r.Context(), issueReq, r.RemoteAddr)
if err != nil {
log.W.F("Cashu mint failed for %x: %v", pubkey[:8], err)
http.Error(w, err.Error(), http.StatusForbidden)
return
}
log.D.F("Cashu token issued for %x, scope=%s, keyset=%s", pubkey[:8], req.Scope, resp.KeysetID)
// Return response
mintResp := CashuMintResponse{
BlindedSignature: hex.EncodeToString(resp.BlindedSignature),
KeysetID: resp.KeysetID,
Expiry: resp.Expiry,
MintPubkey: hex.EncodeToString(resp.MintPubkey),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(mintResp)
}
// handleCashuKeysets handles GET /cashu/keysets - returns available keysets.
func (s *Server) handleCashuKeysets(w http.ResponseWriter, r *http.Request) {
// CORS headers for browser-based CAT support
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Accept")
// Handle preflight
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if s.CashuIssuer == nil {
http.Error(w, "Cashu tokens not enabled", http.StatusNotImplemented)
return
}
infos := s.CashuIssuer.GetKeysetInfo()
type KeysetsResponse struct {
Keysets []keyset.KeysetInfo `json:"keysets"`
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(KeysetsResponse{Keysets: infos})
}
// handleCashuInfo handles GET /cashu/info - returns mint information.
func (s *Server) handleCashuInfo(w http.ResponseWriter, r *http.Request) {
// CORS headers for browser-based CAT support detection
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Accept")
// Handle preflight
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if s.CashuIssuer == nil {
http.Error(w, "Cashu tokens not enabled", http.StatusNotImplemented)
return
}
info := s.CashuIssuer.GetMintInfo(s.Config.AppName)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(info)
}
// CashuTokenTTL returns the configured token TTL.
func (s *Server) CashuTokenTTL() time.Duration {
enabled, tokenTTL, _, _, _, _ := s.Config.GetCashuConfigValues()
if !enabled {
return 0
}
return tokenTTL
}
// CashuKeysetTTL returns the configured keyset TTL.
func (s *Server) CashuKeysetTTL() time.Duration {
enabled, _, keysetTTL, _, _, _ := s.Config.GetCashuConfigValues()
if !enabled {
return 0
}
return keysetTTL
}

50
app/handle-event.go

@ -7,7 +7,6 @@ import ( @@ -7,7 +7,6 @@ import (
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/cashu/token"
"next.orly.dev/pkg/event/routing"
"git.mleku.dev/mleku/nostr/encoders/envelopes/authenvelope"
"git.mleku.dev/mleku/nostr/encoders/envelopes/eventenvelope"
@ -134,36 +133,6 @@ func (l *Listener) HandleEvent(msg []byte) (err error) { @@ -134,36 +133,6 @@ func (l *Listener) HandleEvent(msg []byte) (err error) {
return
}
// Check Cashu token kind permissions if a token was provided
if l.cashuToken != nil && !l.cashuToken.IsKindPermitted(int(env.E.Kind)) {
log.W.F("HandleEvent: rejecting event kind %d - not permitted by Cashu token", env.E.Kind)
if err = Ok.Error(l, env, "event kind not permitted by access token"); chk.E(err) {
return
}
return
}
// Require Cashu token for NIP-46 events when Cashu is enabled and ACL is active
const kindNIP46 = 24133
if env.E.Kind == kindNIP46 && l.CashuVerifier != nil && l.Config.ACLMode != "none" {
log.D.F("HandleEvent: NIP-46 event from %s, cashuToken=%v, ACLMode=%s", l.remote, l.cashuToken != nil, l.Config.ACLMode)
if l.cashuToken == nil {
log.W.F("HandleEvent: rejecting NIP-46 event from %s - Cashu access token required (connection has no token)", l.remote)
if err = Ok.Error(l, env, "restricted: NIP-46 requires Cashu access token"); chk.E(err) {
return
}
return
}
// Also verify the token has NIP-46 scope
if l.cashuToken.Scope != token.ScopeNIP46 && l.cashuToken.Scope != token.ScopeRelay {
log.W.F("HandleEvent: rejecting NIP-46 event - token scope %q not valid for NIP-46", l.cashuToken.Scope)
if err = Ok.Error(l, env, "restricted: access token scope not valid for NIP-46"); chk.E(err) {
return
}
return
}
}
// Handle NIP-43 special events before ACL checks
switch env.E.Kind {
case nip43.KindJoinRequest:
@ -262,17 +231,14 @@ func (l *Listener) HandleEvent(msg []byte) (err error) { @@ -262,17 +231,14 @@ func (l *Listener) HandleEvent(msg []byte) (err error) {
log.I.F("HandleEvent: authorized with access level %s", decision.AccessLevel)
// Progressive throttle for follows ACL mode (delays non-followed users)
// Skip throttle if a Cashu Access Token is present (authenticated via CAT)
if l.cashuToken == nil {
if delay := l.getFollowsThrottleDelay(env.E); delay > 0 {
log.D.F("HandleEvent: applying progressive throttle delay of %v for %0x from %s",
delay, env.E.Pubkey, l.remote)
select {
case <-l.ctx.Done():
return l.ctx.Err()
case <-time.After(delay):
// Delay completed, continue processing
}
if delay := l.getFollowsThrottleDelay(env.E); delay > 0 {
log.D.F("HandleEvent: applying progressive throttle delay of %v for %0x from %s",
delay, env.E.Pubkey, l.remote)
select {
case <-l.ctx.Done():
return l.ctx.Err()
case <-time.After(delay):
// Delay completed, continue processing
}
}

64
app/handle-nrc.go

@ -15,27 +15,12 @@ import ( @@ -15,27 +15,12 @@ import (
"next.orly.dev/pkg/database"
)
// getCashuMintURL returns the Cashu mint URL based on relay configuration.
// Returns empty string if Cashu is not enabled.
func (s *Server) getCashuMintURL() string {
if !s.Config.CashuEnabled || s.CashuIssuer == nil {
return ""
}
// Use configured relay URL with /cashu/mint path
relayURL := strings.TrimSuffix(s.Config.RelayURL, "/")
if relayURL == "" {
return ""
}
return relayURL + "/cashu/mint"
}
// NRCConnectionResponse is the response structure for NRC connection API.
type NRCConnectionResponse struct {
ID string `json:"id"`
Label string `json:"label"`
CreatedAt int64 `json:"created_at"`
LastUsed int64 `json:"last_used"`
UseCashu bool `json:"use_cashu"`
URI string `json:"uri,omitempty"` // Only included when specifically requested
}
@ -49,14 +34,12 @@ type NRCConnectionsResponse struct { @@ -49,14 +34,12 @@ type NRCConnectionsResponse struct {
type NRCConfigResponse struct {
Enabled bool `json:"enabled"`
RendezvousURL string `json:"rendezvous_url"`
MintURL string `json:"mint_url,omitempty"`
RelayPubkey string `json:"relay_pubkey"`
}
// NRCCreateRequest is the request body for creating a connection.
type NRCCreateRequest struct {
Label string `json:"label"`
UseCashu bool `json:"use_cashu"`
Label string `json:"label"`
}
// handleNRCConnections handles GET /api/nrc/connections
@ -107,7 +90,7 @@ func (s *Server) handleNRCConnections(w http.ResponseWriter, r *http.Request) { @@ -107,7 +90,7 @@ func (s *Server) handleNRCConnections(w http.ResponseWriter, r *http.Request) {
relayPubkey, _ := keys.SecretBytesToPubKeyBytes(relaySecretKey)
// Get NRC config values
nrcEnabled, nrcRendezvousURL, _, nrcUseCashu, _ := s.Config.GetNRCConfigValues()
nrcEnabled, nrcRendezvousURL, _, _ := s.Config.GetNRCConfigValues()
// Build response
response := NRCConnectionsResponse{
@ -119,19 +102,12 @@ func (s *Server) handleNRCConnections(w http.ResponseWriter, r *http.Request) { @@ -119,19 +102,12 @@ func (s *Server) handleNRCConnections(w http.ResponseWriter, r *http.Request) {
},
}
// Add mint URL if Cashu is enabled
mintURL := s.getCashuMintURL()
if nrcUseCashu && mintURL != "" {
response.Config.MintURL = mintURL
}
for _, conn := range conns {
response.Connections = append(response.Connections, NRCConnectionResponse{
ID: conn.ID,
Label: conn.Label,
CreatedAt: conn.CreatedAt,
LastUsed: conn.LastUsed,
UseCashu: conn.UseCashu,
})
}
@ -186,7 +162,7 @@ func (s *Server) handleNRCCreate(w http.ResponseWriter, r *http.Request) { @@ -186,7 +162,7 @@ func (s *Server) handleNRCCreate(w http.ResponseWriter, r *http.Request) {
}
// Create the connection
conn, err := badgerDB.CreateNRCConnection(req.Label, req.UseCashu)
conn, err := badgerDB.CreateNRCConnection(req.Label)
if chk.E(err) {
http.Error(w, "Failed to create connection", http.StatusInternalServerError)
return
@ -201,16 +177,10 @@ func (s *Server) handleNRCCreate(w http.ResponseWriter, r *http.Request) { @@ -201,16 +177,10 @@ func (s *Server) handleNRCCreate(w http.ResponseWriter, r *http.Request) {
relayPubkey, _ := keys.SecretBytesToPubKeyBytes(relaySecretKey)
// Get NRC config values
_, nrcRendezvousURL, _, nrcUseCashu, _ := s.Config.GetNRCConfigValues()
// Get mint URL if Cashu enabled
mintURL := ""
if nrcUseCashu {
mintURL = s.getCashuMintURL()
}
_, nrcRendezvousURL, _, _ := s.Config.GetNRCConfigValues()
// Generate URI
uri, err := badgerDB.GetNRCConnectionURI(conn, relayPubkey, nrcRendezvousURL, mintURL)
uri, err := badgerDB.GetNRCConnectionURI(conn, relayPubkey, nrcRendezvousURL)
if chk.E(err) {
log.W.F("failed to generate URI for new connection: %v", err)
}
@ -224,7 +194,6 @@ func (s *Server) handleNRCCreate(w http.ResponseWriter, r *http.Request) { @@ -224,7 +194,6 @@ func (s *Server) handleNRCCreate(w http.ResponseWriter, r *http.Request) {
Label: conn.Label,
CreatedAt: conn.CreatedAt,
LastUsed: conn.LastUsed,
UseCashu: conn.UseCashu,
URI: uri,
}
@ -347,16 +316,10 @@ func (s *Server) handleNRCGetURI(w http.ResponseWriter, r *http.Request) { @@ -347,16 +316,10 @@ func (s *Server) handleNRCGetURI(w http.ResponseWriter, r *http.Request) {
relayPubkey, _ := keys.SecretBytesToPubKeyBytes(relaySecretKey)
// Get NRC config values
_, nrcRendezvousURL, _, nrcUseCashu, _ := s.Config.GetNRCConfigValues()
// Get mint URL if Cashu enabled
mintURL := ""
if nrcUseCashu {
mintURL = s.getCashuMintURL()
}
_, nrcRendezvousURL, _, _ := s.Config.GetNRCConfigValues()
// Generate URI
uri, err := badgerDB.GetNRCConnectionURI(conn, relayPubkey, nrcRendezvousURL, mintURL)
uri, err := badgerDB.GetNRCConnectionURI(conn, relayPubkey, nrcRendezvousURL)
if chk.E(err) {
http.Error(w, "Failed to generate URI", http.StatusInternalServerError)
return
@ -417,7 +380,7 @@ func (s *Server) handleNRCConfig(w http.ResponseWriter, r *http.Request) { @@ -417,7 +380,7 @@ func (s *Server) handleNRCConfig(w http.ResponseWriter, r *http.Request) {
}
// Get NRC config values
nrcEnabled, nrcRendezvousURL, _, nrcUseCashu, _ := s.Config.GetNRCConfigValues()
nrcEnabled, nrcRendezvousURL, _, _ := s.Config.GetNRCConfigValues()
// Check if Badger is available (NRC requires Badger)
_, badgerAvailable := s.DB.(*database.D)
@ -426,21 +389,10 @@ func (s *Server) handleNRCConfig(w http.ResponseWriter, r *http.Request) { @@ -426,21 +389,10 @@ func (s *Server) handleNRCConfig(w http.ResponseWriter, r *http.Request) {
Enabled bool `json:"enabled"`
BadgerRequired bool `json:"badger_required"`
RendezvousURL string `json:"rendezvous_url,omitempty"`
UseCashu bool `json:"use_cashu"`
MintURL string `json:"mint_url,omitempty"`
}{
Enabled: nrcEnabled && badgerAvailable,
BadgerRequired: !badgerAvailable,
RendezvousURL: nrcRendezvousURL,
UseCashu: nrcUseCashu,
}
// Add mint URL if Cashu is enabled
if nrcUseCashu {
mintURL := s.getCashuMintURL()
if mintURL != "" {
response.MintURL = mintURL
}
}
w.Header().Set("Content-Type", "application/json")

63
app/handle-websocket.go

@ -13,7 +13,6 @@ import ( @@ -13,7 +13,6 @@ import (
"lol.mleku.dev/log"
"git.mleku.dev/mleku/nostr/encoders/envelopes/authenvelope"
"git.mleku.dev/mleku/nostr/encoders/hex"
"next.orly.dev/pkg/cashu/token"
"next.orly.dev/pkg/protocol/publish"
"git.mleku.dev/mleku/nostr/utils/units"
)
@ -57,12 +56,6 @@ func (s *Server) HandleWebsocket(w http.ResponseWriter, r *http.Request) { @@ -57,12 +56,6 @@ func (s *Server) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
return
}
whitelist:
// Extract and verify Cashu access token if verifier is configured
var cashuToken *token.Token
if s.CashuVerifier != nil {
cashuToken = s.extractWebSocketToken(r, remote)
}
// Create an independent context for this connection
// This context will be cancelled when the connection closes or server shuts down
ctx, cancel := context.WithCancel(s.Ctx)
@ -109,7 +102,6 @@ whitelist: @@ -109,7 +102,6 @@ whitelist:
remote: remote,
connectionID: fmt.Sprintf("%s-%d", remote, now.UnixNano()), // Unique connection ID for access tracking
req: r,
cashuToken: cashuToken, // Verified Cashu access token (nil if none provided)
startTime: now,
writeChan: make(chan publish.WriteRequest, 100), // Buffered channel for writes
writeDone: make(chan struct{}),
@ -303,58 +295,3 @@ func (s *Server) Pinger( @@ -303,58 +295,3 @@ func (s *Server) Pinger(
}
}
// extractWebSocketToken extracts and verifies a Cashu access token from a WebSocket upgrade request.
// Checks query param first (for browser WebSocket clients), then headers.
// Returns nil if no token is provided or if token verification fails.
func (s *Server) extractWebSocketToken(r *http.Request, remote string) *token.Token {
// Try query param first (WebSocket clients often can't set custom headers)
tokenStr := r.URL.Query().Get("token")
log.D.F("ws %s: CAT extraction - query param token: %v", remote, tokenStr != "")
// Try X-Cashu-Token header
if tokenStr == "" {
tokenStr = r.Header.Get("X-Cashu-Token")
log.D.F("ws %s: CAT extraction - X-Cashu-Token header: %v", remote, tokenStr != "")
}
// Try Authorization: Cashu scheme
if tokenStr == "" {
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Cashu ") {
tokenStr = strings.TrimPrefix(auth, "Cashu ")
}
log.D.F("ws %s: CAT extraction - Authorization header: %v", remote, tokenStr != "")
}
// No token provided - this is fine, connection proceeds without token
if tokenStr == "" {
log.D.F("ws %s: CAT extraction - no token found", remote)
return nil
}
log.D.F("ws %s: CAT extraction - found token (len=%d)", remote, len(tokenStr))
// Parse the token
tok, err := token.Parse(tokenStr)
if err != nil {
log.W.F("ws %s: invalid Cashu token format: %v", remote, err)
return nil
}
// Verify token - accept both "relay" and "nip46" scopes for WebSocket connections
// NIP-46 connections are also WebSocket-based
ctx := context.Background()
if err := s.CashuVerifier.Verify(ctx, tok, remote); err != nil {
log.W.F("ws %s: Cashu token verification failed: %v", remote, err)
return nil
}
// Check scope - allow "relay" or "nip46"
if tok.Scope != token.ScopeRelay && tok.Scope != token.ScopeNIP46 {
log.W.F("ws %s: Cashu token has invalid scope %q for WebSocket", remote, tok.Scope)
return nil
}
log.D.F("ws %s: verified Cashu token with scope %q, expires %v",
remote, tok.Scope, tok.ExpiresAt())
return tok
}

2
app/listener.go

@ -13,7 +13,6 @@ import ( @@ -13,7 +13,6 @@ import (
"lol.mleku.dev/errorf"
"lol.mleku.dev/log"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/cashu/token"
"next.orly.dev/pkg/database"
"git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/filter"
@ -32,7 +31,6 @@ type Listener struct { @@ -32,7 +31,6 @@ type Listener struct {
req *http.Request
challenge atomicutils.Bytes
authedPubkey atomicutils.Bytes
cashuToken *token.Token // Verified Cashu access token for this connection (nil if no token)
startTime time.Time
isBlacklisted bool // Marker to identify blacklisted IPs
blacklistTimeout time.Time // When to timeout blacklisted connections

41
app/main.go

@ -27,11 +27,7 @@ import ( @@ -27,11 +27,7 @@ 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"
"next.orly.dev/pkg/protocol/nrc"
cashuiface "next.orly.dev/pkg/interfaces/cashu"
"next.orly.dev/pkg/ratelimit"
"next.orly.dev/pkg/spider"
"next.orly.dev/pkg/storage"
@ -193,34 +189,8 @@ func Run( @@ -193,34 +189,8 @@ 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 NRC (Nostr Relay Connect) bridge if enabled
nrcEnabled, nrcRendezvousURL, nrcAuthorizedKeys, nrcUseCashu, nrcSessionTimeout := cfg.GetNRCConfigValues()
nrcEnabled, nrcRendezvousURL, nrcAuthorizedKeys, nrcSessionTimeout := cfg.GetNRCConfigValues()
if nrcEnabled && nrcRendezvousURL != "" {
// Get relay identity for signing NRC responses
relaySecretKey, err := db.GetOrCreateRelayIdentitySecret()
@ -276,19 +246,14 @@ func Run( @@ -276,19 +246,14 @@ func Run(
SessionTimeout: nrcSessionTimeout,
}
// Add Cashu verifier if enabled
if nrcUseCashu && l.CashuVerifier != nil {
bridgeConfig.CashuVerifier = l.CashuVerifier
}
// Create and start the bridge
l.nrcBridge = nrc.NewBridge(bridgeConfig)
if err := l.nrcBridge.Start(); err != nil {
log.E.F("failed to start NRC bridge: %v", err)
l.nrcBridge = nil
} else {
log.I.F("NRC bridge started (rendezvous: %s, authorized: %d, cashu: %v)",
nrcRendezvousURL, len(authorizedSecrets), nrcUseCashu && l.CashuVerifier != nil)
log.I.F("NRC bridge started (rendezvous: %s, authorized: %d)",
nrcRendezvousURL, len(authorizedSecrets))
}
}
}

23
app/server.go

@ -35,8 +35,6 @@ import ( @@ -35,8 +35,6 @@ 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/verifier"
"next.orly.dev/pkg/protocol/nrc"
"next.orly.dev/pkg/ratelimit"
"next.orly.dev/pkg/spider"
@ -93,10 +91,6 @@ type Server struct { @@ -93,10 +91,6 @@ type Server struct {
bunkerServer *bunker.Server
subnetPool *wireguard.SubnetPool
// Cashu access token system (NIP-XX)
CashuIssuer *issuer.Issuer
CashuVerifier *verifier.Verifier
// NRC (Nostr Relay Connect) bridge for remote relay access
nrcBridge *nrc.Bridge
@ -432,14 +426,6 @@ func (s *Server) UserInterface() { @@ -432,14 +426,6 @@ func (s *Server) UserInterface() {
s.mux.HandleFunc("/api/bunker/url", s.handleBunkerURL)
s.mux.HandleFunc("/api/bunker/info", s.handleBunkerInfo)
// Cashu access token endpoints (NIP-XX)
s.mux.HandleFunc("/cashu/mint", s.handleCashuMint)
s.mux.HandleFunc("/cashu/keysets", s.handleCashuKeysets)
s.mux.HandleFunc("/cashu/info", s.handleCashuInfo)
if s.CashuIssuer != nil {
log.Printf("Cashu access token API enabled at /cashu")
}
// NRC (Nostr Relay Connect) management endpoints
s.mux.HandleFunc("/api/nrc/connections", s.handleNRCConnectionsRouter)
s.mux.HandleFunc("/api/nrc/connections/", s.handleNRCConnectionsRouter)
@ -1510,10 +1496,11 @@ func (s *Server) InitEventServices() { @@ -1510,10 +1496,11 @@ func (s *Server) InitEventServices() {
// Initialize authorization service
authCfg := &authorization.Config{
AuthRequired: s.Config.AuthRequired,
AuthToWrite: s.Config.AuthToWrite,
Admins: s.Admins,
Owners: s.Owners,
AuthRequired: s.Config.AuthRequired,
AuthToWrite: s.Config.AuthToWrite,
NIP46BypassAuth: s.Config.NIP46BypassAuth,
Admins: s.Admins,
Owners: s.Owners,
}
s.eventAuthorizer = authorization.New(
authCfg,

2
app/web/dist/bundle.css vendored

File diff suppressed because one or more lines are too long

20
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

500
app/web/src/BunkerView.svelte

@ -1,19 +1,13 @@ @@ -1,19 +1,13 @@
<script>
import { createEventDispatcher, onMount } from "svelte";
import QRCode from "qrcode";
import { getBunkerInfo, createNIP98Auth } from "./api.js";
import { requestToken, encodeToken, TokenScope, getMintInfo } from "./cashu-client.js";
import { hexToBytes, bytesToHex } from "@noble/hashes/utils";
import { getBunkerInfo } from "./api.js";
import { bytesToHex } from "@noble/hashes/utils";
import {
bunkerServiceActive,
bunkerServiceCatToken,
bunkerClientTokens,
bunkerSelectedTokenId,
bunkerConnectedClients,
configureBunkerWorker,
connectBunkerWorker,
disconnectBunkerWorker,
addBunkerSecret,
requestBunkerStatus,
resetBunkerState
} from "./stores.js";
@ -38,109 +32,8 @@ @@ -38,109 +32,8 @@
// Subscribe to global bunker stores
$: isServiceActive = $bunkerServiceActive;
$: clientTokens = $bunkerClientTokens;
$: selectedTokenId = $bunkerSelectedTokenId;
$: connectedClients = $bunkerConnectedClients;
// 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
};
bunkerClientTokens.update(tokens => [...tokens, newToken]);
// Select the new token if none selected
if (!$bunkerSelectedTokenId) {
bunkerSelectedTokenId.set(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" ||
currentEffectiveRole === "admin" ||
@ -148,10 +41,8 @@ @@ -148,10 +41,8 @@
);
// Generate bunker URLs when bunkerInfo and userPubkey are available
// 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}` : "";
$: clientBunkerURL = bunkerInfo && userPubkey ?
`bunker://${userPubkey}?relay=${encodeURIComponent(bunkerInfo.relay_url)}${bunkerSecret ? `&secret=${bunkerSecret}` : ''}` : "";
$: signerBunkerURL = bunkerInfo ?
`nostr+connect://${bunkerInfo.relay_url}` : "";
@ -181,50 +72,19 @@ @@ -181,50 +72,19 @@
error = "";
try {
let serviceCatTokenEncoded = null;
// Check if CAT is required and mint tokens
if (bunkerInfo.cashu_enabled) {
console.log("CAT required, minting tokens...");
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}`;
};
// 1. Token for worker's relay connection
const serviceCatToken = await requestToken(
mintInfo.mintUrl,
TokenScope.NIP46,
hexToBytes(userPubkey),
signHttpAuth,
[24133]
);
serviceCatTokenEncoded = encodeToken(serviceCatToken);
bunkerServiceCatToken.set(serviceCatToken);
console.log("Service CAT token acquired, expires:", new Date(serviceCatToken.expiry * 1000).toISOString());
// 2. Create first client token
await addClientToken(mintInfo, signHttpAuth);
}
}
// Configure the worker with user credentials
const privkeyHex = userPrivkey instanceof Uint8Array ? bytesToHex(userPrivkey) : userPrivkey;
configureBunkerWorker({
userPubkey,
userPrivkey: privkeyHex,
relayUrl: bunkerInfo.relay_url,
catTokenEncoded: serviceCatTokenEncoded,
secrets: bunkerSecret ? [bunkerSecret] : []
});
// Connect the worker
connectBunkerWorker();
// Regenerate QR codes with CAT token
// Regenerate QR codes
await generateQRCodes();
console.log("Bunker worker started successfully");
@ -240,7 +100,6 @@ @@ -240,7 +100,6 @@
// Stop the bunker service (via Web Worker)
function stopBunkerService() {
resetBunkerState();
// Regenerate QR codes without CAT token
generateQRCodes();
}
@ -334,13 +193,6 @@ @@ -334,13 +193,6 @@
<div class="error-message">{error}</div>
{/if}
{#if bunkerInfo?.cashu_enabled && bunkerInfo?.acl_mode !== "none"}
<div class="cat-warning">
<strong>CAT Required:</strong> This relay requires Cashu Access Tokens (CAT) for bunker connections.
Your client must support CAT authentication or connections will be rejected.
</div>
{/if}
{#if isLoading && !bunkerInfo}
<div class="loading">Loading bunker information...</div>
{:else if bunkerInfo}
@ -391,97 +243,6 @@ @@ -391,97 +243,6 @@
{/if}
</div>
<!-- Client Tokens Table - show if tokens exist, even if temporarily disconnected -->
{#if 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="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>
{/each}
</div>
</div>
{/if}
<!-- Connection Info -->
<div class="connection-info">
<h4>Connection Details</h4>
@ -563,16 +324,6 @@ @@ -563,16 +324,6 @@
margin-bottom: 1em;
}
.cat-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;
margin-bottom: 1em;
font-size: 0.95em;
}
.loading {
text-align: center;
padding: 2em;
@ -715,29 +466,6 @@ @@ -715,29 +466,6 @@
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));
@ -957,187 +685,6 @@ @@ -957,187 +685,6 @@
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;
@ -1151,42 +698,5 @@ @@ -1151,42 +698,5 @@
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>

61
app/web/src/RelayConnectView.svelte

@ -22,7 +22,6 @@ @@ -22,7 +22,6 @@
// New connection form
let newLabel = "";
let newUseCashu = false;
// URI display modal
let showURIModal = false;
@ -92,7 +91,7 @@ @@ -92,7 +91,7 @@
isLoading = true;
try {
const result = await api.createNRCConnection(userSigner, userPubkey, newLabel.trim(), newUseCashu);
const result = await api.createNRCConnection(userSigner, userPubkey, newLabel.trim());
// Show the URI modal with the new connection
currentURI = result.uri;
@ -101,7 +100,6 @@ @@ -101,7 +100,6 @@
// Reset form
newLabel = "";
newUseCashu = false;
// Reload connections
await loadConnections();
@ -223,12 +221,6 @@ @@ -223,12 +221,6 @@
<span class="status-label">Rendezvous:</span>
<span class="status-value">{config.rendezvous_url || "Not configured"}</span>
</div>
{#if config.mint_url}
<div class="status-item">
<span class="status-label">Cashu Mint:</span>
<span class="status-value">{config.mint_url}</span>
</div>
{/if}
</div>
<!-- Create new connection -->
@ -245,19 +237,6 @@ @@ -245,19 +237,6 @@
disabled={isLoading}
/>
</div>
<div class="form-group checkbox-group">
<label>
<input
type="checkbox"
bind:checked={newUseCashu}
disabled={isLoading || !config.mint_url}
/>
Include CAT (Cashu Access Token)
{#if !config.mint_url}
<span class="hint">(requires Cashu mint)</span>
{/if}
</label>
</div>
<button
class="create-btn"
on:click={createConnection}
@ -285,9 +264,6 @@ @@ -285,9 +264,6 @@
{#if conn.last_used}
<span class="detail">Last used: {formatTimestamp(conn.last_used)}</span>
{/if}
{#if conn.use_cashu}
<span class="badge cashu">CAT</span>
{/if}
</div>
</div>
<div class="connection-actions">
@ -452,28 +428,6 @@ @@ -452,28 +428,6 @@
font-size: 1em;
}
.checkbox-group {
flex-direction: row;
align-items: center;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-group input[type="checkbox"] {
width: 1.2em;
height: 1.2em;
}
.hint {
color: var(--muted-foreground);
font-size: 0.85em;
}
.create-btn {
background: var(--primary);
color: var(--text-color);
@ -532,19 +486,6 @@ @@ -532,19 +486,6 @@
color: var(--muted-foreground);
}
.badge {
background: var(--primary);
color: var(--text-color);
padding: 0.1em 0.4em;
border-radius: 0.25rem;
font-size: 0.75em;
font-weight: 600;
}
.badge.cashu {
background: var(--warning);
}
.connection-actions {
display: flex;
gap: 0.5rem;

5
app/web/src/api.js

@ -528,10 +528,9 @@ export async function fetchNRCConnections(signer, pubkey) { @@ -528,10 +528,9 @@ export async function fetchNRCConnections(signer, pubkey) {
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @param {string} label - Connection label
* @param {boolean} useCashu - Whether to use CAT authentication
* @returns {Promise<object>} Created connection with URI
*/
export async function createNRCConnection(signer, pubkey, label, useCashu = false) {
export async function createNRCConnection(signer, pubkey, label) {
const url = `${getApiBase()}/api/nrc/connections`;
const authHeader = await createNIP98Auth(signer, pubkey, "POST", url);
const response = await fetch(url, {
@ -540,7 +539,7 @@ export async function createNRCConnection(signer, pubkey, label, useCashu = fals @@ -540,7 +539,7 @@ export async function createNRCConnection(signer, pubkey, label, useCashu = fals
"Content-Type": "application/json",
...(authHeader ? { Authorization: `Nostr ${authHeader}` } : {}),
},
body: JSON.stringify({ label, use_cashu: useCashu }),
body: JSON.stringify({ label }),
});
if (!response.ok) {
const error = await response.text();

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

@ -14,7 +14,6 @@ @@ -14,7 +14,6 @@
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 = {
@ -55,7 +54,6 @@ export class BunkerService { @@ -55,7 +54,6 @@ export class BunkerService {
this.requestLog = [];
this.heartbeatInterval = null;
this.subscriptionId = null;
this.catToken = null;
// Callbacks
this.onClientConnected = null;
@ -78,13 +76,6 @@ export class BunkerService { @@ -78,13 +76,6 @@ export class BunkerService {
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.
*/
@ -100,15 +91,7 @@ export class BunkerService { @@ -100,15 +91,7 @@ export class BunkerService {
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]);
console.log('[BunkerService] Connecting to:', wsUrl);
const ws = new WebSocket(wsUrl);

11
app/web/src/bunker-worker.js

@ -15,7 +15,6 @@ let connected = false; @@ -15,7 +15,6 @@ let connected = false;
let userPubkey = null;
let userPrivkey = null;
let relayUrl = null;
let catTokenEncoded = null;
let subscriptionId = null;
let heartbeatInterval = null;
let allowedSecrets = new Set();
@ -69,14 +68,7 @@ async function connect() { @@ -69,14 +68,7 @@ async function connect() {
wsUrl = 'wss://' + wsUrl;
}
// Add CAT token if available
if (catTokenEncoded) {
const url = new URL(wsUrl);
url.searchParams.set('token', catTokenEncoded);
wsUrl = url.toString();
}
console.log('[BunkerWorker] Connecting to:', wsUrl.split('?')[0]);
console.log('[BunkerWorker] Connecting to:', wsUrl);
ws = new WebSocket(wsUrl);
@ -383,7 +375,6 @@ self.onmessage = async (event) => { @@ -383,7 +375,6 @@ self.onmessage = async (event) => {
userPubkey = data.userPubkey;
userPrivkey = data.userPrivkey ? hexToBytes(data.userPrivkey) : null;
relayUrl = data.relayUrl;
catTokenEncoded = data.catTokenEncoded;
if (data.secrets) {
allowedSecrets = new Set(data.secrets);
}

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

@ -1,251 +0,0 @@ @@ -1,251 +0,0 @@
/**
* Cashu Token Client
*
* Manages Cashu access tokens for relay 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',
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, 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;
}
}

10
app/web/src/nostr.js

@ -501,6 +501,7 @@ function parseProfileFromEvent(event) { @@ -501,6 +501,7 @@ function parseProfileFromEvent(event) {
// Fetch user profile metadata (kind 0)
export async function fetchUserProfile(pubkey) {
console.log(`Starting profile fetch for pubkey: ${pubkey}`);
console.log(`[fetchUserProfile] Current relay list:`, nostrClient.relays);
// 1) Try cached profile first and resolve immediately if present
try {
@ -551,6 +552,8 @@ export async function fetchUserProfile(pubkey) { @@ -551,6 +552,8 @@ export async function fetchUserProfile(pubkey) {
// Helper to fetch profile from fallback relays
async function fetchProfileFromFallbackRelays(pubkey, filters) {
console.log(`[fetchProfileFromFallbackRelays] Querying fallback relays:`, FALLBACK_RELAYS);
console.log(`[fetchProfileFromFallbackRelays] Using filters:`, JSON.stringify(filters));
return new Promise((resolve) => {
const events = [];
const pool = getFallbackPool();
@ -572,16 +575,19 @@ async function fetchProfileFromFallbackRelays(pubkey, filters) { @@ -572,16 +575,19 @@ async function fetchProfileFromFallbackRelays(pubkey, filters) {
filters,
{
onevent(event) {
console.log("Profile event received from fallback relay:", event.id?.substring(0, 8));
console.log("[fetchProfileFromFallbackRelays] Event received:", event.id?.substring(0, 8), "kind:", event.kind, "pubkey:", event.pubkey?.substring(0, 8));
events.push(event);
},
oneose() {
console.log(`[fetchProfileFromFallbackRelays] EOSE received, got ${events.length} events`);
clearTimeout(timeoutId);
if (sub) sub.close();
if (events.length > 0) {
events.sort((a, b) => b.created_at - a.created_at);
console.log("[fetchProfileFromFallbackRelays] Returning best event:", events[0].id?.substring(0, 8));
resolve(events[0]);
} else {
console.log("[fetchProfileFromFallbackRelays] No events found");
resolve(null);
}
}
@ -1080,6 +1086,8 @@ export async function fetchDeleteEventsByTarget(eventId, options = {}) { @@ -1080,6 +1086,8 @@ export async function fetchDeleteEventsByTarget(eventId, options = {}) {
// Initialize client connection
export async function initializeNostrClient() {
// Refresh relay list to pick up any changes (important for standalone mode)
nostrClient.refreshRelays();
await nostrClient.connect();
}

84
app/web/src/stores.js

@ -165,3 +165,87 @@ export function touchRelay(url) { @@ -165,3 +165,87 @@ export function touchRelay(url) {
return relays;
});
}
// ==================== Bunker Service State ====================
export const bunkerServiceActive = writable(false);
export const bunkerConnectedClients = writable([]);
// Bunker worker instance (persists across component mounts)
let bunkerWorker = null;
/**
* Get or create the bunker worker
*/
function getBunkerWorker() {
if (!bunkerWorker) {
bunkerWorker = new Worker(new URL('./bunker-worker.js', import.meta.url), { type: 'module' });
bunkerWorker.onmessage = (event) => {
const { type, ...data } = event.data;
switch (type) {
case 'status':
bunkerServiceActive.set(data.status === 'connected');
break;
case 'clients':
bunkerConnectedClients.set(data.clients || []);
break;
case 'error':
console.error('[BunkerStore] Worker error:', data.error);
break;
case 'request':
console.log('[BunkerStore] Request:', data.method, 'from:', data.from);
break;
}
};
}
return bunkerWorker;
}
/**
* Configure the bunker worker
*/
export function configureBunkerWorker(config) {
const worker = getBunkerWorker();
worker.postMessage({ type: 'configure', ...config });
}
/**
* Connect the bunker worker
*/
export function connectBunkerWorker() {
const worker = getBunkerWorker();
worker.postMessage({ type: 'connect' });
}
/**
* Disconnect the bunker worker
*/
export function disconnectBunkerWorker() {
const worker = getBunkerWorker();
worker.postMessage({ type: 'disconnect' });
}
/**
* Add a secret to the bunker worker
*/
export function addBunkerSecret(secret) {
const worker = getBunkerWorker();
worker.postMessage({ type: 'addSecret', secret });
}
/**
* Request current bunker status
*/
export function requestBunkerStatus() {
const worker = getBunkerWorker();
worker.postMessage({ type: 'getStatus' });
}
/**
* Reset bunker state
*/
export function resetBunkerState() {
disconnectBunkerWorker();
bunkerServiceActive.set(false);
bunkerConnectedClients.set([]);
}

390
docs/NIP-XX-CASHU-ACCESS-TOKENS.md

@ -1,390 +0,0 @@ @@ -1,390 +0,0 @@
# NIP-XX: Cashu Access Tokens for Relay Authorization
`draft` `optional`
This NIP defines a protocol for relays to issue privacy-preserving access tokens using Cashu blind signatures. Tokens prove relay membership without linking issuance to usage, enabling spam protection while preserving user privacy.
## Motivation
Relays need to control access to prevent spam and abuse. Current approaches (NIP-42, NIP-98) require per-request authentication that links all user activity. Cashu blind signatures allow relays to issue bearer tokens that prove authorization without revealing which specific user is connecting.
This is particularly useful for:
- NIP-46 remote signing (bunker) access control
- Premium relay tiers
- Rate limit bypass for trusted users
- Any service requiring proof of relay membership
## Overview
1. Relay operates as a Cashu mint for its authorized users
2. Users authenticate via NIP-98 to obtain blinded signatures
3. Tokens specify permitted event kinds and expiry
4. Two-token rotation allows seamless renewal before expiry
5. Tokens are bearer credentials passed in HTTP/WebSocket headers
## Token Format
### Token Structure
```json
{
"k": "<keyset_id>",
"s": "<secret_hex>",
"c": "<signature_hex>",
"p": "<pubkey_hex>",
"e": <expiry_unix>,
"kinds": [0, 1, 3, 10002],
"kind_ranges": [[20000, 29999]],
"scope": "relay"
}
```
| Field | Type | Description |
|-------|------|-------------|
| `k` | string | Keyset ID (hex) identifying the signing key |
| `s` | string | 32-byte random secret (hex) |
| `c` | string | 33-byte compressed signature point (hex) |
| `p` | string | 32-byte user pubkey (hex) |
| `e` | number | Unix timestamp when token expires |
| `kinds` | number[] | Explicit list of permitted event kinds |
| `kind_ranges` | number[][] | Ranges of permitted kinds as [min, max] pairs |
| `scope` | string | Token scope: "relay", "nip46", "blossom", or custom |
### Kind Permissions
Tokens specify which event kinds the bearer may publish:
- `kinds`: Explicit list of individual kinds (e.g., `[0, 1, 3]`)
- `kind_ranges`: Inclusive ranges (e.g., `[[20000, 29999]]` for ephemeral events)
A token permits a kind if it appears in `kinds` OR falls within any `kind_ranges` entry.
Special values:
- Empty `kinds` and `kind_ranges`: No write access (read-only token)
- `kinds: [-1]`: All kinds permitted (wildcard)
### Scopes
| Scope | Description |
|-------|-------------|
| `relay` | Standard relay WebSocket access (REQ, EVENT, COUNT) |
| `nip46` | NIP-46 remote signing / bunker access |
| `blossom` | Blossom media server access |
| `api` | HTTP API access |
Custom scopes may be defined by applications.
### Serialization
Tokens are serialized as:
```
cashuA<base64url(json)>
```
The `cashuA` prefix indicates version 1 of this specification.
## Keyset Management
### Keyset Structure
Relays maintain signing keysets that rotate periodically:
```json
{
"id": "<keyset_id>",
"pubkey": "<compressed_pubkey_hex>",
"active": true,
"created_at": 1735300000,
"expires_at": 1736510000
}
```
### Keyset ID Calculation
```
keyset_id = SHA256(compressed_pubkey)[0:7] as hex (14 characters)
```
### Rotation Policy
- **Active period**: 1 week (tokens can be issued)
- **Verification period**: 3 weeks (tokens can still be validated)
- **Total validity**: Active keyset + 2 previous keysets accepted
This ensures tokens issued at the end of an active period remain valid for their full lifetime.
## Two-Token Rotation
Users may hold up to two tokens:
| Token | State | Purpose |
|-------|-------|---------|
| Active | In use | Current authentication credential |
| Pending | Awaiting | Pre-fetched for seamless rotation |
### Rotation Flow
1. User obtains initial token (becomes Active)
2. When Active token reaches 50% lifetime, user requests new token (becomes Pending)
3. When Active token expires, Pending becomes Active
4. User requests new Pending token
5. Repeat
This ensures continuous access without authentication gaps.
### Blacklist Behavior
If a user is removed from the relay's whitelist:
- Active token continues working until expiry (max 1 week)
- Pending token continues working until expiry (max 1 week)
- **Maximum access after blacklist: 2 weeks**
New token requests will fail immediately upon blacklist.
## HTTP Endpoints
### Token Issuance
```http
POST /cashu/mint
Authorization: Nostr <base64_nip98_event>
Content-Type: application/json
{
"blinded_message": "<B_hex>",
"scope": "relay",
"kinds": [0, 1, 3, 7],
"kind_ranges": [[30000, 39999]]
}
```
**Response:**
```json
{
"blinded_signature": "<C_hex>",
"keyset_id": "<keyset_id>",
"expiry": 1736294400,
"pubkey": "<mint_pubkey_hex>"
}
```
The user must:
1. Generate random secret `x` and blinding factor `r`
2. Compute `Y = hash_to_curve(x)`
3. Compute `B_ = Y + r*G`
4. Send `B_` as `blinded_message`
5. Receive `C_` as `blinded_signature`
6. Compute `C = C_ - r*K` (K is mint pubkey)
7. Token is `(x, C)` with metadata
### Keyset Discovery
```http
GET /cashu/keysets
Response:
{
"keysets": [
{
"id": "0a1b2c3d4e5f67",
"pubkey": "02...",
"active": true,
"expires_at": 1736510000
}
]
}
```
### Token Info (Optional)
```http
GET /cashu/info
Response:
{
"name": "Relay Name",
"version": "NIP-XX/1",
"token_ttl": 604800,
"max_kinds": 100,
"supported_scopes": ["relay", "nip46", "blossom"]
}
```
## Authentication Headers
### WebSocket Upgrade
```http
GET / HTTP/1.1
Upgrade: websocket
X-Cashu-Token: cashuA<base64url>
```
### HTTP Requests
```http
GET /api/resource HTTP/1.1
Authorization: Cashu cashuA<base64url>
```
Or as dedicated header:
```http
X-Cashu-Token: cashuA<base64url>
```
### NIP-46 Integration
For NIP-46 bunker connections, the token is passed in the WebSocket upgrade:
```http
GET /nip46 HTTP/1.1
Upgrade: websocket
X-Cashu-Token: cashuA<base64url>
```
The bunker verifies:
1. Token signature is valid
2. Token has not expired
3. Token scope is "nip46"
4. User pubkey in token matches NIP-46 connect pubkey
## Cryptographic Details
### Blind Diffie-Hellman Key Exchange (BDHKE)
Uses secp256k1 curve with Cashu's hash-to-curve:
```
hash_to_curve(message):
msg_hash = SHA256("Secp256k1_HashToCurve_Cashu_" || message)
for counter in 0..65536:
hash = SHA256(msg_hash || counter_le32)
point = try_parse("02" || hash)
if point is valid:
return point
fail
```
**Blinding:**
```
Y = hash_to_curve(secret)
r = random_scalar()
B_ = Y + r*G
```
**Signing (mint):**
```
C_ = k * B_
```
**Unblinding (user):**
```
C = C_ - r*K
```
**Verification (mint):**
```
valid = (C == k * hash_to_curve(secret))
```
## Verification Flow
When relay receives a token:
1. Parse token from header
2. Find keyset by ID (must be active or recently expired)
3. Verify: `C == k * hash_to_curve(secret)`
4. Check: `expiry > now`
5. Check: scope matches service
6. Check: requested kind in `kinds` or `kind_ranges`
7. **Optional**: Re-check user pubkey against current ACL
Step 7 provides "stateless revocation" - tokens become invalid immediately when user is removed from ACL, not just when they expire.
## Security Considerations
### Token as Bearer Credential
Tokens are bearer credentials. Compromise allows impersonation until expiry. Mitigations:
- Short TTL (1 week recommended)
- TLS for all transport
- Secure client storage
### Privacy Properties
- **Unlinkability**: Relay cannot link token issuance to token use
- **No tracking**: Different secrets prevent correlation across tokens
- **Pubkey binding**: Token is bound to user's Nostr pubkey
### Keyset Compromise
If keyset private key is compromised:
- Rotate immediately (new keyset)
- Old keyset enters verification-only mode
- Tokens issued by compromised keyset expire naturally (max 3 weeks)
### Replay Prevention
- Tokens have expiry timestamps
- Optional: Relay tracks used secrets (adds state, breaks unlinkability)
- Scope prevents cross-service replay
## Example Flow
```
1. Alice wants NIP-46 bunker access to relay.example.com
2. Alice authenticates via NIP-98:
POST /cashu/mint
Authorization: Nostr <signed_kind_27235>
{"blinded_message": "02abc...", "scope": "nip46"}
3. Relay checks:
- NIP-98 signature valid
- Alice's pubkey in whitelist with write access
4. Relay responds:
{"blinded_signature": "03def...", "keyset_id": "a1b2c3...", "expiry": 1736294400}
5. Alice unblinds signature, constructs token
6. Alice connects to bunker:
GET /nip46 HTTP/1.1
Upgrade: websocket
X-Cashu-Token: cashuA<token>
7. Bunker verifies token, establishes NIP-46 session
8. One week later, Alice's token approaches expiry
- Alice requests new token (step 2-5)
- New token becomes Pending
- When Active expires, Pending becomes Active
```
## Relay Implementation Notes
### Recommended Defaults
| Parameter | Value | Rationale |
|-----------|-------|-----------|
| Token TTL | 7 days | Balance between convenience and revocation speed |
| Keyset rotation | Weekly | Limits key exposure |
| Verification keysets | 3 | Covers full token lifetime + grace period |
| Re-check ACL | On every use | Enables immediate revocation |
### Error Codes
| Code | Meaning |
|------|---------|
| 401 | Missing or malformed token |
| 403 | Valid token but insufficient permissions (wrong scope/kinds) |
| 410 | Token expired |
| 421 | Unknown keyset ID |
## References
- [Cashu Protocol](https://github.com/cashubtc/nuts)
- [NIP-42: Authentication of clients to relays](https://github.com/nostr-protocol/nips/blob/master/42.md)
- [NIP-46: Nostr Remote Signing](https://github.com/nostr-protocol/nips/blob/master/46.md)
- [NIP-98: HTTP Auth](https://github.com/nostr-protocol/nips/blob/master/98.md)
- [Blind Signatures for Untraceable Payments](http://www.hit.bme.hu/~buttyan/courses/BMEVIHIM219/2009/Chaum.BlindSigForPayworx.1662.pdf)

9
main.go

@ -866,9 +866,6 @@ func printNRCUsage() { @@ -866,9 +866,6 @@ func printNRCUsage() {
fmt.Println(" ORLY_NRC_ENABLED=true")
fmt.Println(" ORLY_NRC_RENDEZVOUS_URL=wss://public-relay.example.com")
fmt.Println(" ORLY_NRC_AUTHORIZED_KEYS=<secret1>:<name1>,<secret2>:<name2>")
fmt.Println("")
fmt.Println("For CAT-based authentication, also set:")
fmt.Println(" ORLY_NRC_USE_CASHU=true")
}
// handleNRCGenerate generates a new NRC connection URI.
@ -908,7 +905,7 @@ func handleNRCGenerate(ctx context.Context, cfg *config.C, args []string) { @@ -908,7 +905,7 @@ func handleNRCGenerate(ctx context.Context, cfg *config.C, args []string) {
}
// Get rendezvous URL from config
nrcEnabled, nrcRendezvousURL, _, _, _ := cfg.GetNRCConfigValues()
nrcEnabled, nrcRendezvousURL, _, _ := cfg.GetNRCConfigValues()
if !nrcEnabled || nrcRendezvousURL == "" {
fmt.Println("Error: NRC is not configured. Set ORLY_NRC_ENABLED=true and ORLY_NRC_RENDEZVOUS_URL")
return
@ -950,7 +947,7 @@ func handleNRCGenerate(ctx context.Context, cfg *config.C, args []string) { @@ -950,7 +947,7 @@ func handleNRCGenerate(ctx context.Context, cfg *config.C, args []string) {
// handleNRCList lists configured authorized secrets from environment.
func handleNRCList(cfg *config.C) {
_, _, authorizedKeys, useCashu, _ := cfg.GetNRCConfigValues()
_, _, authorizedKeys, _ := cfg.GetNRCConfigValues()
fmt.Println("NRC Configuration:")
fmt.Println("")
@ -977,8 +974,6 @@ func handleNRCList(cfg *config.C) { @@ -977,8 +974,6 @@ func handleNRCList(cfg *config.C) {
fmt.Printf(" - %s: %s\n", name, truncated)
}
}
fmt.Println("")
fmt.Printf(" CAT authentication: %v\n", useCashu)
}
// handleNRCRevoke provides instructions for revoking access.

101
pkg/bunker/acl_adapter.go

@ -1,101 +0,0 @@ @@ -1,101 +0,0 @@
// Package bunker implements NIP-46 remote signing with Cashu token authentication.
package bunker
import (
"context"
"next.orly.dev/pkg/acl"
acliface "next.orly.dev/pkg/interfaces/acl"
cashuiface "next.orly.dev/pkg/interfaces/cashu"
"next.orly.dev/pkg/cashu/token"
)
// ACLAuthzChecker adapts ORLY's ACL system to cashu.AuthzChecker.
// This allows the Cashu token system to use the existing ACL for authorization.
type ACLAuthzChecker struct {
// ScopeRequirements maps scopes to required access levels.
// If not set, defaults are used.
ScopeRequirements map[string]string
}
// NewACLAuthzChecker creates a new ACL-based authorization checker.
func NewACLAuthzChecker() *ACLAuthzChecker {
return &ACLAuthzChecker{
ScopeRequirements: map[string]string{
token.ScopeRelay: acliface.Write, // Relay access requires write
token.ScopeNIP46: acliface.Write, // Bunker access requires write
token.ScopeBlossom: acliface.Write, // Blossom access requires write
token.ScopeAPI: acliface.Admin, // API access requires admin
token.ScopeNRC: acliface.Write, // NRC tunnel access requires write
},
}
}
// CheckAuthorization checks if a pubkey is authorized for a scope.
func (a *ACLAuthzChecker) CheckAuthorization(ctx context.Context, pubkey []byte, scope string, remoteAddr string) error {
// Get access level from ACL registry
level := acl.Registry.GetAccessLevel(pubkey, remoteAddr)
// Check against required level for scope
requiredLevel, ok := a.ScopeRequirements[scope]
if !ok {
// Default to write access for unknown scopes
requiredLevel = acliface.Write
}
if !hasAccessLevel(level, requiredLevel) {
return cashuiface.NewAuthzError(
cashuiface.ErrCodeInsufficientAccess,
"insufficient access level for scope "+scope,
)
}
// Check for banned/blocked status
if level == "banned" {
return cashuiface.ErrBanned
}
if level == "blocked" {
return cashuiface.ErrBlocked
}
return nil
}
// ReauthorizationEnabled returns true - we always re-check ACL on each verification.
func (a *ACLAuthzChecker) ReauthorizationEnabled() bool {
return true
}
// hasAccessLevel checks if the actual level meets or exceeds the required level.
func hasAccessLevel(actual, required string) bool {
levels := map[string]int{
acliface.None: 0,
"banned": 0,
"blocked": 0,
acliface.Read: 1,
acliface.Write: 2,
acliface.Admin: 3,
acliface.Owner: 4,
}
actualLevel, aok := levels[actual]
requiredLevel, rok := levels[required]
if !aok || !rok {
return false
}
return actualLevel >= requiredLevel
}
// SetScopeRequirement sets the required access level for a scope.
func (a *ACLAuthzChecker) SetScopeRequirement(scope, level string) {
if a.ScopeRequirements == nil {
a.ScopeRequirements = make(map[string]string)
}
a.ScopeRequirements[scope] = level
}
// Ensure ACLAuthzChecker implements both interfaces.
var _ cashuiface.AuthzChecker = (*ACLAuthzChecker)(nil)
var _ cashuiface.ReauthorizationChecker = (*ACLAuthzChecker)(nil)

293
pkg/cashu/bdhke/bdhke.go

@ -1,293 +0,0 @@ @@ -1,293 +0,0 @@
// Package bdhke implements Blind Diffie-Hellman Key Exchange for Cashu-style tokens.
// This is the core cryptographic primitive used in ecash blind signatures.
//
// The protocol allows a mint (issuer) to sign a message without knowing what
// it's signing, providing unlinkability between token issuance and redemption.
//
// Protocol overview:
// 1. User creates secret x, computes Y = HashToCurve(x)
// 2. User blinds: B_ = Y + r*G (r is random blinding factor)
// 3. Mint signs: C_ = k*B_ (k is mint's private key)
// 4. User unblinds: C = C_ - r*K (K is mint's public key)
// 5. Token is (x, C) - mint can verify: C == k*HashToCurve(x)
//
// Reference: https://github.com/cashubtc/nuts/blob/main/00.md
package bdhke
import (
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
)
// DomainSeparator is prepended to messages before hashing to prevent
// cross-protocol attacks.
const DomainSeparator = "Secp256k1_HashToCurve_Cashu_"
// Errors
var (
ErrHashToCurveFailed = errors.New("bdhke: hash to curve failed after max iterations")
ErrInvalidPoint = errors.New("bdhke: invalid curve point")
ErrInvalidPrivateKey = errors.New("bdhke: invalid private key")
ErrSignatureMismatch = errors.New("bdhke: signature verification failed")
)
// HashToCurve deterministically maps a message to a point on secp256k1.
// Uses the try-and-increment method as specified in Cashu NUT-00.
//
// Algorithm:
// 1. Compute msg_hash = SHA256(domain_separator || message)
// 2. For counter in 0..65536:
// a. Compute hash = SHA256(msg_hash || counter)
// b. Try to parse 02 || hash as compressed point
// c. If valid point, return it
// 3. Fail if no valid point found (extremely unlikely)
func HashToCurve(message []byte) (*secp256k1.PublicKey, error) {
// Hash the message with domain separator
msgHash := sha256.Sum256(append([]byte(DomainSeparator), message...))
// Try up to 65536 iterations (in practice, ~50% chance on first try)
counterBytes := make([]byte, 4)
for counter := uint32(0); counter < 65536; counter++ {
binary.LittleEndian.PutUint32(counterBytes, counter)
// Hash again with counter
toHash := append(msgHash[:], counterBytes...)
hash := sha256.Sum256(toHash)
// Try to parse as compressed point with 02 prefix (even y)
compressed := make([]byte, 33)
compressed[0] = 0x02
copy(compressed[1:], hash[:])
pk, err := secp256k1.ParsePubKey(compressed)
if err == nil {
return pk, nil
}
}
return nil, ErrHashToCurveFailed
}
// BlindResult contains the blinding operation result.
type BlindResult struct {
B *secp256k1.PublicKey // Blinded message B_ = Y + r*G
R *secp256k1.PrivateKey // Blinding factor (keep secret until unblinding)
Y *secp256k1.PublicKey // Original point Y = HashToCurve(secret)
}
// Blind creates a blinded message from a secret.
// The blinding factor r is generated randomly and must be kept secret
// until the signature is received and needs to be unblinded.
//
// B_ = Y + r*G where:
// - Y = HashToCurve(secret)
// - r = random scalar
// - G = generator point
func Blind(secret []byte) (*BlindResult, error) {
// Compute Y = HashToCurve(secret)
Y, err := HashToCurve(secret)
if err != nil {
return nil, fmt.Errorf("blind: %w", err)
}
// Generate random blinding factor r
rBytes := make([]byte, 32)
if _, err := rand.Read(rBytes); err != nil {
return nil, fmt.Errorf("blind: failed to generate random: %w", err)
}
r := secp256k1.PrivKeyFromBytes(rBytes)
// Compute r*G (blinding factor times generator)
rG := new(secp256k1.JacobianPoint)
secp256k1.ScalarBaseMultNonConst(&r.Key, rG)
// Convert Y to Jacobian
yJ := new(secp256k1.JacobianPoint)
Y.AsJacobian(yJ)
// Compute B_ = Y + r*G
bJ := new(secp256k1.JacobianPoint)
secp256k1.AddNonConst(yJ, rG, bJ)
bJ.ToAffine()
// Convert back to PublicKey
B := secp256k1.NewPublicKey(&bJ.X, &bJ.Y)
return &BlindResult{
B: B,
R: r,
Y: Y,
}, nil
}
// BlindWithFactor creates a blinded message using a provided blinding factor.
// This is useful for testing or when the blinding factor needs to be deterministic.
func BlindWithFactor(secret []byte, rBytes []byte) (*BlindResult, error) {
if len(rBytes) != 32 {
return nil, errors.New("blind: blinding factor must be 32 bytes")
}
// Compute Y = HashToCurve(secret)
Y, err := HashToCurve(secret)
if err != nil {
return nil, fmt.Errorf("blind: %w", err)
}
r := secp256k1.PrivKeyFromBytes(rBytes)
// Compute r*G
rG := new(secp256k1.JacobianPoint)
secp256k1.ScalarBaseMultNonConst(&r.Key, rG)
// Convert Y to Jacobian
yJ := new(secp256k1.JacobianPoint)
Y.AsJacobian(yJ)
// Compute B_ = Y + r*G
bJ := new(secp256k1.JacobianPoint)
secp256k1.AddNonConst(yJ, rG, bJ)
bJ.ToAffine()
B := secp256k1.NewPublicKey(&bJ.X, &bJ.Y)
return &BlindResult{
B: B,
R: r,
Y: Y,
}, nil
}
// Sign creates a blinded signature on a blinded message.
// This is performed by the mint using its private key k.
//
// C_ = k * B_ where:
// - k = mint's private key scalar
// - B_ = blinded message from user
func Sign(B *secp256k1.PublicKey, k *secp256k1.PrivateKey) (*secp256k1.PublicKey, error) {
if B == nil || k == nil {
return nil, ErrInvalidPoint
}
// Convert B to Jacobian
bJ := new(secp256k1.JacobianPoint)
B.AsJacobian(bJ)
// Compute C_ = k * B_
cJ := new(secp256k1.JacobianPoint)
secp256k1.ScalarMultNonConst(&k.Key, bJ, cJ)
cJ.ToAffine()
C := secp256k1.NewPublicKey(&cJ.X, &cJ.Y)
return C, nil
}
// Unblind removes the blinding factor from the signature.
// This is performed by the user after receiving the blinded signature.
//
// C = C_ - r*K where:
// - C_ = blinded signature from mint
// - r = original blinding factor
// - K = mint's public key
func Unblind(C_ *secp256k1.PublicKey, r *secp256k1.PrivateKey, K *secp256k1.PublicKey) (*secp256k1.PublicKey, error) {
if C_ == nil || r == nil || K == nil {
return nil, ErrInvalidPoint
}
// Compute r*K
kJ := new(secp256k1.JacobianPoint)
K.AsJacobian(kJ)
rK := new(secp256k1.JacobianPoint)
secp256k1.ScalarMultNonConst(&r.Key, kJ, rK)
// Negate r*K to get -r*K
rK.Y.Negate(1)
rK.Y.Normalize()
// Convert C_ to Jacobian
c_J := new(secp256k1.JacobianPoint)
C_.AsJacobian(c_J)
// Compute C = C_ + (-r*K) = C_ - r*K
cJ := new(secp256k1.JacobianPoint)
secp256k1.AddNonConst(c_J, rK, cJ)
cJ.ToAffine()
C := secp256k1.NewPublicKey(&cJ.X, &cJ.Y)
return C, nil
}
// Verify checks that a token's signature is valid.
// The mint uses this to verify tokens during redemption.
//
// Checks: C == k * HashToCurve(secret) where:
// - C = unblinded signature from token
// - k = mint's private key
// - secret = token's secret value
func Verify(secret []byte, C *secp256k1.PublicKey, k *secp256k1.PrivateKey) (bool, error) {
if C == nil || k == nil {
return false, ErrInvalidPoint
}
// Compute Y = HashToCurve(secret)
Y, err := HashToCurve(secret)
if err != nil {
return false, err
}
// Compute expected = k * Y
yJ := new(secp256k1.JacobianPoint)
Y.AsJacobian(yJ)
expectedJ := new(secp256k1.JacobianPoint)
secp256k1.ScalarMultNonConst(&k.Key, yJ, expectedJ)
expectedJ.ToAffine()
expected := secp256k1.NewPublicKey(&expectedJ.X, &expectedJ.Y)
// Compare C with expected
return C.IsEqual(expected), nil
}
// VerifyWithPublicKey verifies a token without knowing the private key.
// This requires a DLEQ proof (not yet implemented).
// For now, returns error indicating this is not supported.
func VerifyWithPublicKey(secret []byte, C *secp256k1.PublicKey, K *secp256k1.PublicKey) (bool, error) {
return false, errors.New("bdhke: DLEQ proof verification not implemented")
}
// GenerateKeypair generates a new mint keypair.
func GenerateKeypair() (*secp256k1.PrivateKey, *secp256k1.PublicKey, error) {
keyBytes := make([]byte, 32)
if _, err := rand.Read(keyBytes); err != nil {
return nil, nil, fmt.Errorf("generate keypair: %w", err)
}
privKey := secp256k1.PrivKeyFromBytes(keyBytes)
pubKey := privKey.PubKey()
return privKey, pubKey, nil
}
// SecretFromBytes creates a secret suitable for token issuance.
// The secret should be 32 bytes of random data.
func SecretFromBytes(data []byte) []byte {
// Just return a copy - secrets are arbitrary byte strings
secret := make([]byte, len(data))
copy(secret, data)
return secret
}
// GenerateSecret creates a new random 32-byte secret.
func GenerateSecret() ([]byte, error) {
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return nil, fmt.Errorf("generate secret: %w", err)
}
return secret, nil
}

348
pkg/cashu/bdhke/bdhke_test.go

@ -1,348 +0,0 @@ @@ -1,348 +0,0 @@
package bdhke
import (
"bytes"
"encoding/hex"
"testing"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
)
// Test vectors from Cashu NUT-00 specification
// https://github.com/cashubtc/nuts/blob/main/00.md
func TestHashToCurve(t *testing.T) {
tests := []struct {
name string
message string
expected string // Expected compressed public key in hex
}{
{
name: "test vector 1",
message: "0000000000000000000000000000000000000000000000000000000000000000",
expected: "024cce997d3b518f739663b757deaec95bcd9473c30a14ac2fd04023a739d1a725",
},
{
name: "test vector 2",
message: "0000000000000000000000000000000000000000000000000000000000000001",
expected: "022e7158e11c9506f1aa4248bf531298daa7febd6194f003edcd9b93ade6253acf",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msgBytes, err := hex.DecodeString(tt.message)
if err != nil {
t.Fatalf("failed to decode message: %v", err)
}
point, err := HashToCurve(msgBytes)
if err != nil {
t.Fatalf("HashToCurve failed: %v", err)
}
got := hex.EncodeToString(point.SerializeCompressed())
if got != tt.expected {
t.Errorf("HashToCurve(%s) = %s, want %s", tt.message, got, tt.expected)
}
})
}
}
func TestBlindSignUnblindVerify(t *testing.T) {
// Generate mint keypair
k, K, err := GenerateKeypair()
if err != nil {
t.Fatalf("failed to generate keypair: %v", err)
}
// Generate a secret
secret, err := GenerateSecret()
if err != nil {
t.Fatalf("failed to generate secret: %v", err)
}
// User blinds the secret
blindResult, err := Blind(secret)
if err != nil {
t.Fatalf("Blind failed: %v", err)
}
// Mint signs the blinded message
C_, err := Sign(blindResult.B, k)
if err != nil {
t.Fatalf("Sign failed: %v", err)
}
// User unblinds the signature
C, err := Unblind(C_, blindResult.R, K)
if err != nil {
t.Fatalf("Unblind failed: %v", err)
}
// Verify the token
valid, err := Verify(secret, C, k)
if err != nil {
t.Fatalf("Verify failed: %v", err)
}
if !valid {
t.Error("Verify returned false, expected true")
}
}
func TestVerifyWrongSecret(t *testing.T) {
k, K, _ := GenerateKeypair()
secret1, _ := GenerateSecret()
secret2, _ := GenerateSecret()
// Create token with secret1
blindResult, _ := Blind(secret1)
C_, _ := Sign(blindResult.B, k)
C, _ := Unblind(C_, blindResult.R, K)
// Try to verify with secret2
valid, err := Verify(secret2, C, k)
if err != nil {
t.Fatalf("Verify failed: %v", err)
}
if valid {
t.Error("Verify returned true for wrong secret")
}
}
func TestVerifyWrongKey(t *testing.T) {
k1, K1, _ := GenerateKeypair()
k2, _, _ := GenerateKeypair()
secret, _ := GenerateSecret()
// Create token with k1
blindResult, _ := Blind(secret)
C_, _ := Sign(blindResult.B, k1)
C, _ := Unblind(C_, blindResult.R, K1)
// Try to verify with k2
valid, err := Verify(secret, C, k2)
if err != nil {
t.Fatalf("Verify failed: %v", err)
}
if valid {
t.Error("Verify returned true for wrong key")
}
}
func TestBlindWithFactor(t *testing.T) {
k, K, _ := GenerateKeypair()
secret := []byte("test secret message")
// Use deterministic blinding factor
rBytes := make([]byte, 32)
for i := range rBytes {
rBytes[i] = byte(i)
}
blindResult, err := BlindWithFactor(secret, rBytes)
if err != nil {
t.Fatalf("BlindWithFactor failed: %v", err)
}
// Complete the protocol
C_, _ := Sign(blindResult.B, k)
C, _ := Unblind(C_, blindResult.R, K)
valid, _ := Verify(secret, C, k)
if !valid {
t.Error("BlindWithFactor: verification failed")
}
// Do it again with same factor - should get same B
blindResult2, _ := BlindWithFactor(secret, rBytes)
if !bytes.Equal(blindResult.B.SerializeCompressed(), blindResult2.B.SerializeCompressed()) {
t.Error("BlindWithFactor not deterministic")
}
}
func TestHashToCurveDeterministic(t *testing.T) {
message := []byte("deterministic test")
p1, err := HashToCurve(message)
if err != nil {
t.Fatalf("HashToCurve failed: %v", err)
}
p2, err := HashToCurve(message)
if err != nil {
t.Fatalf("HashToCurve failed: %v", err)
}
if !p1.IsEqual(p2) {
t.Error("HashToCurve not deterministic")
}
}
func TestSignNilInputs(t *testing.T) {
k, _, _ := GenerateKeypair()
_, err := Sign(nil, k)
if err == nil {
t.Error("Sign(nil, k) should error")
}
B, _ := HashToCurve([]byte("test"))
_, err = Sign(B, nil)
if err == nil {
t.Error("Sign(B, nil) should error")
}
}
func TestUnblindNilInputs(t *testing.T) {
k, K, _ := GenerateKeypair()
secret, _ := GenerateSecret()
blindResult, _ := Blind(secret)
C_, _ := Sign(blindResult.B, k)
_, err := Unblind(nil, blindResult.R, K)
if err == nil {
t.Error("Unblind(nil, r, K) should error")
}
_, err = Unblind(C_, nil, K)
if err == nil {
t.Error("Unblind(C_, nil, K) should error")
}
_, err = Unblind(C_, blindResult.R, nil)
if err == nil {
t.Error("Unblind(C_, r, nil) should error")
}
}
func TestVerifyNilInputs(t *testing.T) {
k, K, _ := GenerateKeypair()
secret, _ := GenerateSecret()
blindResult, _ := Blind(secret)
C_, _ := Sign(blindResult.B, k)
C, _ := Unblind(C_, blindResult.R, K)
_, err := Verify(secret, nil, k)
if err == nil {
t.Error("Verify(secret, nil, k) should error")
}
_, err = Verify(secret, C, nil)
if err == nil {
t.Error("Verify(secret, C, nil) should error")
}
}
// Benchmark functions
func BenchmarkHashToCurve(b *testing.B) {
secret, _ := GenerateSecret()
b.ResetTimer()
for i := 0; i < b.N; i++ {
HashToCurve(secret)
}
}
func BenchmarkBlind(b *testing.B) {
secret, _ := GenerateSecret()
b.ResetTimer()
for i := 0; i < b.N; i++ {
Blind(secret)
}
}
func BenchmarkSign(b *testing.B) {
k, _, _ := GenerateKeypair()
secret, _ := GenerateSecret()
blindResult, _ := Blind(secret)
b.ResetTimer()
for i := 0; i < b.N; i++ {
Sign(blindResult.B, k)
}
}
func BenchmarkUnblind(b *testing.B) {
k, K, _ := GenerateKeypair()
secret, _ := GenerateSecret()
blindResult, _ := Blind(secret)
C_, _ := Sign(blindResult.B, k)
b.ResetTimer()
for i := 0; i < b.N; i++ {
Unblind(C_, blindResult.R, K)
}
}
func BenchmarkVerify(b *testing.B) {
k, K, _ := GenerateKeypair()
secret, _ := GenerateSecret()
blindResult, _ := Blind(secret)
C_, _ := Sign(blindResult.B, k)
C, _ := Unblind(C_, blindResult.R, K)
b.ResetTimer()
for i := 0; i < b.N; i++ {
Verify(secret, C, k)
}
}
func BenchmarkFullProtocol(b *testing.B) {
k, K, _ := GenerateKeypair()
b.ResetTimer()
for i := 0; i < b.N; i++ {
secret, _ := GenerateSecret()
blindResult, _ := Blind(secret)
C_, _ := Sign(blindResult.B, k)
C, _ := Unblind(C_, blindResult.R, K)
Verify(secret, C, k)
}
}
// Test that serialization/deserialization works correctly
func TestPointSerialization(t *testing.T) {
k, K, _ := GenerateKeypair()
secret, _ := GenerateSecret()
blindResult, _ := Blind(secret)
C_, _ := Sign(blindResult.B, k)
C, _ := Unblind(C_, blindResult.R, K)
// Serialize and deserialize C
serialized := C.SerializeCompressed()
deserialized, err := secp256k1.ParsePubKey(serialized)
if err != nil {
t.Fatalf("failed to parse serialized point: %v", err)
}
// Verify with deserialized point
valid, err := Verify(secret, deserialized, k)
if err != nil {
t.Fatalf("Verify failed: %v", err)
}
if !valid {
t.Error("Verify failed after point serialization round-trip")
}
// Same for K
kSerialized := K.SerializeCompressed()
kDeserialized, err := secp256k1.ParsePubKey(kSerialized)
if err != nil {
t.Fatalf("failed to parse serialized K: %v", err)
}
// Unblind with deserialized K
C2, err := Unblind(C_, blindResult.R, kDeserialized)
if err != nil {
t.Fatalf("Unblind with deserialized K failed: %v", err)
}
if !C.IsEqual(C2) {
t.Error("Unblind result differs after K round-trip")
}
}

295
pkg/cashu/issuer/issuer.go

@ -1,295 +0,0 @@ @@ -1,295 +0,0 @@
// Package issuer implements Cashu token issuance with authorization checks.
package issuer
import (
"context"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"next.orly.dev/pkg/cashu/bdhke"
"next.orly.dev/pkg/cashu/keyset"
"next.orly.dev/pkg/cashu/token"
cashuiface "next.orly.dev/pkg/interfaces/cashu"
)
// Errors.
var (
ErrNoActiveKeyset = errors.New("issuer: no active keyset available")
ErrInvalidBlindedMsg = errors.New("issuer: invalid blinded message")
ErrInvalidPubkey = errors.New("issuer: invalid pubkey")
ErrInvalidScope = errors.New("issuer: invalid scope")
)
// Config holds issuer configuration.
type Config struct {
// DefaultTTL is the default token lifetime.
DefaultTTL time.Duration
// MaxTTL is the maximum allowed token lifetime.
MaxTTL time.Duration
// AllowedScopes is the list of scopes this issuer can issue tokens for.
// Empty means all scopes are allowed.
AllowedScopes []string
// MaxKinds is the maximum number of explicit kinds in a token.
// 0 means unlimited.
MaxKinds int
// MaxKindRanges is the maximum number of kind ranges in a token.
// 0 means unlimited.
MaxKindRanges int
}
// DefaultConfig returns sensible default configuration.
func DefaultConfig() Config {
return Config{
DefaultTTL: 7 * 24 * time.Hour, // 1 week
MaxTTL: 7 * 24 * time.Hour, // 1 week
MaxKinds: 100,
MaxKindRanges: 10,
}
}
// Issuer handles token issuance with authorization checks.
type Issuer struct {
keysets *keyset.Manager
authz cashuiface.AuthzChecker
config Config
}
// New creates a new issuer.
func New(keysets *keyset.Manager, authz cashuiface.AuthzChecker, config Config) *Issuer {
return &Issuer{
keysets: keysets,
authz: authz,
config: config,
}
}
// IssueRequest contains the request parameters for token issuance.
type IssueRequest struct {
// BlindedMessage is the blinded point B_ (33 bytes compressed).
BlindedMessage []byte
// Pubkey is the user's Nostr pubkey (32 bytes).
Pubkey []byte
// Scope is the requested token scope.
Scope string
// Kinds is the list of permitted event kinds.
Kinds []int
// KindRanges is the list of permitted kind ranges.
KindRanges [][]int
// TTL is the requested token lifetime (optional, uses default if zero).
TTL time.Duration
}
// IssueResponse contains the response from token issuance.
type IssueResponse struct {
// BlindedSignature is the blinded signature C_ (33 bytes compressed).
BlindedSignature []byte
// KeysetID is the ID of the keyset used for signing.
KeysetID string
// Expiry is the token expiration timestamp.
Expiry int64
// MintPubkey is the public key of the keyset (for unblinding).
MintPubkey []byte
}
// Issue creates a blinded signature after authorization check.
func (i *Issuer) Issue(ctx context.Context, req *IssueRequest, remoteAddr string) (*IssueResponse, error) {
// Validate request
if err := i.validateRequest(req); err != nil {
return nil, err
}
// Check authorization
if err := i.authz.CheckAuthorization(ctx, req.Pubkey, req.Scope, remoteAddr); err != nil {
return nil, fmt.Errorf("issuer: authorization failed: %w", err)
}
// Get active keyset
ks := i.keysets.GetSigningKeyset()
if ks == nil || !ks.IsActiveForSigning() {
return nil, ErrNoActiveKeyset
}
// Parse blinded message
B_, err := secp256k1.ParsePubKey(req.BlindedMessage)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidBlindedMsg, err)
}
// Sign the blinded message
C_, err := bdhke.Sign(B_, ks.PrivateKey)
if err != nil {
return nil, fmt.Errorf("issuer: signing failed: %w", err)
}
// Calculate expiry
ttl := req.TTL
if ttl <= 0 {
ttl = i.config.DefaultTTL
}
if ttl > i.config.MaxTTL {
ttl = i.config.MaxTTL
}
expiry := time.Now().Add(ttl).Unix()
return &IssueResponse{
BlindedSignature: C_.SerializeCompressed(),
KeysetID: ks.ID,
Expiry: expiry,
MintPubkey: ks.SerializePublicKey(),
}, nil
}
// validateRequest validates the issue request.
func (i *Issuer) validateRequest(req *IssueRequest) error {
// Validate blinded message
if len(req.BlindedMessage) != 33 {
return fmt.Errorf("%w: expected 33 bytes, got %d", ErrInvalidBlindedMsg, len(req.BlindedMessage))
}
// Validate pubkey
if len(req.Pubkey) != 32 {
return fmt.Errorf("%w: expected 32 bytes, got %d", ErrInvalidPubkey, len(req.Pubkey))
}
// Validate scope
if req.Scope == "" {
return ErrInvalidScope
}
if len(i.config.AllowedScopes) > 0 {
allowed := false
for _, s := range i.config.AllowedScopes {
if s == req.Scope {
allowed = true
break
}
}
if !allowed {
return fmt.Errorf("%w: %s not in allowed scopes", ErrInvalidScope, req.Scope)
}
}
// Validate kinds count
if i.config.MaxKinds > 0 && len(req.Kinds) > i.config.MaxKinds {
return fmt.Errorf("issuer: too many kinds: %d > %d", len(req.Kinds), i.config.MaxKinds)
}
// Validate kind ranges count
if i.config.MaxKindRanges > 0 && len(req.KindRanges) > i.config.MaxKindRanges {
return fmt.Errorf("issuer: too many kind ranges: %d > %d", len(req.KindRanges), i.config.MaxKindRanges)
}
// Validate kind ranges format
for idx, r := range req.KindRanges {
if len(r) != 2 {
return fmt.Errorf("issuer: kind range %d must have 2 elements", idx)
}
if r[0] > r[1] {
return fmt.Errorf("issuer: kind range %d min > max: %d > %d", idx, r[0], r[1])
}
}
return nil
}
// GetKeysetInfo returns public information about available keysets.
func (i *Issuer) GetKeysetInfo() []keyset.KeysetInfo {
return i.keysets.ListKeysetInfo()
}
// GetActiveKeysetID returns the ID of the currently active keyset.
func (i *Issuer) GetActiveKeysetID() string {
ks := i.keysets.GetSigningKeyset()
if ks == nil {
return ""
}
return ks.ID
}
// MintInfo contains public information about the mint.
type MintInfo struct {
Name string `json:"name,omitempty"`
Version string `json:"version"`
Pubkey string `json:"pubkey"`
TokenTTL int64 `json:"token_ttl"`
MaxKinds int `json:"max_kinds,omitempty"`
MaxKindRanges int `json:"max_kind_ranges,omitempty"`
SupportedScopes []string `json:"supported_scopes,omitempty"`
}
// GetMintInfo returns public information about the issuer.
func (i *Issuer) GetMintInfo(name string) MintInfo {
var pubkeyHex string
if ks := i.keysets.GetSigningKeyset(); ks != nil {
pubkeyHex = hex.EncodeToString(ks.SerializePublicKey())
}
return MintInfo{
Name: name,
Version: "NIP-XX/1",
Pubkey: pubkeyHex,
TokenTTL: int64(i.config.DefaultTTL.Seconds()),
MaxKinds: i.config.MaxKinds,
MaxKindRanges: i.config.MaxKindRanges,
SupportedScopes: i.config.AllowedScopes,
}
}
// BuildToken is a helper that creates a complete token from the issue response
// and the user's secret and blinding factor.
// This is typically done client-side, but provided for testing and CLI tools.
func BuildToken(
resp *IssueResponse,
secret []byte,
blindingFactor *secp256k1.PrivateKey,
pubkey []byte,
scope string,
kinds []int,
kindRanges [][]int,
) (*token.Token, error) {
// Parse mint pubkey
mintPubkey, err := secp256k1.ParsePubKey(resp.MintPubkey)
if err != nil {
return nil, fmt.Errorf("invalid mint pubkey: %w", err)
}
// Parse blinded signature
C_, err := secp256k1.ParsePubKey(resp.BlindedSignature)
if err != nil {
return nil, fmt.Errorf("invalid blinded signature: %w", err)
}
// Unblind the signature
C, err := bdhke.Unblind(C_, blindingFactor, mintPubkey)
if err != nil {
return nil, fmt.Errorf("unblind failed: %w", err)
}
// Create token
tok := token.New(
resp.KeysetID,
secret,
C.SerializeCompressed(),
pubkey,
time.Unix(resp.Expiry, 0),
scope,
)
tok.SetKinds(kinds...)
tok.KindRanges = kindRanges
return tok, nil
}

296
pkg/cashu/issuer/issuer_test.go

@ -1,296 +0,0 @@ @@ -1,296 +0,0 @@
package issuer
import (
"context"
"testing"
"time"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"next.orly.dev/pkg/cashu/bdhke"
"next.orly.dev/pkg/cashu/keyset"
"next.orly.dev/pkg/cashu/token"
cashuiface "next.orly.dev/pkg/interfaces/cashu"
)
func setupIssuer(authz cashuiface.AuthzChecker) (*Issuer, *keyset.Manager) {
store := keyset.NewMemoryStore()
manager := keyset.NewManager(store, keyset.DefaultActiveWindow, keyset.DefaultVerifyWindow)
manager.Init()
config := DefaultConfig()
issuer := New(manager, authz, config)
return issuer, manager
}
func TestIssueSuccess(t *testing.T) {
issuer, _ := setupIssuer(cashuiface.AllowAllChecker{})
// Generate user keypair
secret, err := bdhke.GenerateSecret()
if err != nil {
t.Fatalf("GenerateSecret failed: %v", err)
}
// Generate blinded message
blindResult, err := bdhke.Blind(secret)
if err != nil {
t.Fatalf("Blind failed: %v", err)
}
// User pubkey
pubkey := make([]byte, 32)
for i := range pubkey {
pubkey[i] = byte(i)
}
req := &IssueRequest{
BlindedMessage: blindResult.B.SerializeCompressed(),
Pubkey: pubkey,
Scope: token.ScopeRelay,
Kinds: []int{0, 1, 3},
KindRanges: [][]int{{30000, 39999}},
}
resp, err := issuer.Issue(context.Background(), req, "127.0.0.1")
if err != nil {
t.Fatalf("Issue failed: %v", err)
}
// Check response
if len(resp.BlindedSignature) != 33 {
t.Errorf("BlindedSignature length = %d, want 33", len(resp.BlindedSignature))
}
if resp.KeysetID == "" {
t.Error("KeysetID is empty")
}
if resp.Expiry <= time.Now().Unix() {
t.Error("Expiry should be in the future")
}
if len(resp.MintPubkey) != 33 {
t.Errorf("MintPubkey length = %d, want 33", len(resp.MintPubkey))
}
}
func TestIssueAuthorizationDenied(t *testing.T) {
issuer, _ := setupIssuer(cashuiface.DenyAllChecker{})
secret, _ := bdhke.GenerateSecret()
blindResult, _ := bdhke.Blind(secret)
pubkey := make([]byte, 32)
req := &IssueRequest{
BlindedMessage: blindResult.B.SerializeCompressed(),
Pubkey: pubkey,
Scope: token.ScopeRelay,
}
_, err := issuer.Issue(context.Background(), req, "127.0.0.1")
if err == nil {
t.Error("Issue should fail when authorization is denied")
}
}
func TestIssueInvalidBlindedMessage(t *testing.T) {
issuer, _ := setupIssuer(cashuiface.AllowAllChecker{})
pubkey := make([]byte, 32)
req := &IssueRequest{
BlindedMessage: []byte{1, 2, 3}, // Invalid
Pubkey: pubkey,
Scope: token.ScopeRelay,
}
_, err := issuer.Issue(context.Background(), req, "127.0.0.1")
if err == nil {
t.Error("Issue should fail with invalid blinded message")
}
}
func TestIssueInvalidPubkey(t *testing.T) {
issuer, _ := setupIssuer(cashuiface.AllowAllChecker{})
secret, _ := bdhke.GenerateSecret()
blindResult, _ := bdhke.Blind(secret)
req := &IssueRequest{
BlindedMessage: blindResult.B.SerializeCompressed(),
Pubkey: []byte{1, 2, 3}, // Invalid length
Scope: token.ScopeRelay,
}
_, err := issuer.Issue(context.Background(), req, "127.0.0.1")
if err == nil {
t.Error("Issue should fail with invalid pubkey")
}
}
func TestIssueInvalidScope(t *testing.T) {
store := keyset.NewMemoryStore()
manager := keyset.NewManager(store, keyset.DefaultActiveWindow, keyset.DefaultVerifyWindow)
manager.Init()
config := DefaultConfig()
config.AllowedScopes = []string{token.ScopeRelay} // Only relay scope allowed
issuer := New(manager, cashuiface.AllowAllChecker{}, config)
secret, _ := bdhke.GenerateSecret()
blindResult, _ := bdhke.Blind(secret)
pubkey := make([]byte, 32)
req := &IssueRequest{
BlindedMessage: blindResult.B.SerializeCompressed(),
Pubkey: pubkey,
Scope: token.ScopeNIP46, // Not allowed
}
_, err := issuer.Issue(context.Background(), req, "127.0.0.1")
if err == nil {
t.Error("Issue should fail with disallowed scope")
}
}
func TestIssueTTL(t *testing.T) {
issuer, _ := setupIssuer(cashuiface.AllowAllChecker{})
secret, _ := bdhke.GenerateSecret()
blindResult, _ := bdhke.Blind(secret)
pubkey := make([]byte, 32)
// Request with custom TTL
req := &IssueRequest{
BlindedMessage: blindResult.B.SerializeCompressed(),
Pubkey: pubkey,
Scope: token.ScopeRelay,
TTL: time.Hour,
}
resp, err := issuer.Issue(context.Background(), req, "127.0.0.1")
if err != nil {
t.Fatalf("Issue failed: %v", err)
}
// Expiry should be ~1 hour from now
expectedExpiry := time.Now().Add(time.Hour).Unix()
if resp.Expiry < expectedExpiry-60 || resp.Expiry > expectedExpiry+60 {
t.Errorf("Expiry %d not within expected range of %d", resp.Expiry, expectedExpiry)
}
}
func TestBuildToken(t *testing.T) {
issuer, manager := setupIssuer(cashuiface.AllowAllChecker{})
// Generate secret and blind it
secret, _ := bdhke.GenerateSecret()
blindResult, _ := bdhke.Blind(secret)
pubkey := make([]byte, 32)
for i := range pubkey {
pubkey[i] = byte(i)
}
// Issue token
req := &IssueRequest{
BlindedMessage: blindResult.B.SerializeCompressed(),
Pubkey: pubkey,
Scope: token.ScopeRelay,
Kinds: []int{1, 2, 3},
}
resp, err := issuer.Issue(context.Background(), req, "127.0.0.1")
if err != nil {
t.Fatalf("Issue failed: %v", err)
}
// Build complete token
tok, err := BuildToken(resp, secret, blindResult.R, pubkey, token.ScopeRelay, []int{1, 2, 3}, nil)
if err != nil {
t.Fatalf("BuildToken failed: %v", err)
}
// Verify token structure
if tok.KeysetID != resp.KeysetID {
t.Errorf("KeysetID mismatch: %s != %s", tok.KeysetID, resp.KeysetID)
}
if tok.Scope != token.ScopeRelay {
t.Errorf("Scope = %s, want %s", tok.Scope, token.ScopeRelay)
}
// Verify signature (using the keyset)
ks := manager.FindByID(tok.KeysetID)
if ks == nil {
t.Fatal("Keyset not found")
}
valid, err := bdhke.Verify(tok.Secret, mustParsePoint(tok.Signature), ks.PrivateKey)
if err != nil {
t.Fatalf("Verify failed: %v", err)
}
if !valid {
t.Error("Token signature is not valid")
}
}
func TestGetKeysetInfo(t *testing.T) {
issuer, _ := setupIssuer(cashuiface.AllowAllChecker{})
infos := issuer.GetKeysetInfo()
if len(infos) == 0 {
t.Error("GetKeysetInfo returned empty")
}
for _, info := range infos {
if info.ID == "" {
t.Error("KeysetInfo has empty ID")
}
if info.PublicKey == "" {
t.Error("KeysetInfo has empty PublicKey")
}
}
}
func TestGetActiveKeysetID(t *testing.T) {
issuer, _ := setupIssuer(cashuiface.AllowAllChecker{})
id := issuer.GetActiveKeysetID()
if id == "" {
t.Error("GetActiveKeysetID returned empty")
}
if len(id) != 14 {
t.Errorf("KeysetID length = %d, want 14", len(id))
}
}
func TestGetMintInfo(t *testing.T) {
store := keyset.NewMemoryStore()
manager := keyset.NewManager(store, keyset.DefaultActiveWindow, keyset.DefaultVerifyWindow)
manager.Init()
config := DefaultConfig()
config.AllowedScopes = []string{token.ScopeRelay, token.ScopeNIP46}
issuer := New(manager, cashuiface.AllowAllChecker{}, config)
info := issuer.GetMintInfo("Test Relay")
if info.Name != "Test Relay" {
t.Errorf("Name = %s, want Test Relay", info.Name)
}
if info.Version != "NIP-XX/1" {
t.Errorf("Version = %s, want NIP-XX/1", info.Version)
}
if len(info.SupportedScopes) != 2 {
t.Errorf("SupportedScopes length = %d, want 2", len(info.SupportedScopes))
}
}
// Helper to parse point for testing
func mustParsePoint(data []byte) *secp256k1.PublicKey {
pk, err := secp256k1.ParsePubKey(data)
if err != nil {
panic(err)
}
return pk
}

218
pkg/cashu/keyset/file_store.go

@ -1,218 +0,0 @@ @@ -1,218 +0,0 @@
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()
}

338
pkg/cashu/keyset/keyset.go

@ -1,338 +0,0 @@ @@ -1,338 +0,0 @@
// Package keyset manages Cashu mint keysets for blind signature tokens.
// Keysets rotate periodically to limit key exposure and provide forward secrecy.
package keyset
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"sync"
"time"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
)
// DefaultActiveWindow is how long a keyset is valid for issuing new tokens.
const DefaultActiveWindow = 7 * 24 * time.Hour // 1 week
// DefaultVerifyWindow is how long a keyset remains valid for verification.
const DefaultVerifyWindow = 21 * 24 * time.Hour // 3 weeks
// Keyset represents a signing keyset with lifecycle management.
type Keyset struct {
ID string // 14-char hex ID (7 bytes)
PrivateKey *secp256k1.PrivateKey // Signing key
PublicKey *secp256k1.PublicKey // Verification key
CreatedAt time.Time // When keyset was created
ActiveAt time.Time // When keyset becomes active for signing
ExpiresAt time.Time // When keyset can no longer sign (but can still verify)
VerifyEnd time.Time // When keyset can no longer verify
Active bool // Whether keyset is currently active for signing
}
// New creates a new keyset with generated keys.
func New() (*Keyset, error) {
return NewWithTTL(DefaultActiveWindow, DefaultVerifyWindow)
}
// NewWithTTL creates a new keyset with custom lifetimes.
func NewWithTTL(activeTTL, verifyTTL time.Duration) (*Keyset, error) {
// Generate random private key
keyBytes := make([]byte, 32)
if _, err := rand.Read(keyBytes); err != nil {
return nil, fmt.Errorf("keyset: failed to generate key: %w", err)
}
privKey := secp256k1.PrivKeyFromBytes(keyBytes)
pubKey := privKey.PubKey()
now := time.Now()
k := &Keyset{
PrivateKey: privKey,
PublicKey: pubKey,
CreatedAt: now,
ActiveAt: now,
ExpiresAt: now.Add(activeTTL),
VerifyEnd: now.Add(verifyTTL),
Active: true,
}
// Calculate ID from public key
k.ID = k.calculateID()
return k, nil
}
// NewFromPrivateKey creates a keyset from an existing private key.
func NewFromPrivateKey(privKeyBytes []byte, createdAt time.Time, activeTTL, verifyTTL time.Duration) (*Keyset, error) {
if len(privKeyBytes) != 32 {
return nil, fmt.Errorf("keyset: private key must be 32 bytes")
}
privKey := secp256k1.PrivKeyFromBytes(privKeyBytes)
pubKey := privKey.PubKey()
k := &Keyset{
PrivateKey: privKey,
PublicKey: pubKey,
CreatedAt: createdAt,
ActiveAt: createdAt,
ExpiresAt: createdAt.Add(activeTTL),
VerifyEnd: createdAt.Add(verifyTTL),
Active: true,
}
k.ID = k.calculateID()
return k, nil
}
// calculateID computes the keyset ID from the public key.
// ID = hex(SHA256(compressed_pubkey)[0:7])
func (k *Keyset) calculateID() string {
compressed := k.PublicKey.SerializeCompressed()
hash := sha256.Sum256(compressed)
return hex.EncodeToString(hash[:7])
}
// IsActiveForSigning returns true if keyset can be used to sign new tokens.
func (k *Keyset) IsActiveForSigning() bool {
now := time.Now()
return k.Active && now.After(k.ActiveAt) && now.Before(k.ExpiresAt)
}
// IsValidForVerification returns true if keyset can be used to verify tokens.
func (k *Keyset) IsValidForVerification() bool {
now := time.Now()
return now.After(k.ActiveAt) && now.Before(k.VerifyEnd)
}
// Deactivate marks the keyset as no longer active for signing.
func (k *Keyset) Deactivate() {
k.Active = false
}
// SerializePrivateKey returns the private key as bytes for storage.
func (k *Keyset) SerializePrivateKey() []byte {
return k.PrivateKey.Serialize()
}
// SerializePublicKey returns the compressed public key.
func (k *Keyset) SerializePublicKey() []byte {
return k.PublicKey.SerializeCompressed()
}
// KeysetInfo is a public view of a keyset (without private key).
type KeysetInfo struct {
ID string `json:"id"`
PublicKey string `json:"pubkey"`
Active bool `json:"active"`
CreatedAt int64 `json:"created_at"`
ExpiresAt int64 `json:"expires_at"`
VerifyEnd int64 `json:"verify_end"`
}
// Info returns public information about the keyset.
func (k *Keyset) Info() KeysetInfo {
return KeysetInfo{
ID: k.ID,
PublicKey: hex.EncodeToString(k.SerializePublicKey()),
Active: k.IsActiveForSigning(),
CreatedAt: k.CreatedAt.Unix(),
ExpiresAt: k.ExpiresAt.Unix(),
VerifyEnd: k.VerifyEnd.Unix(),
}
}
// Manager handles keyset lifecycle including rotation.
type Manager struct {
store Store
activeTTL time.Duration
verifyTTL time.Duration
mu sync.RWMutex
current *Keyset // Current active keyset for signing
verification []*Keyset // All keysets valid for verification (including current)
}
// NewManager creates a keyset manager.
func NewManager(store Store, activeTTL, verifyTTL time.Duration) *Manager {
return &Manager{
store: store,
activeTTL: activeTTL,
verifyTTL: verifyTTL,
verification: make([]*Keyset, 0),
}
}
// Init initializes the manager by loading existing keysets or creating a new one.
func (m *Manager) Init() error {
m.mu.Lock()
defer m.mu.Unlock()
// Load all valid keysets from store
keysets, err := m.store.ListVerificationKeysets()
if err != nil {
return fmt.Errorf("manager: failed to load keysets: %w", err)
}
// Find current active keyset
var active *Keyset
for _, k := range keysets {
if k.IsActiveForSigning() {
if active == nil || k.CreatedAt.After(active.CreatedAt) {
active = k
}
}
if k.IsValidForVerification() {
m.verification = append(m.verification, k)
}
}
// If no active keyset, create one
if active == nil {
newKeyset, err := NewWithTTL(m.activeTTL, m.verifyTTL)
if err != nil {
return fmt.Errorf("manager: failed to create initial keyset: %w", err)
}
if err := m.store.SaveKeyset(newKeyset); err != nil {
return fmt.Errorf("manager: failed to save initial keyset: %w", err)
}
active = newKeyset
m.verification = append(m.verification, newKeyset)
}
m.current = active
return nil
}
// GetSigningKeyset returns the current active keyset for signing.
func (m *Manager) GetSigningKeyset() *Keyset {
m.mu.RLock()
defer m.mu.RUnlock()
return m.current
}
// GetVerificationKeysets returns all keysets valid for verification.
func (m *Manager) GetVerificationKeysets() []*Keyset {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]*Keyset, 0, len(m.verification))
for _, k := range m.verification {
if k.IsValidForVerification() {
result = append(result, k)
}
}
return result
}
// FindByID returns the keyset with the given ID, if it's valid for verification.
func (m *Manager) FindByID(id string) *Keyset {
m.mu.RLock()
defer m.mu.RUnlock()
for _, k := range m.verification {
if k.ID == id && k.IsValidForVerification() {
return k
}
}
return nil
}
// RotateIfNeeded checks if rotation is needed and performs it.
// Returns true if a new keyset was created.
func (m *Manager) RotateIfNeeded() (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
// Check if current keyset is still active
if m.current != nil && m.current.IsActiveForSigning() {
return false, nil
}
// Create new keyset
newKeyset, err := NewWithTTL(m.activeTTL, m.verifyTTL)
if err != nil {
return false, fmt.Errorf("manager: failed to create new keyset: %w", err)
}
// Deactivate old keyset
if m.current != nil {
m.current.Deactivate()
}
// Save new keyset
if err := m.store.SaveKeyset(newKeyset); err != nil {
return false, fmt.Errorf("manager: failed to save new keyset: %w", err)
}
// Update manager state
m.current = newKeyset
m.verification = append(m.verification, newKeyset)
// Prune expired verification keysets
m.pruneExpired()
return true, nil
}
// pruneExpired removes keysets that are no longer valid for verification.
// Must be called with lock held.
func (m *Manager) pruneExpired() {
valid := make([]*Keyset, 0, len(m.verification))
for _, k := range m.verification {
if k.IsValidForVerification() {
valid = append(valid, k)
}
}
m.verification = valid
}
// ListKeysetInfo returns public info for all verification keysets.
func (m *Manager) ListKeysetInfo() []KeysetInfo {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]KeysetInfo, 0, len(m.verification))
for _, k := range m.verification {
if k.IsValidForVerification() {
result = append(result, k.Info())
}
}
return result
}
// StartRotationTicker starts a goroutine that rotates keysets periodically.
// Returns a channel that receives true on each rotation.
func (m *Manager) StartRotationTicker(interval time.Duration) (rotated <-chan bool, stop func()) {
ticker := time.NewTicker(interval)
ch := make(chan bool, 1)
done := make(chan struct{})
go func() {
for {
select {
case <-ticker.C:
rotated, err := m.RotateIfNeeded()
if err != nil {
// Log error but continue
continue
}
if rotated {
select {
case ch <- true:
default:
}
}
case <-done:
ticker.Stop()
close(ch)
return
}
}
}()
return ch, func() { close(done) }
}

278
pkg/cashu/keyset/keyset_test.go

@ -1,278 +0,0 @@ @@ -1,278 +0,0 @@
package keyset
import (
"testing"
"time"
)
func TestNewKeyset(t *testing.T) {
k, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
// Check ID is 14 characters (7 bytes hex)
if len(k.ID) != 14 {
t.Errorf("ID length = %d, want 14", len(k.ID))
}
// Check keys are set
if k.PrivateKey == nil {
t.Error("PrivateKey is nil")
}
if k.PublicKey == nil {
t.Error("PublicKey is nil")
}
// Check times are set
if k.CreatedAt.IsZero() {
t.Error("CreatedAt is zero")
}
if !k.IsActiveForSigning() {
t.Error("New keyset should be active for signing")
}
if !k.IsValidForVerification() {
t.Error("New keyset should be valid for verification")
}
}
func TestKeysetIDDeterministic(t *testing.T) {
// Same private key should produce same ID
privKeyBytes := make([]byte, 32)
for i := range privKeyBytes {
privKeyBytes[i] = byte(i)
}
k1, err := NewFromPrivateKey(privKeyBytes, time.Now(), DefaultActiveWindow, DefaultVerifyWindow)
if err != nil {
t.Fatalf("NewFromPrivateKey failed: %v", err)
}
k2, err := NewFromPrivateKey(privKeyBytes, time.Now(), DefaultActiveWindow, DefaultVerifyWindow)
if err != nil {
t.Fatalf("NewFromPrivateKey failed: %v", err)
}
if k1.ID != k2.ID {
t.Errorf("IDs should match: %s != %s", k1.ID, k2.ID)
}
}
func TestKeysetExpiration(t *testing.T) {
// Create keyset with very short TTL
k, err := NewWithTTL(100*time.Millisecond, 200*time.Millisecond)
if err != nil {
t.Fatalf("NewWithTTL failed: %v", err)
}
// Should be active initially
if !k.IsActiveForSigning() {
t.Error("New keyset should be active for signing")
}
// Wait for signing to expire
time.Sleep(150 * time.Millisecond)
if k.IsActiveForSigning() {
t.Error("Keyset should not be active for signing after expiry")
}
if !k.IsValidForVerification() {
t.Error("Keyset should still be valid for verification")
}
// Wait for verification to expire
time.Sleep(100 * time.Millisecond)
if k.IsValidForVerification() {
t.Error("Keyset should not be valid for verification after verify expiry")
}
}
func TestKeysetDeactivate(t *testing.T) {
k, _ := New()
if !k.Active {
t.Error("New keyset should be active")
}
k.Deactivate()
if k.Active {
t.Error("Keyset should not be active after Deactivate()")
}
if k.IsActiveForSigning() {
t.Error("Deactivated keyset should not be active for signing")
}
}
func TestKeysetInfo(t *testing.T) {
k, _ := New()
info := k.Info()
if info.ID != k.ID {
t.Errorf("Info ID = %s, want %s", info.ID, k.ID)
}
if len(info.PublicKey) != 66 { // 33 bytes * 2 hex chars
t.Errorf("Info PublicKey length = %d, want 66", len(info.PublicKey))
}
if !info.Active {
t.Error("Info Active should be true for new keyset")
}
}
func TestManager(t *testing.T) {
store := NewMemoryStore()
manager := NewManager(store, DefaultActiveWindow, DefaultVerifyWindow)
if err := manager.Init(); err != nil {
t.Fatalf("Init failed: %v", err)
}
// Should have a signing keyset
signing := manager.GetSigningKeyset()
if signing == nil {
t.Fatal("GetSigningKeyset returned nil")
}
// Should have at least one verification keyset
verification := manager.GetVerificationKeysets()
if len(verification) == 0 {
t.Error("GetVerificationKeysets returned empty")
}
// Should find keyset by ID
found := manager.FindByID(signing.ID)
if found == nil {
t.Error("FindByID returned nil for signing keyset")
}
if found.ID != signing.ID {
t.Errorf("FindByID returned wrong keyset: %s != %s", found.ID, signing.ID)
}
}
func TestManagerRotation(t *testing.T) {
store := NewMemoryStore()
manager := NewManager(store, 50*time.Millisecond, 200*time.Millisecond)
if err := manager.Init(); err != nil {
t.Fatalf("Init failed: %v", err)
}
initialID := manager.GetSigningKeyset().ID
// Rotation should not happen yet
rotated, err := manager.RotateIfNeeded()
if err != nil {
t.Fatalf("RotateIfNeeded failed: %v", err)
}
if rotated {
t.Error("Should not rotate when keyset is still active")
}
// Wait for signing to expire
time.Sleep(60 * time.Millisecond)
// Now rotation should happen
rotated, err = manager.RotateIfNeeded()
if err != nil {
t.Fatalf("RotateIfNeeded failed: %v", err)
}
if !rotated {
t.Error("Should rotate when keyset is expired")
}
newID := manager.GetSigningKeyset().ID
if newID == initialID {
t.Error("New keyset should have different ID")
}
// Old keyset should still be valid for verification
old := manager.FindByID(initialID)
if old == nil {
t.Error("Old keyset should still be found for verification")
}
}
func TestManagerPersistence(t *testing.T) {
store := NewMemoryStore()
// First manager creates keyset
m1 := NewManager(store, DefaultActiveWindow, DefaultVerifyWindow)
if err := m1.Init(); err != nil {
t.Fatalf("Init failed: %v", err)
}
id := m1.GetSigningKeyset().ID
// Second manager should load existing keyset
m2 := NewManager(store, DefaultActiveWindow, DefaultVerifyWindow)
if err := m2.Init(); err != nil {
t.Fatalf("Init failed: %v", err)
}
if m2.GetSigningKeyset().ID != id {
t.Error("Second manager should use same keyset as first")
}
}
func TestManagerListKeysetInfo(t *testing.T) {
store := NewMemoryStore()
manager := NewManager(store, DefaultActiveWindow, DefaultVerifyWindow)
manager.Init()
infos := manager.ListKeysetInfo()
if len(infos) == 0 {
t.Error("ListKeysetInfo returned empty")
}
for _, info := range infos {
if info.ID == "" {
t.Error("KeysetInfo has empty ID")
}
if info.PublicKey == "" {
t.Error("KeysetInfo has empty PublicKey")
}
}
}
func TestMemoryStore(t *testing.T) {
store := NewMemoryStore()
k, _ := New()
// Save
if err := store.SaveKeyset(k); err != nil {
t.Fatalf("SaveKeyset failed: %v", err)
}
// Load
loaded, err := store.LoadKeyset(k.ID)
if err != nil {
t.Fatalf("LoadKeyset failed: %v", err)
}
if loaded == nil {
t.Fatal("LoadKeyset returned nil")
}
if loaded.ID != k.ID {
t.Errorf("Loaded ID = %s, want %s", loaded.ID, k.ID)
}
// List active
active, err := store.ListActiveKeysets()
if err != nil {
t.Fatalf("ListActiveKeysets failed: %v", err)
}
if len(active) != 1 {
t.Errorf("ListActiveKeysets returned %d, want 1", len(active))
}
// Delete
if err := store.DeleteKeyset(k.ID); err != nil {
t.Fatalf("DeleteKeyset failed: %v", err)
}
// Should be gone
loaded, _ = store.LoadKeyset(k.ID)
if loaded != nil {
t.Error("Keyset should be deleted")
}
}

74
pkg/cashu/keyset/store.go

@ -1,74 +0,0 @@ @@ -1,74 +0,0 @@
package keyset
// Store is the interface for persisting keysets.
// Implement this interface for your database backend.
type Store interface {
// SaveKeyset persists a keyset.
SaveKeyset(k *Keyset) error
// LoadKeyset loads a keyset by ID.
LoadKeyset(id string) (*Keyset, error)
// ListActiveKeysets returns all keysets that can be used for signing.
ListActiveKeysets() ([]*Keyset, error)
// ListVerificationKeysets returns all keysets that can be used for verification.
ListVerificationKeysets() ([]*Keyset, error)
// DeleteKeyset removes a keyset from storage.
DeleteKeyset(id string) error
}
// MemoryStore is an in-memory implementation of Store for testing.
type MemoryStore struct {
keysets map[string]*Keyset
}
// NewMemoryStore creates a new in-memory store.
func NewMemoryStore() *MemoryStore {
return &MemoryStore{
keysets: make(map[string]*Keyset),
}
}
// SaveKeyset saves a keyset to memory.
func (s *MemoryStore) SaveKeyset(k *Keyset) error {
s.keysets[k.ID] = k
return nil
}
// LoadKeyset loads a keyset by ID.
func (s *MemoryStore) LoadKeyset(id string) (*Keyset, error) {
if k, ok := s.keysets[id]; ok {
return k, nil
}
return nil, nil
}
// ListActiveKeysets returns all active keysets.
func (s *MemoryStore) ListActiveKeysets() ([]*Keyset, error) {
result := make([]*Keyset, 0)
for _, k := range s.keysets {
if k.IsActiveForSigning() {
result = append(result, k)
}
}
return result, nil
}
// ListVerificationKeysets returns all keysets valid for verification.
func (s *MemoryStore) ListVerificationKeysets() ([]*Keyset, error) {
result := make([]*Keyset, 0)
for _, k := range s.keysets {
if k.IsValidForVerification() {
result = append(result, k)
}
}
return result, nil
}
// DeleteKeyset removes a keyset.
func (s *MemoryStore) DeleteKeyset(id string) error {
delete(s.keysets, id)
return nil
}

346
pkg/cashu/token/token.go

@ -1,346 +0,0 @@ @@ -1,346 +0,0 @@
// Package token implements the Cashu access token format as defined in NIP-XX.
// Tokens are privacy-preserving bearer credentials with kind permissions.
package token
import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
// Prefix for serialized tokens.
const Prefix = "cashuA"
// Predefined scopes.
const (
ScopeRelay = "relay" // Standard relay WebSocket access
ScopeNIP46 = "nip46" // NIP-46 remote signing / bunker
ScopeBlossom = "blossom" // Blossom media server
ScopeAPI = "api" // HTTP API access
ScopeNRC = "nrc" // Nostr Relay Connect tunneling
)
// WildcardKind indicates all kinds are permitted.
const WildcardKind = -1
// Errors.
var (
ErrInvalidPrefix = errors.New("token: invalid prefix, expected cashuA")
ErrInvalidEncoding = errors.New("token: invalid base64url encoding")
ErrInvalidJSON = errors.New("token: invalid JSON structure")
ErrTokenExpired = errors.New("token: expired")
ErrKindNotPermitted = errors.New("token: kind not permitted")
ErrScopeMismatch = errors.New("token: scope mismatch")
)
// Token represents a Cashu access token with kind permissions.
type Token struct {
// Cryptographic fields
KeysetID string `json:"k"` // Keyset ID (hex)
Secret []byte `json:"s"` // Random secret (32 bytes)
Signature []byte `json:"c"` // Blind signature (33 bytes compressed)
Pubkey []byte `json:"p"` // User's Nostr pubkey (32 bytes)
// Metadata
Expiry int64 `json:"e"` // Unix timestamp when token expires
Scope string `json:"sc"` // Token scope (relay, nip46, etc.)
// Kind permissions
Kinds []int `json:"kinds,omitempty"` // Explicit list of permitted kinds
KindRanges [][]int `json:"kind_ranges,omitempty"` // Ranges as [min, max] pairs
}
// tokenJSON is the JSON-serializable form with hex-encoded bytes.
type tokenJSON struct {
KeysetID string `json:"k"`
Secret string `json:"s"`
Signature string `json:"c"`
Pubkey string `json:"p"`
Expiry int64 `json:"e"`
Scope string `json:"sc"`
Kinds []int `json:"kinds,omitempty"`
KindRanges [][]int `json:"kind_ranges,omitempty"`
}
// New creates a new token with the given parameters.
func New(keysetID string, secret, signature, pubkey []byte, expiry time.Time, scope string) *Token {
return &Token{
KeysetID: keysetID,
Secret: secret,
Signature: signature,
Pubkey: pubkey,
Expiry: expiry.Unix(),
Scope: scope,
}
}
// SetKinds sets explicit permitted kinds.
// Use WildcardKind (-1) to allow all kinds.
func (t *Token) SetKinds(kinds ...int) {
t.Kinds = kinds
}
// SetKindRanges sets permitted kind ranges.
// Each range is [min, max] inclusive.
func (t *Token) SetKindRanges(ranges ...[]int) {
t.KindRanges = ranges
}
// AddKindRange adds a single kind range.
func (t *Token) AddKindRange(min, max int) {
t.KindRanges = append(t.KindRanges, []int{min, max})
}
// IsExpired returns true if the token has expired.
func (t *Token) IsExpired() bool {
return time.Now().Unix() > t.Expiry
}
// ExpiresAt returns the expiry time.
func (t *Token) ExpiresAt() time.Time {
return time.Unix(t.Expiry, 0)
}
// TimeRemaining returns the duration until expiry.
func (t *Token) TimeRemaining() time.Duration {
return time.Until(t.ExpiresAt())
}
// IsKindPermitted checks if a given event kind is permitted by this token.
func (t *Token) IsKindPermitted(kind int) bool {
// Check for wildcard
for _, k := range t.Kinds {
if k == WildcardKind {
return true
}
}
// Check explicit kinds
for _, k := range t.Kinds {
if k == kind {
return true
}
}
// Check kind ranges
for _, r := range t.KindRanges {
if len(r) >= 2 && kind >= r[0] && kind <= r[1] {
return true
}
}
// If no kinds or ranges specified, check scope defaults
if len(t.Kinds) == 0 && len(t.KindRanges) == 0 {
return t.defaultKindPermitted(kind)
}
return false
}
// defaultKindPermitted returns default permissions based on scope.
func (t *Token) defaultKindPermitted(kind int) bool {
switch t.Scope {
case ScopeRelay:
// Default relay scope allows common kinds
return true
case ScopeNIP46:
// NIP-46 scope allows NIP-46 kinds (24133)
return kind == 24133
case ScopeBlossom:
// Blossom scope allows auth kinds
return kind == 24242
default:
return false
}
}
// HasWritePermission returns true if any kind is permitted (not read-only).
func (t *Token) HasWritePermission() bool {
return len(t.Kinds) > 0 || len(t.KindRanges) > 0
}
// IsReadOnly returns true if no kinds are permitted.
func (t *Token) IsReadOnly() bool {
return !t.HasWritePermission()
}
// MatchesScope checks if the token scope matches the required scope.
func (t *Token) MatchesScope(requiredScope string) bool {
return t.Scope == requiredScope
}
// PubkeyHex returns the pubkey as a hex string.
func (t *Token) PubkeyHex() string {
return hex.EncodeToString(t.Pubkey)
}
// Encode serializes the token to the wire format: cashuA<base64url(json)>
func (t *Token) Encode() (string, error) {
// Convert to JSON-friendly format
tj := tokenJSON{
KeysetID: t.KeysetID,
Secret: hex.EncodeToString(t.Secret),
Signature: hex.EncodeToString(t.Signature),
Pubkey: hex.EncodeToString(t.Pubkey),
Expiry: t.Expiry,
Scope: t.Scope,
Kinds: t.Kinds,
KindRanges: t.KindRanges,
}
jsonBytes, err := json.Marshal(tj)
if err != nil {
return "", fmt.Errorf("token: failed to encode: %w", err)
}
encoded := base64.RawURLEncoding.EncodeToString(jsonBytes)
return Prefix + encoded, nil
}
// Parse decodes a token from the wire format.
func Parse(s string) (*Token, error) {
// Check prefix
if !strings.HasPrefix(s, Prefix) {
return nil, ErrInvalidPrefix
}
// Decode base64url
encoded := strings.TrimPrefix(s, Prefix)
jsonBytes, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidEncoding, err)
}
// Parse JSON
var tj tokenJSON
if err := json.Unmarshal(jsonBytes, &tj); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidJSON, err)
}
// Decode hex fields
secret, err := hex.DecodeString(tj.Secret)
if err != nil {
return nil, fmt.Errorf("token: invalid secret hex: %w", err)
}
signature, err := hex.DecodeString(tj.Signature)
if err != nil {
return nil, fmt.Errorf("token: invalid signature hex: %w", err)
}
pubkey, err := hex.DecodeString(tj.Pubkey)
if err != nil {
return nil, fmt.Errorf("token: invalid pubkey hex: %w", err)
}
return &Token{
KeysetID: tj.KeysetID,
Secret: secret,
Signature: signature,
Pubkey: pubkey,
Expiry: tj.Expiry,
Scope: tj.Scope,
Kinds: tj.Kinds,
KindRanges: tj.KindRanges,
}, nil
}
// ParseFromHeader extracts and parses a token from HTTP headers.
// Supports:
// - X-Cashu-Token: cashuA...
// - Authorization: Cashu cashuA...
func ParseFromHeader(header string) (*Token, error) {
// Try X-Cashu-Token format (raw token)
if strings.HasPrefix(header, Prefix) {
return Parse(header)
}
// Try Authorization format
if strings.HasPrefix(header, "Cashu ") {
tokenStr := strings.TrimPrefix(header, "Cashu ")
return Parse(strings.TrimSpace(tokenStr))
}
return nil, ErrInvalidPrefix
}
// Validate performs basic validation on the token.
// Does NOT verify the cryptographic signature - use Verifier for that.
func (t *Token) Validate() error {
if t.IsExpired() {
return ErrTokenExpired
}
if len(t.KeysetID) != 14 {
return fmt.Errorf("token: invalid keyset ID length: %d", len(t.KeysetID))
}
if len(t.Secret) != 32 {
return fmt.Errorf("token: invalid secret length: %d", len(t.Secret))
}
if len(t.Signature) != 33 {
return fmt.Errorf("token: invalid signature length: %d", len(t.Signature))
}
if len(t.Pubkey) != 32 {
return fmt.Errorf("token: invalid pubkey length: %d", len(t.Pubkey))
}
if t.Scope == "" {
return errors.New("token: missing scope")
}
// Validate kind ranges
for i, r := range t.KindRanges {
if len(r) != 2 {
return fmt.Errorf("token: kind range %d must have 2 elements", i)
}
if r[0] > r[1] {
return fmt.Errorf("token: kind range %d min > max: %d > %d", i, r[0], r[1])
}
}
return nil
}
// Clone creates a copy of the token.
func (t *Token) Clone() *Token {
clone := &Token{
KeysetID: t.KeysetID,
Secret: make([]byte, len(t.Secret)),
Signature: make([]byte, len(t.Signature)),
Pubkey: make([]byte, len(t.Pubkey)),
Expiry: t.Expiry,
Scope: t.Scope,
}
copy(clone.Secret, t.Secret)
copy(clone.Signature, t.Signature)
copy(clone.Pubkey, t.Pubkey)
if len(t.Kinds) > 0 {
clone.Kinds = make([]int, len(t.Kinds))
copy(clone.Kinds, t.Kinds)
}
if len(t.KindRanges) > 0 {
clone.KindRanges = make([][]int, len(t.KindRanges))
for i, r := range t.KindRanges {
clone.KindRanges[i] = make([]int, len(r))
copy(clone.KindRanges[i], r)
}
}
return clone
}
// String returns the encoded token string.
func (t *Token) String() string {
s, _ := t.Encode()
return s
}

336
pkg/cashu/token/token_test.go

@ -1,336 +0,0 @@ @@ -1,336 +0,0 @@
package token
import (
"encoding/hex"
"testing"
"time"
)
func makeTestToken() *Token {
secret := make([]byte, 32)
signature := make([]byte, 33)
pubkey := make([]byte, 32)
for i := range secret {
secret[i] = byte(i)
}
for i := range signature {
signature[i] = byte(i + 32)
}
for i := range pubkey {
pubkey[i] = byte(i + 64)
}
signature[0] = 0x02 // Valid compressed point prefix
return New(
"0a1b2c3d4e5f67",
secret,
signature,
pubkey,
time.Now().Add(time.Hour),
ScopeRelay,
)
}
func TestTokenEncodeDecode(t *testing.T) {
tok := makeTestToken()
tok.SetKinds(0, 1, 3, 7)
tok.AddKindRange(30000, 39999)
encoded, err := tok.Encode()
if err != nil {
t.Fatalf("Encode failed: %v", err)
}
// Should have correct prefix
if encoded[:6] != Prefix {
t.Errorf("Encoded token should start with %s, got %s", Prefix, encoded[:6])
}
// Decode
decoded, err := Parse(encoded)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
// Compare fields
if decoded.KeysetID != tok.KeysetID {
t.Errorf("KeysetID mismatch: %s != %s", decoded.KeysetID, tok.KeysetID)
}
if hex.EncodeToString(decoded.Secret) != hex.EncodeToString(tok.Secret) {
t.Error("Secret mismatch")
}
if hex.EncodeToString(decoded.Signature) != hex.EncodeToString(tok.Signature) {
t.Error("Signature mismatch")
}
if hex.EncodeToString(decoded.Pubkey) != hex.EncodeToString(tok.Pubkey) {
t.Error("Pubkey mismatch")
}
if decoded.Expiry != tok.Expiry {
t.Errorf("Expiry mismatch: %d != %d", decoded.Expiry, tok.Expiry)
}
if decoded.Scope != tok.Scope {
t.Errorf("Scope mismatch: %s != %s", decoded.Scope, tok.Scope)
}
// Check kinds
if len(decoded.Kinds) != len(tok.Kinds) {
t.Errorf("Kinds length mismatch: %d != %d", len(decoded.Kinds), len(tok.Kinds))
}
for i, k := range decoded.Kinds {
if k != tok.Kinds[i] {
t.Errorf("Kinds[%d] mismatch: %d != %d", i, k, tok.Kinds[i])
}
}
// Check kind ranges
if len(decoded.KindRanges) != len(tok.KindRanges) {
t.Errorf("KindRanges length mismatch: %d != %d", len(decoded.KindRanges), len(tok.KindRanges))
}
}
func TestTokenKindPermissions(t *testing.T) {
tok := makeTestToken()
tok.SetKinds(0, 1, 3)
tok.AddKindRange(30000, 39999)
tests := []struct {
kind int
expected bool
}{
{0, true}, // Explicit kind
{1, true}, // Explicit kind
{3, true}, // Explicit kind
{2, false}, // Not in list
{7, false}, // Not in list
{30000, true}, // Start of range
{35000, true}, // Middle of range
{39999, true}, // End of range
{29999, false}, // Just before range
{40000, false}, // Just after range
}
for _, tt := range tests {
result := tok.IsKindPermitted(tt.kind)
if result != tt.expected {
t.Errorf("IsKindPermitted(%d) = %v, want %v", tt.kind, result, tt.expected)
}
}
}
func TestTokenWildcardKind(t *testing.T) {
tok := makeTestToken()
tok.SetKinds(WildcardKind)
// All kinds should be permitted
for _, kind := range []int{0, 1, 100, 1000, 30000, 65535} {
if !tok.IsKindPermitted(kind) {
t.Errorf("Wildcard should permit kind %d", kind)
}
}
}
func TestTokenReadOnly(t *testing.T) {
tok := makeTestToken()
// No kinds set - should be read-only by kinds check
if tok.HasWritePermission() {
t.Error("Token with no kinds should not have write permission")
}
tok.SetKinds(1)
if !tok.HasWritePermission() {
t.Error("Token with kinds should have write permission")
}
}
func TestTokenExpiry(t *testing.T) {
// Token that expires in 1 hour
tok := makeTestToken()
if tok.IsExpired() {
t.Error("Token should not be expired yet")
}
// Token that expired 1 hour ago
tok.Expiry = time.Now().Add(-time.Hour).Unix()
if !tok.IsExpired() {
t.Error("Token should be expired")
}
}
func TestTokenTimeRemaining(t *testing.T) {
tok := makeTestToken()
remaining := tok.TimeRemaining()
// Should be close to 1 hour
if remaining < 59*time.Minute || remaining > 61*time.Minute {
t.Errorf("TimeRemaining = %v, expected ~1 hour", remaining)
}
}
func TestTokenValidate(t *testing.T) {
// Valid token
tok := makeTestToken()
if err := tok.Validate(); err != nil {
t.Errorf("Validate failed for valid token: %v", err)
}
// Expired token
expired := makeTestToken()
expired.Expiry = time.Now().Add(-time.Hour).Unix()
if err := expired.Validate(); err != ErrTokenExpired {
t.Errorf("Validate should return ErrTokenExpired, got %v", err)
}
// Invalid keyset ID
badKeyset := makeTestToken()
badKeyset.KeysetID = "short"
if err := badKeyset.Validate(); err == nil {
t.Error("Validate should fail for short keyset ID")
}
// Invalid secret length
badSecret := makeTestToken()
badSecret.Secret = []byte{1, 2, 3}
if err := badSecret.Validate(); err == nil {
t.Error("Validate should fail for wrong secret length")
}
// Invalid kind range
badRange := makeTestToken()
badRange.KindRanges = [][]int{{100, 50}} // min > max
if err := badRange.Validate(); err == nil {
t.Error("Validate should fail for invalid kind range")
}
}
func TestParseFromHeader(t *testing.T) {
tok := makeTestToken()
encoded, _ := tok.Encode()
// Test X-Cashu-Token format
parsed, err := ParseFromHeader(encoded)
if err != nil {
t.Fatalf("ParseFromHeader failed for raw token: %v", err)
}
if parsed.KeysetID != tok.KeysetID {
t.Error("Parsed token has wrong KeysetID")
}
// Test Authorization format
parsed, err = ParseFromHeader("Cashu " + encoded)
if err != nil {
t.Fatalf("ParseFromHeader failed for Authorization format: %v", err)
}
if parsed.KeysetID != tok.KeysetID {
t.Error("Parsed token has wrong KeysetID")
}
// Test invalid format
_, err = ParseFromHeader("Bearer xyz")
if err != ErrInvalidPrefix {
t.Errorf("Expected ErrInvalidPrefix, got %v", err)
}
}
func TestTokenClone(t *testing.T) {
tok := makeTestToken()
tok.SetKinds(1, 2, 3)
tok.AddKindRange(100, 200)
clone := tok.Clone()
// Modify original
tok.Secret[0] = 0xFF
tok.Kinds[0] = 999
tok.KindRanges[0][0] = 999
// Clone should be unchanged
if clone.Secret[0] == 0xFF {
t.Error("Clone secret was modified when original changed")
}
if clone.Kinds[0] == 999 {
t.Error("Clone kinds was modified when original changed")
}
if clone.KindRanges[0][0] == 999 {
t.Error("Clone kind ranges was modified when original changed")
}
}
func TestTokenMatchesScope(t *testing.T) {
tok := makeTestToken()
tok.Scope = ScopeNIP46
if !tok.MatchesScope(ScopeNIP46) {
t.Error("Should match ScopeNIP46")
}
if tok.MatchesScope(ScopeRelay) {
t.Error("Should not match ScopeRelay")
}
}
func TestTokenPubkeyHex(t *testing.T) {
tok := makeTestToken()
hexPubkey := tok.PubkeyHex()
// Should be 64 characters (32 bytes * 2)
if len(hexPubkey) != 64 {
t.Errorf("PubkeyHex length = %d, want 64", len(hexPubkey))
}
// Should decode back to original
decoded, err := hex.DecodeString(hexPubkey)
if err != nil {
t.Fatalf("PubkeyHex is not valid hex: %v", err)
}
for i, b := range decoded {
if b != tok.Pubkey[i] {
t.Errorf("PubkeyHex[%d] mismatch", i)
}
}
}
func TestTokenString(t *testing.T) {
tok := makeTestToken()
s := tok.String()
if s[:6] != Prefix {
t.Errorf("String() should start with prefix, got %s", s[:6])
}
}
func BenchmarkTokenEncode(b *testing.B) {
tok := makeTestToken()
tok.SetKinds(0, 1, 3, 7)
tok.AddKindRange(30000, 39999)
b.ResetTimer()
for i := 0; i < b.N; i++ {
tok.Encode()
}
}
func BenchmarkTokenParse(b *testing.B) {
tok := makeTestToken()
tok.SetKinds(0, 1, 3, 7)
tok.AddKindRange(30000, 39999)
encoded, _ := tok.Encode()
b.ResetTimer()
for i := 0; i < b.N; i++ {
Parse(encoded)
}
}
func BenchmarkTokenIsKindPermitted(b *testing.B) {
tok := makeTestToken()
tok.SetKinds(0, 1, 3, 7, 10, 20, 30, 40, 50)
tok.AddKindRange(30000, 39999)
tok.AddKindRange(20000, 29999)
b.ResetTimer()
for i := 0; i < b.N; i++ {
tok.IsKindPermitted(35000)
}
}

138
pkg/cashu/verifier/middleware.go

@ -1,138 +0,0 @@ @@ -1,138 +0,0 @@
package verifier
import (
"context"
"net/http"
"next.orly.dev/pkg/cashu/token"
)
// ContextKey is the type for context keys.
type ContextKey string
const (
// TokenContextKey is the context key for the verified token.
TokenContextKey ContextKey = "cashu_token"
// PubkeyContextKey is the context key for the user's pubkey.
PubkeyContextKey ContextKey = "cashu_pubkey"
)
// TokenFromContext extracts the verified token from the request context.
func TokenFromContext(ctx context.Context) *token.Token {
if tok, ok := ctx.Value(TokenContextKey).(*token.Token); ok {
return tok
}
return nil
}
// PubkeyFromContext extracts the user's pubkey from the request context.
func PubkeyFromContext(ctx context.Context) []byte {
if pubkey, ok := ctx.Value(PubkeyContextKey).([]byte); ok {
return pubkey
}
return nil
}
// Middleware creates an HTTP middleware that verifies Cashu tokens.
func Middleware(v *Verifier, requiredScope string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tok, err := v.VerifyRequest(r.Context(), r, requiredScope)
if err != nil {
writeError(w, err)
return
}
// Add token and pubkey to context
ctx := context.WithValue(r.Context(), TokenContextKey, tok)
ctx = context.WithValue(ctx, PubkeyContextKey, tok.Pubkey)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// MiddlewareForKind creates middleware that also checks kind permission.
func MiddlewareForKind(v *Verifier, requiredScope string, kind int) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tok, err := v.VerifyRequestForKind(r.Context(), r, requiredScope, kind)
if err != nil {
writeError(w, err)
return
}
ctx := context.WithValue(r.Context(), TokenContextKey, tok)
ctx = context.WithValue(ctx, PubkeyContextKey, tok.Pubkey)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// OptionalMiddleware creates middleware that verifies tokens if present,
// but allows requests without tokens to proceed.
func OptionalMiddleware(v *Verifier, requiredScope string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tok, err := v.VerifyRequest(r.Context(), r, requiredScope)
if err == nil {
// Token present and valid - add to context
ctx := context.WithValue(r.Context(), TokenContextKey, tok)
ctx = context.WithValue(ctx, PubkeyContextKey, tok.Pubkey)
r = r.WithContext(ctx)
} else if err != ErrMissingToken {
// Token present but invalid - reject
writeError(w, err)
return
}
// No token or valid token - proceed
next.ServeHTTP(w, r)
})
}
}
// writeError writes an appropriate HTTP error response.
func writeError(w http.ResponseWriter, err error) {
switch err {
case ErrMissingToken:
http.Error(w, "Missing token", http.StatusUnauthorized)
case ErrTokenExpired:
http.Error(w, "Token expired", http.StatusGone)
case ErrUnknownKeyset:
http.Error(w, "Unknown keyset", http.StatusMisdirectedRequest)
case ErrInvalidSignature:
http.Error(w, "Invalid signature", http.StatusUnauthorized)
case ErrScopeMismatch:
http.Error(w, "Scope mismatch", http.StatusForbidden)
case ErrKindNotPermitted:
http.Error(w, "Kind not permitted", http.StatusForbidden)
case ErrAccessRevoked:
http.Error(w, "Access revoked", http.StatusForbidden)
default:
http.Error(w, err.Error(), http.StatusUnauthorized)
}
}
// RequireToken is a helper that extracts and verifies a token inline.
// Returns the token or writes an error response and returns nil.
func RequireToken(v *Verifier, w http.ResponseWriter, r *http.Request, requiredScope string) *token.Token {
tok, err := v.VerifyRequest(r.Context(), r, requiredScope)
if err != nil {
writeError(w, err)
return nil
}
return tok
}
// RequireKind is a helper that also checks kind permission inline.
func RequireKind(v *Verifier, w http.ResponseWriter, r *http.Request, requiredScope string, kind int) *token.Token {
tok, err := v.VerifyRequestForKind(r.Context(), r, requiredScope, kind)
if err != nil {
writeError(w, err)
return nil
}
return tok
}

186
pkg/cashu/verifier/verifier.go

@ -1,186 +0,0 @@ @@ -1,186 +0,0 @@
// Package verifier implements Cashu token verification with optional re-authorization.
package verifier
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"next.orly.dev/pkg/cashu/bdhke"
"next.orly.dev/pkg/cashu/keyset"
"next.orly.dev/pkg/cashu/token"
cashuiface "next.orly.dev/pkg/interfaces/cashu"
)
// Errors.
var (
ErrTokenExpired = errors.New("verifier: token expired")
ErrUnknownKeyset = errors.New("verifier: unknown keyset")
ErrInvalidSignature = errors.New("verifier: invalid signature")
ErrScopeMismatch = errors.New("verifier: scope mismatch")
ErrKindNotPermitted = errors.New("verifier: kind not permitted")
ErrAccessRevoked = errors.New("verifier: access revoked")
ErrMissingToken = errors.New("verifier: missing token")
)
// Config holds verifier configuration.
type Config struct {
// Reauthorize enables re-checking authorization on each verification.
// This provides "stateless revocation" at the cost of an extra check.
Reauthorize bool
}
// DefaultConfig returns sensible default configuration.
func DefaultConfig() Config {
return Config{
Reauthorize: true, // Enable stateless revocation by default
}
}
// Verifier validates Cashu tokens and checks permissions.
type Verifier struct {
keysets *keyset.Manager
authz cashuiface.AuthzChecker
claimValidator cashuiface.ClaimValidator
config Config
}
// New creates a new verifier.
func New(keysets *keyset.Manager, authz cashuiface.AuthzChecker, config Config) *Verifier {
return &Verifier{
keysets: keysets,
authz: authz,
config: config,
}
}
// SetClaimValidator sets an optional claim validator.
func (v *Verifier) SetClaimValidator(cv cashuiface.ClaimValidator) {
v.claimValidator = cv
}
// Verify validates a token's cryptographic signature and checks expiry.
func (v *Verifier) Verify(ctx context.Context, tok *token.Token, remoteAddr string) error {
// Basic validation
if err := tok.Validate(); err != nil {
return err
}
// Check expiry
if tok.IsExpired() {
return ErrTokenExpired
}
// Find keyset
ks := v.keysets.FindByID(tok.KeysetID)
if ks == nil {
return fmt.Errorf("%w: %s", ErrUnknownKeyset, tok.KeysetID)
}
// Verify signature
valid, err := v.verifySignature(tok, ks)
if err != nil {
return fmt.Errorf("verifier: signature check failed: %w", err)
}
if !valid {
return ErrInvalidSignature
}
// Re-check authorization if enabled
if v.config.Reauthorize && v.authz != nil {
if err := v.authz.CheckAuthorization(ctx, tok.Pubkey, tok.Scope, remoteAddr); err != nil {
return fmt.Errorf("%w: %v", ErrAccessRevoked, err)
}
}
return nil
}
// VerifyForScope verifies a token and checks that it has the required scope.
func (v *Verifier) VerifyForScope(ctx context.Context, tok *token.Token, requiredScope string, remoteAddr string) error {
if err := v.Verify(ctx, tok, remoteAddr); err != nil {
return err
}
if !tok.MatchesScope(requiredScope) {
return fmt.Errorf("%w: expected %s, got %s", ErrScopeMismatch, requiredScope, tok.Scope)
}
return nil
}
// VerifyForKind verifies a token and checks that the specified kind is permitted.
func (v *Verifier) VerifyForKind(ctx context.Context, tok *token.Token, kind int, remoteAddr string) error {
if err := v.Verify(ctx, tok, remoteAddr); err != nil {
return err
}
if !tok.IsKindPermitted(kind) {
return fmt.Errorf("%w: kind %d", ErrKindNotPermitted, kind)
}
return nil
}
// verifySignature checks the BDHKE signature.
func (v *Verifier) verifySignature(tok *token.Token, ks *keyset.Keyset) (bool, error) {
// Parse signature as curve point
C, err := secp256k1.ParsePubKey(tok.Signature)
if err != nil {
return false, fmt.Errorf("invalid signature format: %w", err)
}
// Verify: C == k * HashToCurve(secret)
return bdhke.Verify(tok.Secret, C, ks.PrivateKey)
}
// ExtractFromRequest extracts and parses a token from an HTTP request.
// Checks headers in order: X-Cashu-Token, Authorization (Cashu scheme).
func (v *Verifier) ExtractFromRequest(r *http.Request) (*token.Token, error) {
// Try X-Cashu-Token header first
if header := r.Header.Get("X-Cashu-Token"); header != "" {
return token.ParseFromHeader(header)
}
// Try Authorization header
if header := r.Header.Get("Authorization"); header != "" {
return token.ParseFromHeader(header)
}
return nil, ErrMissingToken
}
// VerifyRequest extracts, parses, and verifies a token from an HTTP request.
func (v *Verifier) VerifyRequest(ctx context.Context, r *http.Request, requiredScope string) (*token.Token, error) {
tok, err := v.ExtractFromRequest(r)
if err != nil {
return nil, err
}
if err := v.VerifyForScope(ctx, tok, requiredScope, r.RemoteAddr); err != nil {
return nil, err
}
return tok, nil
}
// VerifyRequestForKind extracts, parses, and verifies a token for a specific kind.
func (v *Verifier) VerifyRequestForKind(ctx context.Context, r *http.Request, requiredScope string, kind int) (*token.Token, error) {
tok, err := v.ExtractFromRequest(r)
if err != nil {
return nil, err
}
if err := v.VerifyForScope(ctx, tok, requiredScope, r.RemoteAddr); err != nil {
return nil, err
}
if !tok.IsKindPermitted(kind) {
return nil, fmt.Errorf("%w: kind %d", ErrKindNotPermitted, kind)
}
return tok, nil
}

396
pkg/cashu/verifier/verifier_test.go

@ -1,396 +0,0 @@ @@ -1,396 +0,0 @@
package verifier
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"next.orly.dev/pkg/cashu/bdhke"
"next.orly.dev/pkg/cashu/issuer"
"next.orly.dev/pkg/cashu/keyset"
"next.orly.dev/pkg/cashu/token"
cashuiface "next.orly.dev/pkg/interfaces/cashu"
)
func setupVerifier() (*Verifier, *issuer.Issuer, *keyset.Manager) {
store := keyset.NewMemoryStore()
manager := keyset.NewManager(store, keyset.DefaultActiveWindow, keyset.DefaultVerifyWindow)
manager.Init()
issuerConfig := issuer.DefaultConfig()
iss := issuer.New(manager, cashuiface.AllowAllChecker{}, issuerConfig)
verifierConfig := DefaultConfig()
ver := New(manager, cashuiface.AllowAllChecker{}, verifierConfig)
return ver, iss, manager
}
func issueTestToken(iss *issuer.Issuer, scope string, kinds []int) (*token.Token, error) {
secret, err := bdhke.GenerateSecret()
if err != nil {
return nil, err
}
blindResult, err := bdhke.Blind(secret)
if err != nil {
return nil, err
}
pubkey := make([]byte, 32)
for i := range pubkey {
pubkey[i] = byte(i)
}
req := &issuer.IssueRequest{
BlindedMessage: blindResult.B.SerializeCompressed(),
Pubkey: pubkey,
Scope: scope,
Kinds: kinds,
}
resp, err := iss.Issue(context.Background(), req, "127.0.0.1")
if err != nil {
return nil, err
}
return issuer.BuildToken(resp, secret, blindResult.R, pubkey, scope, kinds, nil)
}
func TestVerifySuccess(t *testing.T) {
ver, iss, _ := setupVerifier()
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1, 2, 3})
if err != nil {
t.Fatalf("issueTestToken failed: %v", err)
}
err = ver.Verify(context.Background(), tok, "127.0.0.1")
if err != nil {
t.Errorf("Verify failed: %v", err)
}
}
func TestVerifyExpired(t *testing.T) {
ver, iss, _ := setupVerifier()
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
if err != nil {
t.Fatalf("issueTestToken failed: %v", err)
}
// Expire the token
tok.Expiry = time.Now().Add(-time.Hour).Unix()
err = ver.Verify(context.Background(), tok, "127.0.0.1")
if err == nil {
t.Error("Verify should fail for expired token")
}
}
func TestVerifyInvalidSignature(t *testing.T) {
ver, iss, _ := setupVerifier()
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
if err != nil {
t.Fatalf("issueTestToken failed: %v", err)
}
// Corrupt the signature
tok.Signature[10] ^= 0xFF
err = ver.Verify(context.Background(), tok, "127.0.0.1")
if err == nil {
t.Error("Verify should fail for invalid signature")
}
}
func TestVerifyUnknownKeyset(t *testing.T) {
ver, iss, _ := setupVerifier()
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
if err != nil {
t.Fatalf("issueTestToken failed: %v", err)
}
// Change keyset ID
tok.KeysetID = "00000000000000"
err = ver.Verify(context.Background(), tok, "127.0.0.1")
if err == nil {
t.Error("Verify should fail for unknown keyset")
}
}
func TestVerifyForScope(t *testing.T) {
ver, iss, _ := setupVerifier()
tok, err := issueTestToken(iss, token.ScopeNIP46, []int{24133})
if err != nil {
t.Fatalf("issueTestToken failed: %v", err)
}
// Should pass for correct scope
err = ver.VerifyForScope(context.Background(), tok, token.ScopeNIP46, "127.0.0.1")
if err != nil {
t.Errorf("VerifyForScope failed for correct scope: %v", err)
}
// Should fail for wrong scope
err = ver.VerifyForScope(context.Background(), tok, token.ScopeRelay, "127.0.0.1")
if err == nil {
t.Error("VerifyForScope should fail for wrong scope")
}
}
func TestVerifyForKind(t *testing.T) {
ver, iss, _ := setupVerifier()
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1, 2, 3})
if err != nil {
t.Fatalf("issueTestToken failed: %v", err)
}
// Should pass for permitted kind
err = ver.VerifyForKind(context.Background(), tok, 1, "127.0.0.1")
if err != nil {
t.Errorf("VerifyForKind failed for permitted kind: %v", err)
}
// Should fail for non-permitted kind
err = ver.VerifyForKind(context.Background(), tok, 100, "127.0.0.1")
if err == nil {
t.Error("VerifyForKind should fail for non-permitted kind")
}
}
func TestVerifyReauthorization(t *testing.T) {
store := keyset.NewMemoryStore()
manager := keyset.NewManager(store, keyset.DefaultActiveWindow, keyset.DefaultVerifyWindow)
manager.Init()
iss := issuer.New(manager, cashuiface.AllowAllChecker{}, issuer.DefaultConfig())
// Create verifier that denies authorization
config := DefaultConfig()
config.Reauthorize = true
ver := New(manager, cashuiface.DenyAllChecker{}, config)
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
if err != nil {
t.Fatalf("issueTestToken failed: %v", err)
}
// Should fail due to reauthorization check
err = ver.Verify(context.Background(), tok, "127.0.0.1")
if err == nil {
t.Error("Verify should fail when reauthorization fails")
}
}
func TestExtractFromRequest(t *testing.T) {
ver, iss, _ := setupVerifier()
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
if err != nil {
t.Fatalf("issueTestToken failed: %v", err)
}
encoded, _ := tok.Encode()
tests := []struct {
name string
header string
value string
}{
{"X-Cashu-Token", "X-Cashu-Token", encoded},
{"Authorization Cashu", "Authorization", "Cashu " + encoded},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set(tt.header, tt.value)
extracted, err := ver.ExtractFromRequest(req)
if err != nil {
t.Fatalf("ExtractFromRequest failed: %v", err)
}
if extracted.KeysetID != tok.KeysetID {
t.Errorf("KeysetID mismatch: %s != %s", extracted.KeysetID, tok.KeysetID)
}
})
}
}
func TestExtractFromRequestMissing(t *testing.T) {
ver, _, _ := setupVerifier()
req := httptest.NewRequest("GET", "/", nil)
_, err := ver.ExtractFromRequest(req)
if err != ErrMissingToken {
t.Errorf("Expected ErrMissingToken, got %v", err)
}
}
func TestVerifyRequest(t *testing.T) {
ver, iss, _ := setupVerifier()
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
if err != nil {
t.Fatalf("issueTestToken failed: %v", err)
}
encoded, _ := tok.Encode()
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Cashu-Token", encoded)
verified, err := ver.VerifyRequest(context.Background(), req, token.ScopeRelay)
if err != nil {
t.Fatalf("VerifyRequest failed: %v", err)
}
if verified.KeysetID != tok.KeysetID {
t.Error("VerifyRequest returned wrong token")
}
}
func TestMiddleware(t *testing.T) {
ver, iss, _ := setupVerifier()
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
if err != nil {
t.Fatalf("issueTestToken failed: %v", err)
}
encoded, _ := tok.Encode()
// Handler that checks context
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctxTok := TokenFromContext(r.Context())
if ctxTok == nil {
t.Error("Token not in context")
}
pubkey := PubkeyFromContext(r.Context())
if pubkey == nil {
t.Error("Pubkey not in context")
}
w.WriteHeader(http.StatusOK)
})
wrapped := Middleware(ver, token.ScopeRelay)(handler)
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Cashu-Token", encoded)
rec := httptest.NewRecorder()
wrapped.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("Status = %d, want 200", rec.Code)
}
}
func TestMiddlewareUnauthorized(t *testing.T) {
ver, _, _ := setupVerifier()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
wrapped := Middleware(ver, token.ScopeRelay)(handler)
// Request without token
req := httptest.NewRequest("GET", "/", nil)
rec := httptest.NewRecorder()
wrapped.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("Status = %d, want 401", rec.Code)
}
}
func TestOptionalMiddleware(t *testing.T) {
ver, iss, _ := setupVerifier()
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
if err != nil {
t.Fatalf("issueTestToken failed: %v", err)
}
encoded, _ := tok.Encode()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
wrapped := OptionalMiddleware(ver, token.ScopeRelay)(handler)
// With token
req1 := httptest.NewRequest("GET", "/", nil)
req1.Header.Set("X-Cashu-Token", encoded)
rec1 := httptest.NewRecorder()
wrapped.ServeHTTP(rec1, req1)
if rec1.Code != http.StatusOK {
t.Errorf("With token: Status = %d, want 200", rec1.Code)
}
// Without token
req2 := httptest.NewRequest("GET", "/", nil)
rec2 := httptest.NewRecorder()
wrapped.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusOK {
t.Errorf("Without token: Status = %d, want 200", rec2.Code)
}
}
func TestRequireToken(t *testing.T) {
ver, iss, _ := setupVerifier()
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
if err != nil {
t.Fatalf("issueTestToken failed: %v", err)
}
encoded, _ := tok.Encode()
// With valid token
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Cashu-Token", encoded)
rec := httptest.NewRecorder()
result := RequireToken(ver, rec, req, token.ScopeRelay)
if result == nil {
t.Error("RequireToken should return token")
}
// Without token
req2 := httptest.NewRequest("GET", "/", nil)
rec2 := httptest.NewRecorder()
result2 := RequireToken(ver, rec2, req2, token.ScopeRelay)
if result2 != nil {
t.Error("RequireToken should return nil for missing token")
}
if rec2.Code != http.StatusUnauthorized {
t.Errorf("Status = %d, want 401", rec2.Code)
}
}
// Helper to parse point
func mustParsePoint(data []byte) *secp256k1.PublicKey {
pk, err := secp256k1.ParsePubKey(data)
if err != nil {
panic(err)
}
return pk
}

25
pkg/database/nrc.go

@ -29,7 +29,6 @@ type NRCConnection struct { @@ -29,7 +29,6 @@ type NRCConnection struct {
Secret []byte `json:"secret"` // 32-byte secret for client authentication
CreatedAt int64 `json:"created_at"` // Unix timestamp
LastUsed int64 `json:"last_used"` // Unix timestamp of last connection (0 if never)
UseCashu bool `json:"use_cashu"` // Whether to include CAT token in URI
}
// GetNRCConnection retrieves an NRC connection by ID.
@ -108,7 +107,7 @@ func (d *D) GetAllNRCConnections() (conns []*NRCConnection, err error) { @@ -108,7 +107,7 @@ func (d *D) GetAllNRCConnections() (conns []*NRCConnection, err error) {
}
// CreateNRCConnection generates a new NRC connection with a random secret.
func (d *D) CreateNRCConnection(label string, useCashu bool) (*NRCConnection, error) {
func (d *D) CreateNRCConnection(label string) (*NRCConnection, error) {
// Generate random 32-byte secret
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
@ -124,22 +123,20 @@ func (d *D) CreateNRCConnection(label string, useCashu bool) (*NRCConnection, er @@ -124,22 +123,20 @@ func (d *D) CreateNRCConnection(label string, useCashu bool) (*NRCConnection, er
Secret: secret,
CreatedAt: time.Now().Unix(),
LastUsed: 0,
UseCashu: useCashu,
}
if err := d.SaveNRCConnection(conn); chk.E(err) {
return nil, err
}
log.I.F("created NRC connection: id=%s label=%s cashu=%v", id, label, useCashu)
log.I.F("created NRC connection: id=%s label=%s", id, label)
return conn, nil
}
// GetNRCConnectionURI generates the full connection URI for a connection.
// relayPubkey is the relay's public key (32 bytes).
// rendezvousURL is the public relay URL.
// mintURL is the CAT mint URL (required if useCashu is true).
func (d *D) GetNRCConnectionURI(conn *NRCConnection, relayPubkey []byte, rendezvousURL, mintURL string) (string, error) {
func (d *D) GetNRCConnectionURI(conn *NRCConnection, relayPubkey []byte, rendezvousURL string) (string, error) {
if len(relayPubkey) != 32 {
return "", fmt.Errorf("invalid relay pubkey length: %d", len(relayPubkey))
}
@ -150,19 +147,9 @@ func (d *D) GetNRCConnectionURI(conn *NRCConnection, relayPubkey []byte, rendezv @@ -150,19 +147,9 @@ func (d *D) GetNRCConnectionURI(conn *NRCConnection, relayPubkey []byte, rendezv
relayPubkeyHex := hex.Enc(relayPubkey)
secretHex := hex.Enc(conn.Secret)
var uri string
if conn.UseCashu {
if mintURL == "" {
return "", fmt.Errorf("mint URL is required for CAT authentication")
}
// CAT-based URI includes both secret (for non-CAT relays) and CAT auth
uri = fmt.Sprintf("nostr+relayconnect://%s?relay=%s&secret=%s&auth=cat&mint=%s",
relayPubkeyHex, rendezvousURL, secretHex, mintURL)
} else {
// Secret-only URI
uri = fmt.Sprintf("nostr+relayconnect://%s?relay=%s&secret=%s",
relayPubkeyHex, rendezvousURL, secretHex)
}
// Secret-only URI
uri := fmt.Sprintf("nostr+relayconnect://%s?relay=%s&secret=%s",
relayPubkeyHex, rendezvousURL, secretHex)
if conn.Label != "" {
uri += fmt.Sprintf("&name=%s", conn.Label)

15
pkg/event/authorization/authorization.go

@ -70,10 +70,11 @@ type SyncManager interface { @@ -70,10 +70,11 @@ type SyncManager interface {
// Config holds configuration for the authorization service.
type Config struct {
AuthRequired bool // Whether auth is required for all operations
AuthToWrite bool // Whether auth is required for write operations
Admins [][]byte // Admin pubkeys
Owners [][]byte // Owner pubkeys
AuthRequired bool // Whether auth is required for all operations
AuthToWrite bool // Whether auth is required for write operations
NIP46BypassAuth bool // Allow NIP-46 events through without auth
Admins [][]byte // Admin pubkeys
Owners [][]byte // Owner pubkeys
}
// Service implements the Authorizer interface.
@ -135,8 +136,12 @@ func (s *Service) Authorize(ev *event.E, authedPubkey []byte, remote string, eve @@ -135,8 +136,12 @@ func (s *Service) Authorize(ev *event.E, authedPubkey []byte, remote string, eve
}
// Check if auth is required but user not authenticated
// NIP-46 bunker events (kind 24133) can bypass auth if configured
const kindNIP46 = 24133
if (s.cfg.AuthRequired || s.cfg.AuthToWrite) && len(authedPubkey) == 0 {
return Deny("authentication required for write operations", true)
if !(s.cfg.NIP46BypassAuth && eventKind == kindNIP46) {
return Deny("authentication required for write operations", true)
}
}
// Get access level

106
pkg/interfaces/cashu/cashu.go

@ -1,106 +0,0 @@ @@ -1,106 +0,0 @@
// Package cashu defines interfaces for the Cashu access token system.
// Implement these interfaces to integrate with your authorization backend.
package cashu
import (
"context"
)
// AuthzChecker determines if a pubkey is authorized for a given scope.
// Implement this interface to integrate with your access control system.
type AuthzChecker interface {
// CheckAuthorization returns nil if the pubkey is authorized for the scope,
// or an error describing why authorization failed.
//
// Parameters:
// - ctx: Context for cancellation and timeouts
// - pubkey: User's Nostr pubkey (32 bytes)
// - scope: Token scope (e.g., "relay", "nip46", "api")
// - remoteAddr: Client's remote address (for IP-based checks)
//
// The implementation should check if the user has sufficient permissions
// for the requested scope. This is called during token issuance.
CheckAuthorization(ctx context.Context, pubkey []byte, scope string, remoteAddr string) error
}
// ReauthorizationChecker is an optional extension of AuthzChecker that
// supports re-checking authorization during token verification.
// This enables "stateless revocation" - tokens become invalid immediately
// when the user is removed from the access list.
type ReauthorizationChecker interface {
AuthzChecker
// ReauthorizationEnabled returns true if authorization should be
// re-checked on every token verification.
ReauthorizationEnabled() bool
}
// ClaimValidator validates custom claims in tokens.
// Implement this for application-specific claim validation.
type ClaimValidator interface {
// ValidateClaims validates custom claims embedded in a token.
// Returns nil if claims are valid, error otherwise.
ValidateClaims(claims map[string]any) error
}
// KindPermissionChecker validates event kind permissions.
// This is typically implemented by the token itself, but can be
// extended for additional validation logic.
type KindPermissionChecker interface {
// IsKindPermitted returns true if the given event kind is allowed.
IsKindPermitted(kind int) bool
// HasWritePermission returns true if any kinds are permitted.
HasWritePermission() bool
}
// Common error types that implementations may return.
type AuthzError struct {
Code string
Message string
}
func (e *AuthzError) Error() string {
return e.Message
}
// Predefined authorization error codes.
const (
ErrCodeNotAuthorized = "not_authorized"
ErrCodeBanned = "banned"
ErrCodeBlocked = "blocked"
ErrCodeInvalidScope = "invalid_scope"
ErrCodeRateLimited = "rate_limited"
ErrCodeInsufficientAccess = "insufficient_access"
)
// NewAuthzError creates a new authorization error.
func NewAuthzError(code, message string) *AuthzError {
return &AuthzError{Code: code, Message: message}
}
// Common authorization errors.
var (
ErrNotAuthorized = NewAuthzError(ErrCodeNotAuthorized, "not authorized for this scope")
ErrBanned = NewAuthzError(ErrCodeBanned, "user is banned")
ErrBlocked = NewAuthzError(ErrCodeBlocked, "IP address is blocked")
ErrInvalidScope = NewAuthzError(ErrCodeInvalidScope, "invalid scope requested")
)
// AllowAllChecker is a simple implementation that allows all requests.
// Useful for testing or open relays.
type AllowAllChecker struct{}
// CheckAuthorization always returns nil (allowed).
func (AllowAllChecker) CheckAuthorization(ctx context.Context, pubkey []byte, scope string, remoteAddr string) error {
return nil
}
// DenyAllChecker is a simple implementation that denies all requests.
// Useful for testing or temporarily disabling token issuance.
type DenyAllChecker struct{}
// CheckAuthorization always returns ErrNotAuthorized.
func (DenyAllChecker) CheckAuthorization(ctx context.Context, pubkey []byte, scope string, remoteAddr string) error {
return ErrNotAuthorized
}

35
pkg/protocol/nrc/bridge.go

@ -18,9 +18,6 @@ import ( @@ -18,9 +18,6 @@ import (
"git.mleku.dev/mleku/nostr/ws"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/cashu/token"
"next.orly.dev/pkg/cashu/verifier"
)
const (
@ -40,8 +37,6 @@ type BridgeConfig struct { @@ -40,8 +37,6 @@ type BridgeConfig struct {
Signer signer.I
// AuthorizedSecrets maps derived pubkeys to device names (secret-based auth).
AuthorizedSecrets map[string]string
// CashuVerifier is used for CAT token verification (optional).
CashuVerifier *verifier.Verifier
// SessionTimeout is the inactivity timeout for sessions.
SessionTimeout time.Duration
}
@ -267,36 +262,6 @@ func (b *Bridge) authorize(ctx context.Context, ev *event.E) (conversationKey [] @@ -267,36 +262,6 @@ func (b *Bridge) authorize(ctx context.Context, ev *event.E) (conversationKey []
clientPubkey := ev.Pubkey[:]
clientPubkeyHex := string(hex.Enc(clientPubkey))
// Check for CAT token in tags
cashuTag := ev.Tags.GetFirst([]byte("cashu"))
if cashuTag != nil && cashuTag.Len() >= 2 {
// CAT authentication
if b.config.CashuVerifier == nil {
err = fmt.Errorf("CAT auth not configured")
return
}
tokenStr := string(cashuTag.Value())
var tok *token.Token
tok, err = token.Parse(tokenStr)
if chk.E(err) {
err = fmt.Errorf("invalid CAT token: %w", err)
return
}
if err = b.config.CashuVerifier.VerifyForScope(ctx, tok, token.ScopeNRC, ""); chk.E(err) {
return
}
// CAT auth uses ECDH between relay key and client's Nostr key
conversationKey, err = encryption.GenerateConversationKey(
b.config.Signer.Sec(),
clientPubkey,
)
if chk.E(err) {
return
}
authMode = AuthModeCAT
return
}
// Secret-based authentication: check if client pubkey is in authorized list
if name, ok := b.config.AuthorizedSecrets[clientPubkeyHex]; ok {
// Secret auth uses ECDH between relay key and client's derived key

17
pkg/protocol/nrc/nrc_test.go

@ -91,23 +91,6 @@ func TestParseConnectionURI(t *testing.T) { @@ -91,23 +91,6 @@ func TestParseConnectionURI(t *testing.T) {
}
},
},
{
name: "valid CAT-based URI",
uri: "nostr+relayconnect://" + relayPubkeyHex + "?relay=wss://relay.example.com&auth=cat&mint=https://mint.example.com",
check: func(t *testing.T, conn *ConnectionURI) {
if conn.AuthMode != AuthModeCAT {
t.Errorf("expected AuthModeCAT, got %d", conn.AuthMode)
}
if conn.MintURL != "https://mint.example.com" {
t.Errorf("expected mint URL, got %s", conn.MintURL)
}
},
},
{
name: "CAT URI missing mint",
uri: "nostr+relayconnect://" + relayPubkeyHex + "?relay=wss://relay.example.com&auth=cat",
wantErr: true,
},
}
for _, tt := range tests {

113
pkg/protocol/nrc/uri.go

@ -17,8 +17,6 @@ type AuthMode int @@ -17,8 +17,6 @@ type AuthMode int
const (
// AuthModeSecret uses a shared secret for authentication.
AuthModeSecret AuthMode = iota
// AuthModeCAT uses Cashu Access Tokens for authentication.
AuthModeCAT
)
// ConnectionURI represents a parsed nostr+relayconnect:// URI.
@ -27,7 +25,7 @@ type ConnectionURI struct { @@ -27,7 +25,7 @@ type ConnectionURI struct {
RelayPubkey []byte
// RendezvousRelay is the WebSocket URL of the public relay.
RendezvousRelay string
// AuthMode indicates whether to use secret or CAT authentication.
// AuthMode indicates the authentication mode.
AuthMode AuthMode
// DeviceName is an optional human-readable device identifier.
DeviceName string
@ -35,9 +33,6 @@ type ConnectionURI struct { @@ -35,9 +33,6 @@ type ConnectionURI struct {
// Secret-based authentication fields
clientSecretKey signer.I
conversationKey []byte
// CAT-based authentication fields
MintURL string
}
// GetClientSigner returns the signer derived from the secret (secret-based auth only).
@ -52,13 +47,9 @@ func (c *ConnectionURI) GetConversationKey() []byte { @@ -52,13 +47,9 @@ func (c *ConnectionURI) GetConversationKey() []byte {
// ParseConnectionURI parses a nostr+relayconnect:// URI.
//
// Secret-based URI format:
// URI format:
//
// nostr+relayconnect://<relay-pubkey>?relay=<rendezvous-relay>&secret=<client-secret>[&name=<device-name>]
//
// CAT-based URI format:
//
// nostr+relayconnect://<relay-pubkey>?relay=<rendezvous-relay>&auth=cat&mint=<mint-url>
func ParseConnectionURI(nrcURI string) (conn *ConnectionURI, err error) {
var p *url.URL
if p, err = url.Parse(nrcURI); chk.E(err) {
@ -100,52 +91,40 @@ func ParseConnectionURI(nrcURI string) (conn *ConnectionURI, err error) { @@ -100,52 +91,40 @@ func ParseConnectionURI(nrcURI string) (conn *ConnectionURI, err error) {
// Parse optional device name
conn.DeviceName = query.Get("name")
// Determine auth mode
authParam := query.Get("auth")
if authParam == "cat" {
conn.AuthMode = AuthModeCAT
// Parse mint URL for CAT auth
conn.MintURL = query.Get("mint")
if conn.MintURL == "" {
err = errors.New("missing mint parameter for CAT auth")
return
}
} else {
conn.AuthMode = AuthModeSecret
// Parse secret for secret-based auth
secret := query.Get("secret")
if secret == "" {
err = errors.New("missing secret parameter")
return
}
var secretBytes []byte
if secretBytes, err = hex.Dec(secret); chk.E(err) {
err = errors.New("invalid secret: must be hex-encoded")
return
}
if len(secretBytes) != 32 {
err = errors.New("secret must be 32 bytes")
return
}
// Create signer from secret
var clientKey *p8k.Signer
if clientKey, err = p8k.New(); chk.E(err) {
return
}
if err = clientKey.InitSec(secretBytes); chk.E(err) {
return
}
conn.clientSecretKey = clientKey
// Generate conversation key using NIP-44 key derivation
if conn.conversationKey, err = encryption.GenerateConversationKey(
clientKey.Sec(),
conn.RelayPubkey,
); chk.E(err) {
return
}
conn.AuthMode = AuthModeSecret
// Parse secret for secret-based auth
secret := query.Get("secret")
if secret == "" {
err = errors.New("missing secret parameter")
return
}
var secretBytes []byte
if secretBytes, err = hex.Dec(secret); chk.E(err) {
err = errors.New("invalid secret: must be hex-encoded")
return
}
if len(secretBytes) != 32 {
err = errors.New("secret must be 32 bytes")
return
}
// Create signer from secret
var clientKey *p8k.Signer
if clientKey, err = p8k.New(); chk.E(err) {
return
}
if err = clientKey.InitSec(secretBytes); chk.E(err) {
return
}
conn.clientSecretKey = clientKey
// Generate conversation key using NIP-44 key derivation
if conn.conversationKey, err = encryption.GenerateConversationKey(
clientKey.Sec(),
conn.RelayPubkey,
); chk.E(err) {
return
}
return
@ -184,23 +163,3 @@ func GenerateConnectionURI(relayPubkey []byte, rendezvousRelay string, deviceNam @@ -184,23 +163,3 @@ func GenerateConnectionURI(relayPubkey []byte, rendezvousRelay string, deviceNam
return
}
// GenerateCATConnectionURI creates a new NRC connection URI for CAT authentication.
func GenerateCATConnectionURI(relayPubkey []byte, rendezvousRelay string, mintURL string) (uri string, err error) {
if len(relayPubkey) != 32 {
err = errors.New("relay public key must be 32 bytes")
return
}
// Build URI
u := &url.URL{
Scheme: "nostr+relayconnect",
Host: string(hex.Enc(relayPubkey)),
}
q := u.Query()
q.Set("relay", rendezvousRelay)
q.Set("auth", "cat")
q.Set("mint", mintURL)
u.RawQuery = q.Encode()
uri = u.String()
return
}

2
pkg/version/version

@ -1 +1 @@ @@ -1 +1 @@
v0.52.2
v0.52.3

Loading…
Cancel
Save