Add Cashu blind signature access tokens (NIP-XX draft)
Implements privacy-preserving bearer tokens for relay access control using Cashu-style blind signatures. Tokens prove whitelist membership without linking issuance to usage. Features: - BDHKE crypto primitives (HashToCurve, Blind, Sign, Unblind, Verify) - Keyset management with weekly rotation - Token format with kind permissions and scope isolation - Generic issuer/verifier with pluggable authorization - HTTP endpoints: POST /cashu/mint, GET /cashu/keysets, GET /cashu/info - ACL adapter bridging ORLY's access control to Cashu AuthzChecker - Stateless revocation via ACL re-check on each token use - Two-token rotation for seamless renewal (max 2 weeks after blacklist) Configuration: - ORLY_CASHU_ENABLED: Enable Cashu tokens - ORLY_CASHU_TOKEN_TTL: Token validity (default: 1 week) - ORLY_CASHU_SCOPES: Allowed scopes (relay, nip46, blossom, api) - ORLY_CASHU_REAUTHORIZE: Re-check ACL on each verification Files: - pkg/cashu/bdhke/: Core blind signature cryptography - pkg/cashu/keyset/: Keyset management and rotation - pkg/cashu/token/: Token format with kind permissions - pkg/cashu/issuer/: Token issuance with authorization - pkg/cashu/verifier/: Token verification with middleware - pkg/interfaces/cashu/: AuthzChecker, KeysetStore interfaces - pkg/bunker/acl_adapter.go: ORLY ACL integration - app/handle-cashu.go: HTTP endpoints - docs/NIP-XX-CASHU-ACCESS-TOKENS.md: Full specification 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -143,6 +143,14 @@ type C struct {
|
||||
BunkerEnabled bool `env:"ORLY_BUNKER_ENABLED" default:"false" usage:"enable NIP-46 bunker signing service (requires WireGuard)"`
|
||||
BunkerPort int `env:"ORLY_BUNKER_PORT" default:"3335" usage:"internal port for bunker WebSocket (only accessible via WireGuard)"`
|
||||
|
||||
// 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"`
|
||||
|
||||
// Cluster replication configuration
|
||||
ClusterPropagatePrivilegedEvents bool `env:"ORLY_CLUSTER_PROPAGATE_PRIVILEGED_EVENTS" default:"true" usage:"propagate privileged events (DMs, gift wraps, etc.) to relay peers for replication"`
|
||||
|
||||
@@ -523,3 +531,54 @@ func (cfg *C) GetWireGuardConfigValues() (
|
||||
cfg.BunkerEnabled,
|
||||
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
|
||||
}
|
||||
|
||||
144
app/handle-cashu.go
Normal file
144
app/handle-cashu.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"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.
|
||||
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:"mint_pubkey"` // Hex-encoded mint public key
|
||||
}
|
||||
|
||||
// handleCashuMint handles POST /cashu/mint - issues a new token.
|
||||
func (s *Server) handleCashuMint(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if Cashu is enabled
|
||||
if s.CashuIssuer == nil {
|
||||
http.Error(w, "Cashu tokens not enabled", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
// Require NIP-98 authentication
|
||||
valid, pubkey, err := httpauth.CheckAuth(r)
|
||||
if chk.E(err) || !valid {
|
||||
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) {
|
||||
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) {
|
||||
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
|
||||
}
|
||||
@@ -34,6 +34,8 @@ 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/ratelimit"
|
||||
"next.orly.dev/pkg/spider"
|
||||
dsync "next.orly.dev/pkg/sync"
|
||||
@@ -85,6 +87,10 @@ type Server struct {
|
||||
wireguardServer *wireguard.Server
|
||||
bunkerServer *bunker.Server
|
||||
subnetPool *wireguard.SubnetPool
|
||||
|
||||
// Cashu access token system (NIP-XX)
|
||||
CashuIssuer *issuer.Issuer
|
||||
CashuVerifier *verifier.Verifier
|
||||
}
|
||||
|
||||
// isIPBlacklisted checks if an IP address is blacklisted using the managed ACL system
|
||||
@@ -350,6 +356,14 @@ func (s *Server) UserInterface() {
|
||||
s.mux.HandleFunc("/api/wireguard/status", s.handleWireGuardStatus)
|
||||
s.mux.HandleFunc("/api/wireguard/audit", s.handleWireGuardAudit)
|
||||
s.mux.HandleFunc("/api/bunker/url", s.handleBunkerURL)
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
// handleFavicon serves orly-favicon.png as favicon.ico
|
||||
|
||||
Reference in New Issue
Block a user