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>
145 lines
4.1 KiB
Go
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
|
|
}
|