Files
next.orly.dev/pkg/cashu/issuer/issuer.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

289 lines
7.5 KiB
Go

// Package issuer implements Cashu token issuance with authorization checks.
package issuer
import (
"context"
"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"`
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 {
return MintInfo{
Name: name,
Version: "NIP-XX/1",
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
}