Files
next.orly.dev/app/handle-cashu.go
mleku ea4a54c5e7 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>
2025-12-28 11:30:11 +02:00

145 lines
4.1 KiB
Go

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
}