You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
288 lines
7.5 KiB
288 lines
7.5 KiB
// 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 |
|
}
|
|
|