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:
345
pkg/cashu/token/token.go
Normal file
345
pkg/cashu/token/token.go
Normal file
@@ -0,0 +1,345 @@
|
||||
// Package token implements the Cashu access token format as defined in NIP-XX.
|
||||
// Tokens are privacy-preserving bearer credentials with kind permissions.
|
||||
package token
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Prefix for serialized tokens.
|
||||
const Prefix = "cashuA"
|
||||
|
||||
// Predefined scopes.
|
||||
const (
|
||||
ScopeRelay = "relay" // Standard relay WebSocket access
|
||||
ScopeNIP46 = "nip46" // NIP-46 remote signing / bunker
|
||||
ScopeBlossom = "blossom" // Blossom media server
|
||||
ScopeAPI = "api" // HTTP API access
|
||||
)
|
||||
|
||||
// WildcardKind indicates all kinds are permitted.
|
||||
const WildcardKind = -1
|
||||
|
||||
// Errors.
|
||||
var (
|
||||
ErrInvalidPrefix = errors.New("token: invalid prefix, expected cashuA")
|
||||
ErrInvalidEncoding = errors.New("token: invalid base64url encoding")
|
||||
ErrInvalidJSON = errors.New("token: invalid JSON structure")
|
||||
ErrTokenExpired = errors.New("token: expired")
|
||||
ErrKindNotPermitted = errors.New("token: kind not permitted")
|
||||
ErrScopeMismatch = errors.New("token: scope mismatch")
|
||||
)
|
||||
|
||||
// Token represents a Cashu access token with kind permissions.
|
||||
type Token struct {
|
||||
// Cryptographic fields
|
||||
KeysetID string `json:"k"` // Keyset ID (hex)
|
||||
Secret []byte `json:"s"` // Random secret (32 bytes)
|
||||
Signature []byte `json:"c"` // Blind signature (33 bytes compressed)
|
||||
Pubkey []byte `json:"p"` // User's Nostr pubkey (32 bytes)
|
||||
|
||||
// Metadata
|
||||
Expiry int64 `json:"e"` // Unix timestamp when token expires
|
||||
Scope string `json:"sc"` // Token scope (relay, nip46, etc.)
|
||||
|
||||
// Kind permissions
|
||||
Kinds []int `json:"kinds,omitempty"` // Explicit list of permitted kinds
|
||||
KindRanges [][]int `json:"kind_ranges,omitempty"` // Ranges as [min, max] pairs
|
||||
}
|
||||
|
||||
// tokenJSON is the JSON-serializable form with hex-encoded bytes.
|
||||
type tokenJSON struct {
|
||||
KeysetID string `json:"k"`
|
||||
Secret string `json:"s"`
|
||||
Signature string `json:"c"`
|
||||
Pubkey string `json:"p"`
|
||||
Expiry int64 `json:"e"`
|
||||
Scope string `json:"sc"`
|
||||
Kinds []int `json:"kinds,omitempty"`
|
||||
KindRanges [][]int `json:"kind_ranges,omitempty"`
|
||||
}
|
||||
|
||||
// New creates a new token with the given parameters.
|
||||
func New(keysetID string, secret, signature, pubkey []byte, expiry time.Time, scope string) *Token {
|
||||
return &Token{
|
||||
KeysetID: keysetID,
|
||||
Secret: secret,
|
||||
Signature: signature,
|
||||
Pubkey: pubkey,
|
||||
Expiry: expiry.Unix(),
|
||||
Scope: scope,
|
||||
}
|
||||
}
|
||||
|
||||
// SetKinds sets explicit permitted kinds.
|
||||
// Use WildcardKind (-1) to allow all kinds.
|
||||
func (t *Token) SetKinds(kinds ...int) {
|
||||
t.Kinds = kinds
|
||||
}
|
||||
|
||||
// SetKindRanges sets permitted kind ranges.
|
||||
// Each range is [min, max] inclusive.
|
||||
func (t *Token) SetKindRanges(ranges ...[]int) {
|
||||
t.KindRanges = ranges
|
||||
}
|
||||
|
||||
// AddKindRange adds a single kind range.
|
||||
func (t *Token) AddKindRange(min, max int) {
|
||||
t.KindRanges = append(t.KindRanges, []int{min, max})
|
||||
}
|
||||
|
||||
// IsExpired returns true if the token has expired.
|
||||
func (t *Token) IsExpired() bool {
|
||||
return time.Now().Unix() > t.Expiry
|
||||
}
|
||||
|
||||
// ExpiresAt returns the expiry time.
|
||||
func (t *Token) ExpiresAt() time.Time {
|
||||
return time.Unix(t.Expiry, 0)
|
||||
}
|
||||
|
||||
// TimeRemaining returns the duration until expiry.
|
||||
func (t *Token) TimeRemaining() time.Duration {
|
||||
return time.Until(t.ExpiresAt())
|
||||
}
|
||||
|
||||
// IsKindPermitted checks if a given event kind is permitted by this token.
|
||||
func (t *Token) IsKindPermitted(kind int) bool {
|
||||
// Check for wildcard
|
||||
for _, k := range t.Kinds {
|
||||
if k == WildcardKind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check explicit kinds
|
||||
for _, k := range t.Kinds {
|
||||
if k == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check kind ranges
|
||||
for _, r := range t.KindRanges {
|
||||
if len(r) >= 2 && kind >= r[0] && kind <= r[1] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// If no kinds or ranges specified, check scope defaults
|
||||
if len(t.Kinds) == 0 && len(t.KindRanges) == 0 {
|
||||
return t.defaultKindPermitted(kind)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// defaultKindPermitted returns default permissions based on scope.
|
||||
func (t *Token) defaultKindPermitted(kind int) bool {
|
||||
switch t.Scope {
|
||||
case ScopeRelay:
|
||||
// Default relay scope allows common kinds
|
||||
return true
|
||||
case ScopeNIP46:
|
||||
// NIP-46 scope allows NIP-46 kinds (24133)
|
||||
return kind == 24133
|
||||
case ScopeBlossom:
|
||||
// Blossom scope allows auth kinds
|
||||
return kind == 24242
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// HasWritePermission returns true if any kind is permitted (not read-only).
|
||||
func (t *Token) HasWritePermission() bool {
|
||||
return len(t.Kinds) > 0 || len(t.KindRanges) > 0
|
||||
}
|
||||
|
||||
// IsReadOnly returns true if no kinds are permitted.
|
||||
func (t *Token) IsReadOnly() bool {
|
||||
return !t.HasWritePermission()
|
||||
}
|
||||
|
||||
// MatchesScope checks if the token scope matches the required scope.
|
||||
func (t *Token) MatchesScope(requiredScope string) bool {
|
||||
return t.Scope == requiredScope
|
||||
}
|
||||
|
||||
// PubkeyHex returns the pubkey as a hex string.
|
||||
func (t *Token) PubkeyHex() string {
|
||||
return hex.EncodeToString(t.Pubkey)
|
||||
}
|
||||
|
||||
// Encode serializes the token to the wire format: cashuA<base64url(json)>
|
||||
func (t *Token) Encode() (string, error) {
|
||||
// Convert to JSON-friendly format
|
||||
tj := tokenJSON{
|
||||
KeysetID: t.KeysetID,
|
||||
Secret: hex.EncodeToString(t.Secret),
|
||||
Signature: hex.EncodeToString(t.Signature),
|
||||
Pubkey: hex.EncodeToString(t.Pubkey),
|
||||
Expiry: t.Expiry,
|
||||
Scope: t.Scope,
|
||||
Kinds: t.Kinds,
|
||||
KindRanges: t.KindRanges,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(tj)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("token: failed to encode: %w", err)
|
||||
}
|
||||
|
||||
encoded := base64.RawURLEncoding.EncodeToString(jsonBytes)
|
||||
return Prefix + encoded, nil
|
||||
}
|
||||
|
||||
// Parse decodes a token from the wire format.
|
||||
func Parse(s string) (*Token, error) {
|
||||
// Check prefix
|
||||
if !strings.HasPrefix(s, Prefix) {
|
||||
return nil, ErrInvalidPrefix
|
||||
}
|
||||
|
||||
// Decode base64url
|
||||
encoded := strings.TrimPrefix(s, Prefix)
|
||||
jsonBytes, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidEncoding, err)
|
||||
}
|
||||
|
||||
// Parse JSON
|
||||
var tj tokenJSON
|
||||
if err := json.Unmarshal(jsonBytes, &tj); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidJSON, err)
|
||||
}
|
||||
|
||||
// Decode hex fields
|
||||
secret, err := hex.DecodeString(tj.Secret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token: invalid secret hex: %w", err)
|
||||
}
|
||||
|
||||
signature, err := hex.DecodeString(tj.Signature)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token: invalid signature hex: %w", err)
|
||||
}
|
||||
|
||||
pubkey, err := hex.DecodeString(tj.Pubkey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token: invalid pubkey hex: %w", err)
|
||||
}
|
||||
|
||||
return &Token{
|
||||
KeysetID: tj.KeysetID,
|
||||
Secret: secret,
|
||||
Signature: signature,
|
||||
Pubkey: pubkey,
|
||||
Expiry: tj.Expiry,
|
||||
Scope: tj.Scope,
|
||||
Kinds: tj.Kinds,
|
||||
KindRanges: tj.KindRanges,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ParseFromHeader extracts and parses a token from HTTP headers.
|
||||
// Supports:
|
||||
// - X-Cashu-Token: cashuA...
|
||||
// - Authorization: Cashu cashuA...
|
||||
func ParseFromHeader(header string) (*Token, error) {
|
||||
// Try X-Cashu-Token format (raw token)
|
||||
if strings.HasPrefix(header, Prefix) {
|
||||
return Parse(header)
|
||||
}
|
||||
|
||||
// Try Authorization format
|
||||
if strings.HasPrefix(header, "Cashu ") {
|
||||
tokenStr := strings.TrimPrefix(header, "Cashu ")
|
||||
return Parse(strings.TrimSpace(tokenStr))
|
||||
}
|
||||
|
||||
return nil, ErrInvalidPrefix
|
||||
}
|
||||
|
||||
// Validate performs basic validation on the token.
|
||||
// Does NOT verify the cryptographic signature - use Verifier for that.
|
||||
func (t *Token) Validate() error {
|
||||
if t.IsExpired() {
|
||||
return ErrTokenExpired
|
||||
}
|
||||
|
||||
if len(t.KeysetID) != 14 {
|
||||
return fmt.Errorf("token: invalid keyset ID length: %d", len(t.KeysetID))
|
||||
}
|
||||
|
||||
if len(t.Secret) != 32 {
|
||||
return fmt.Errorf("token: invalid secret length: %d", len(t.Secret))
|
||||
}
|
||||
|
||||
if len(t.Signature) != 33 {
|
||||
return fmt.Errorf("token: invalid signature length: %d", len(t.Signature))
|
||||
}
|
||||
|
||||
if len(t.Pubkey) != 32 {
|
||||
return fmt.Errorf("token: invalid pubkey length: %d", len(t.Pubkey))
|
||||
}
|
||||
|
||||
if t.Scope == "" {
|
||||
return errors.New("token: missing scope")
|
||||
}
|
||||
|
||||
// Validate kind ranges
|
||||
for i, r := range t.KindRanges {
|
||||
if len(r) != 2 {
|
||||
return fmt.Errorf("token: kind range %d must have 2 elements", i)
|
||||
}
|
||||
if r[0] > r[1] {
|
||||
return fmt.Errorf("token: kind range %d min > max: %d > %d", i, r[0], r[1])
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clone creates a copy of the token.
|
||||
func (t *Token) Clone() *Token {
|
||||
clone := &Token{
|
||||
KeysetID: t.KeysetID,
|
||||
Secret: make([]byte, len(t.Secret)),
|
||||
Signature: make([]byte, len(t.Signature)),
|
||||
Pubkey: make([]byte, len(t.Pubkey)),
|
||||
Expiry: t.Expiry,
|
||||
Scope: t.Scope,
|
||||
}
|
||||
|
||||
copy(clone.Secret, t.Secret)
|
||||
copy(clone.Signature, t.Signature)
|
||||
copy(clone.Pubkey, t.Pubkey)
|
||||
|
||||
if len(t.Kinds) > 0 {
|
||||
clone.Kinds = make([]int, len(t.Kinds))
|
||||
copy(clone.Kinds, t.Kinds)
|
||||
}
|
||||
|
||||
if len(t.KindRanges) > 0 {
|
||||
clone.KindRanges = make([][]int, len(t.KindRanges))
|
||||
for i, r := range t.KindRanges {
|
||||
clone.KindRanges[i] = make([]int, len(r))
|
||||
copy(clone.KindRanges[i], r)
|
||||
}
|
||||
}
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
// String returns the encoded token string.
|
||||
func (t *Token) String() string {
|
||||
s, _ := t.Encode()
|
||||
return s
|
||||
}
|
||||
336
pkg/cashu/token/token_test.go
Normal file
336
pkg/cashu/token/token_test.go
Normal file
@@ -0,0 +1,336 @@
|
||||
package token
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func makeTestToken() *Token {
|
||||
secret := make([]byte, 32)
|
||||
signature := make([]byte, 33)
|
||||
pubkey := make([]byte, 32)
|
||||
|
||||
for i := range secret {
|
||||
secret[i] = byte(i)
|
||||
}
|
||||
for i := range signature {
|
||||
signature[i] = byte(i + 32)
|
||||
}
|
||||
for i := range pubkey {
|
||||
pubkey[i] = byte(i + 64)
|
||||
}
|
||||
|
||||
signature[0] = 0x02 // Valid compressed point prefix
|
||||
|
||||
return New(
|
||||
"0a1b2c3d4e5f67",
|
||||
secret,
|
||||
signature,
|
||||
pubkey,
|
||||
time.Now().Add(time.Hour),
|
||||
ScopeRelay,
|
||||
)
|
||||
}
|
||||
|
||||
func TestTokenEncodeDecode(t *testing.T) {
|
||||
tok := makeTestToken()
|
||||
tok.SetKinds(0, 1, 3, 7)
|
||||
tok.AddKindRange(30000, 39999)
|
||||
|
||||
encoded, err := tok.Encode()
|
||||
if err != nil {
|
||||
t.Fatalf("Encode failed: %v", err)
|
||||
}
|
||||
|
||||
// Should have correct prefix
|
||||
if encoded[:6] != Prefix {
|
||||
t.Errorf("Encoded token should start with %s, got %s", Prefix, encoded[:6])
|
||||
}
|
||||
|
||||
// Decode
|
||||
decoded, err := Parse(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed: %v", err)
|
||||
}
|
||||
|
||||
// Compare fields
|
||||
if decoded.KeysetID != tok.KeysetID {
|
||||
t.Errorf("KeysetID mismatch: %s != %s", decoded.KeysetID, tok.KeysetID)
|
||||
}
|
||||
if hex.EncodeToString(decoded.Secret) != hex.EncodeToString(tok.Secret) {
|
||||
t.Error("Secret mismatch")
|
||||
}
|
||||
if hex.EncodeToString(decoded.Signature) != hex.EncodeToString(tok.Signature) {
|
||||
t.Error("Signature mismatch")
|
||||
}
|
||||
if hex.EncodeToString(decoded.Pubkey) != hex.EncodeToString(tok.Pubkey) {
|
||||
t.Error("Pubkey mismatch")
|
||||
}
|
||||
if decoded.Expiry != tok.Expiry {
|
||||
t.Errorf("Expiry mismatch: %d != %d", decoded.Expiry, tok.Expiry)
|
||||
}
|
||||
if decoded.Scope != tok.Scope {
|
||||
t.Errorf("Scope mismatch: %s != %s", decoded.Scope, tok.Scope)
|
||||
}
|
||||
|
||||
// Check kinds
|
||||
if len(decoded.Kinds) != len(tok.Kinds) {
|
||||
t.Errorf("Kinds length mismatch: %d != %d", len(decoded.Kinds), len(tok.Kinds))
|
||||
}
|
||||
for i, k := range decoded.Kinds {
|
||||
if k != tok.Kinds[i] {
|
||||
t.Errorf("Kinds[%d] mismatch: %d != %d", i, k, tok.Kinds[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Check kind ranges
|
||||
if len(decoded.KindRanges) != len(tok.KindRanges) {
|
||||
t.Errorf("KindRanges length mismatch: %d != %d", len(decoded.KindRanges), len(tok.KindRanges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenKindPermissions(t *testing.T) {
|
||||
tok := makeTestToken()
|
||||
tok.SetKinds(0, 1, 3)
|
||||
tok.AddKindRange(30000, 39999)
|
||||
|
||||
tests := []struct {
|
||||
kind int
|
||||
expected bool
|
||||
}{
|
||||
{0, true}, // Explicit kind
|
||||
{1, true}, // Explicit kind
|
||||
{3, true}, // Explicit kind
|
||||
{2, false}, // Not in list
|
||||
{7, false}, // Not in list
|
||||
{30000, true}, // Start of range
|
||||
{35000, true}, // Middle of range
|
||||
{39999, true}, // End of range
|
||||
{29999, false}, // Just before range
|
||||
{40000, false}, // Just after range
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := tok.IsKindPermitted(tt.kind)
|
||||
if result != tt.expected {
|
||||
t.Errorf("IsKindPermitted(%d) = %v, want %v", tt.kind, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenWildcardKind(t *testing.T) {
|
||||
tok := makeTestToken()
|
||||
tok.SetKinds(WildcardKind)
|
||||
|
||||
// All kinds should be permitted
|
||||
for _, kind := range []int{0, 1, 100, 1000, 30000, 65535} {
|
||||
if !tok.IsKindPermitted(kind) {
|
||||
t.Errorf("Wildcard should permit kind %d", kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenReadOnly(t *testing.T) {
|
||||
tok := makeTestToken()
|
||||
|
||||
// No kinds set - should be read-only by kinds check
|
||||
if tok.HasWritePermission() {
|
||||
t.Error("Token with no kinds should not have write permission")
|
||||
}
|
||||
|
||||
tok.SetKinds(1)
|
||||
if !tok.HasWritePermission() {
|
||||
t.Error("Token with kinds should have write permission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenExpiry(t *testing.T) {
|
||||
// Token that expires in 1 hour
|
||||
tok := makeTestToken()
|
||||
if tok.IsExpired() {
|
||||
t.Error("Token should not be expired yet")
|
||||
}
|
||||
|
||||
// Token that expired 1 hour ago
|
||||
tok.Expiry = time.Now().Add(-time.Hour).Unix()
|
||||
if !tok.IsExpired() {
|
||||
t.Error("Token should be expired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenTimeRemaining(t *testing.T) {
|
||||
tok := makeTestToken()
|
||||
remaining := tok.TimeRemaining()
|
||||
|
||||
// Should be close to 1 hour
|
||||
if remaining < 59*time.Minute || remaining > 61*time.Minute {
|
||||
t.Errorf("TimeRemaining = %v, expected ~1 hour", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenValidate(t *testing.T) {
|
||||
// Valid token
|
||||
tok := makeTestToken()
|
||||
if err := tok.Validate(); err != nil {
|
||||
t.Errorf("Validate failed for valid token: %v", err)
|
||||
}
|
||||
|
||||
// Expired token
|
||||
expired := makeTestToken()
|
||||
expired.Expiry = time.Now().Add(-time.Hour).Unix()
|
||||
if err := expired.Validate(); err != ErrTokenExpired {
|
||||
t.Errorf("Validate should return ErrTokenExpired, got %v", err)
|
||||
}
|
||||
|
||||
// Invalid keyset ID
|
||||
badKeyset := makeTestToken()
|
||||
badKeyset.KeysetID = "short"
|
||||
if err := badKeyset.Validate(); err == nil {
|
||||
t.Error("Validate should fail for short keyset ID")
|
||||
}
|
||||
|
||||
// Invalid secret length
|
||||
badSecret := makeTestToken()
|
||||
badSecret.Secret = []byte{1, 2, 3}
|
||||
if err := badSecret.Validate(); err == nil {
|
||||
t.Error("Validate should fail for wrong secret length")
|
||||
}
|
||||
|
||||
// Invalid kind range
|
||||
badRange := makeTestToken()
|
||||
badRange.KindRanges = [][]int{{100, 50}} // min > max
|
||||
if err := badRange.Validate(); err == nil {
|
||||
t.Error("Validate should fail for invalid kind range")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFromHeader(t *testing.T) {
|
||||
tok := makeTestToken()
|
||||
encoded, _ := tok.Encode()
|
||||
|
||||
// Test X-Cashu-Token format
|
||||
parsed, err := ParseFromHeader(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFromHeader failed for raw token: %v", err)
|
||||
}
|
||||
if parsed.KeysetID != tok.KeysetID {
|
||||
t.Error("Parsed token has wrong KeysetID")
|
||||
}
|
||||
|
||||
// Test Authorization format
|
||||
parsed, err = ParseFromHeader("Cashu " + encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFromHeader failed for Authorization format: %v", err)
|
||||
}
|
||||
if parsed.KeysetID != tok.KeysetID {
|
||||
t.Error("Parsed token has wrong KeysetID")
|
||||
}
|
||||
|
||||
// Test invalid format
|
||||
_, err = ParseFromHeader("Bearer xyz")
|
||||
if err != ErrInvalidPrefix {
|
||||
t.Errorf("Expected ErrInvalidPrefix, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenClone(t *testing.T) {
|
||||
tok := makeTestToken()
|
||||
tok.SetKinds(1, 2, 3)
|
||||
tok.AddKindRange(100, 200)
|
||||
|
||||
clone := tok.Clone()
|
||||
|
||||
// Modify original
|
||||
tok.Secret[0] = 0xFF
|
||||
tok.Kinds[0] = 999
|
||||
tok.KindRanges[0][0] = 999
|
||||
|
||||
// Clone should be unchanged
|
||||
if clone.Secret[0] == 0xFF {
|
||||
t.Error("Clone secret was modified when original changed")
|
||||
}
|
||||
if clone.Kinds[0] == 999 {
|
||||
t.Error("Clone kinds was modified when original changed")
|
||||
}
|
||||
if clone.KindRanges[0][0] == 999 {
|
||||
t.Error("Clone kind ranges was modified when original changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenMatchesScope(t *testing.T) {
|
||||
tok := makeTestToken()
|
||||
tok.Scope = ScopeNIP46
|
||||
|
||||
if !tok.MatchesScope(ScopeNIP46) {
|
||||
t.Error("Should match ScopeNIP46")
|
||||
}
|
||||
if tok.MatchesScope(ScopeRelay) {
|
||||
t.Error("Should not match ScopeRelay")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenPubkeyHex(t *testing.T) {
|
||||
tok := makeTestToken()
|
||||
hexPubkey := tok.PubkeyHex()
|
||||
|
||||
// Should be 64 characters (32 bytes * 2)
|
||||
if len(hexPubkey) != 64 {
|
||||
t.Errorf("PubkeyHex length = %d, want 64", len(hexPubkey))
|
||||
}
|
||||
|
||||
// Should decode back to original
|
||||
decoded, err := hex.DecodeString(hexPubkey)
|
||||
if err != nil {
|
||||
t.Fatalf("PubkeyHex is not valid hex: %v", err)
|
||||
}
|
||||
for i, b := range decoded {
|
||||
if b != tok.Pubkey[i] {
|
||||
t.Errorf("PubkeyHex[%d] mismatch", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenString(t *testing.T) {
|
||||
tok := makeTestToken()
|
||||
s := tok.String()
|
||||
|
||||
if s[:6] != Prefix {
|
||||
t.Errorf("String() should start with prefix, got %s", s[:6])
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkTokenEncode(b *testing.B) {
|
||||
tok := makeTestToken()
|
||||
tok.SetKinds(0, 1, 3, 7)
|
||||
tok.AddKindRange(30000, 39999)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
tok.Encode()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkTokenParse(b *testing.B) {
|
||||
tok := makeTestToken()
|
||||
tok.SetKinds(0, 1, 3, 7)
|
||||
tok.AddKindRange(30000, 39999)
|
||||
encoded, _ := tok.Encode()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
Parse(encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkTokenIsKindPermitted(b *testing.B) {
|
||||
tok := makeTestToken()
|
||||
tok.SetKinds(0, 1, 3, 7, 10, 20, 30, 40, 50)
|
||||
tok.AddKindRange(30000, 39999)
|
||||
tok.AddKindRange(20000, 29999)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
tok.IsKindPermitted(35000)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user