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:
2025-12-28 11:30:11 +02:00
parent 2eb523c161
commit ea4a54c5e7
18 changed files with 4129 additions and 0 deletions

293
pkg/cashu/bdhke/bdhke.go Normal file
View File

@@ -0,0 +1,293 @@
// Package bdhke implements Blind Diffie-Hellman Key Exchange for Cashu-style tokens.
// This is the core cryptographic primitive used in ecash blind signatures.
//
// The protocol allows a mint (issuer) to sign a message without knowing what
// it's signing, providing unlinkability between token issuance and redemption.
//
// Protocol overview:
// 1. User creates secret x, computes Y = HashToCurve(x)
// 2. User blinds: B_ = Y + r*G (r is random blinding factor)
// 3. Mint signs: C_ = k*B_ (k is mint's private key)
// 4. User unblinds: C = C_ - r*K (K is mint's public key)
// 5. Token is (x, C) - mint can verify: C == k*HashToCurve(x)
//
// Reference: https://github.com/cashubtc/nuts/blob/main/00.md
package bdhke
import (
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
)
// DomainSeparator is prepended to messages before hashing to prevent
// cross-protocol attacks.
const DomainSeparator = "Secp256k1_HashToCurve_Cashu_"
// Errors
var (
ErrHashToCurveFailed = errors.New("bdhke: hash to curve failed after max iterations")
ErrInvalidPoint = errors.New("bdhke: invalid curve point")
ErrInvalidPrivateKey = errors.New("bdhke: invalid private key")
ErrSignatureMismatch = errors.New("bdhke: signature verification failed")
)
// HashToCurve deterministically maps a message to a point on secp256k1.
// Uses the try-and-increment method as specified in Cashu NUT-00.
//
// Algorithm:
// 1. Compute msg_hash = SHA256(domain_separator || message)
// 2. For counter in 0..65536:
// a. Compute hash = SHA256(msg_hash || counter)
// b. Try to parse 02 || hash as compressed point
// c. If valid point, return it
// 3. Fail if no valid point found (extremely unlikely)
func HashToCurve(message []byte) (*secp256k1.PublicKey, error) {
// Hash the message with domain separator
msgHash := sha256.Sum256(append([]byte(DomainSeparator), message...))
// Try up to 65536 iterations (in practice, ~50% chance on first try)
counterBytes := make([]byte, 4)
for counter := uint32(0); counter < 65536; counter++ {
binary.LittleEndian.PutUint32(counterBytes, counter)
// Hash again with counter
toHash := append(msgHash[:], counterBytes...)
hash := sha256.Sum256(toHash)
// Try to parse as compressed point with 02 prefix (even y)
compressed := make([]byte, 33)
compressed[0] = 0x02
copy(compressed[1:], hash[:])
pk, err := secp256k1.ParsePubKey(compressed)
if err == nil {
return pk, nil
}
}
return nil, ErrHashToCurveFailed
}
// BlindResult contains the blinding operation result.
type BlindResult struct {
B *secp256k1.PublicKey // Blinded message B_ = Y + r*G
R *secp256k1.PrivateKey // Blinding factor (keep secret until unblinding)
Y *secp256k1.PublicKey // Original point Y = HashToCurve(secret)
}
// Blind creates a blinded message from a secret.
// The blinding factor r is generated randomly and must be kept secret
// until the signature is received and needs to be unblinded.
//
// B_ = Y + r*G where:
// - Y = HashToCurve(secret)
// - r = random scalar
// - G = generator point
func Blind(secret []byte) (*BlindResult, error) {
// Compute Y = HashToCurve(secret)
Y, err := HashToCurve(secret)
if err != nil {
return nil, fmt.Errorf("blind: %w", err)
}
// Generate random blinding factor r
rBytes := make([]byte, 32)
if _, err := rand.Read(rBytes); err != nil {
return nil, fmt.Errorf("blind: failed to generate random: %w", err)
}
r := secp256k1.PrivKeyFromBytes(rBytes)
// Compute r*G (blinding factor times generator)
rG := new(secp256k1.JacobianPoint)
secp256k1.ScalarBaseMultNonConst(&r.Key, rG)
// Convert Y to Jacobian
yJ := new(secp256k1.JacobianPoint)
Y.AsJacobian(yJ)
// Compute B_ = Y + r*G
bJ := new(secp256k1.JacobianPoint)
secp256k1.AddNonConst(yJ, rG, bJ)
bJ.ToAffine()
// Convert back to PublicKey
B := secp256k1.NewPublicKey(&bJ.X, &bJ.Y)
return &BlindResult{
B: B,
R: r,
Y: Y,
}, nil
}
// BlindWithFactor creates a blinded message using a provided blinding factor.
// This is useful for testing or when the blinding factor needs to be deterministic.
func BlindWithFactor(secret []byte, rBytes []byte) (*BlindResult, error) {
if len(rBytes) != 32 {
return nil, errors.New("blind: blinding factor must be 32 bytes")
}
// Compute Y = HashToCurve(secret)
Y, err := HashToCurve(secret)
if err != nil {
return nil, fmt.Errorf("blind: %w", err)
}
r := secp256k1.PrivKeyFromBytes(rBytes)
// Compute r*G
rG := new(secp256k1.JacobianPoint)
secp256k1.ScalarBaseMultNonConst(&r.Key, rG)
// Convert Y to Jacobian
yJ := new(secp256k1.JacobianPoint)
Y.AsJacobian(yJ)
// Compute B_ = Y + r*G
bJ := new(secp256k1.JacobianPoint)
secp256k1.AddNonConst(yJ, rG, bJ)
bJ.ToAffine()
B := secp256k1.NewPublicKey(&bJ.X, &bJ.Y)
return &BlindResult{
B: B,
R: r,
Y: Y,
}, nil
}
// Sign creates a blinded signature on a blinded message.
// This is performed by the mint using its private key k.
//
// C_ = k * B_ where:
// - k = mint's private key scalar
// - B_ = blinded message from user
func Sign(B *secp256k1.PublicKey, k *secp256k1.PrivateKey) (*secp256k1.PublicKey, error) {
if B == nil || k == nil {
return nil, ErrInvalidPoint
}
// Convert B to Jacobian
bJ := new(secp256k1.JacobianPoint)
B.AsJacobian(bJ)
// Compute C_ = k * B_
cJ := new(secp256k1.JacobianPoint)
secp256k1.ScalarMultNonConst(&k.Key, bJ, cJ)
cJ.ToAffine()
C := secp256k1.NewPublicKey(&cJ.X, &cJ.Y)
return C, nil
}
// Unblind removes the blinding factor from the signature.
// This is performed by the user after receiving the blinded signature.
//
// C = C_ - r*K where:
// - C_ = blinded signature from mint
// - r = original blinding factor
// - K = mint's public key
func Unblind(C_ *secp256k1.PublicKey, r *secp256k1.PrivateKey, K *secp256k1.PublicKey) (*secp256k1.PublicKey, error) {
if C_ == nil || r == nil || K == nil {
return nil, ErrInvalidPoint
}
// Compute r*K
kJ := new(secp256k1.JacobianPoint)
K.AsJacobian(kJ)
rK := new(secp256k1.JacobianPoint)
secp256k1.ScalarMultNonConst(&r.Key, kJ, rK)
// Negate r*K to get -r*K
rK.Y.Negate(1)
rK.Y.Normalize()
// Convert C_ to Jacobian
c_J := new(secp256k1.JacobianPoint)
C_.AsJacobian(c_J)
// Compute C = C_ + (-r*K) = C_ - r*K
cJ := new(secp256k1.JacobianPoint)
secp256k1.AddNonConst(c_J, rK, cJ)
cJ.ToAffine()
C := secp256k1.NewPublicKey(&cJ.X, &cJ.Y)
return C, nil
}
// Verify checks that a token's signature is valid.
// The mint uses this to verify tokens during redemption.
//
// Checks: C == k * HashToCurve(secret) where:
// - C = unblinded signature from token
// - k = mint's private key
// - secret = token's secret value
func Verify(secret []byte, C *secp256k1.PublicKey, k *secp256k1.PrivateKey) (bool, error) {
if C == nil || k == nil {
return false, ErrInvalidPoint
}
// Compute Y = HashToCurve(secret)
Y, err := HashToCurve(secret)
if err != nil {
return false, err
}
// Compute expected = k * Y
yJ := new(secp256k1.JacobianPoint)
Y.AsJacobian(yJ)
expectedJ := new(secp256k1.JacobianPoint)
secp256k1.ScalarMultNonConst(&k.Key, yJ, expectedJ)
expectedJ.ToAffine()
expected := secp256k1.NewPublicKey(&expectedJ.X, &expectedJ.Y)
// Compare C with expected
return C.IsEqual(expected), nil
}
// VerifyWithPublicKey verifies a token without knowing the private key.
// This requires a DLEQ proof (not yet implemented).
// For now, returns error indicating this is not supported.
func VerifyWithPublicKey(secret []byte, C *secp256k1.PublicKey, K *secp256k1.PublicKey) (bool, error) {
return false, errors.New("bdhke: DLEQ proof verification not implemented")
}
// GenerateKeypair generates a new mint keypair.
func GenerateKeypair() (*secp256k1.PrivateKey, *secp256k1.PublicKey, error) {
keyBytes := make([]byte, 32)
if _, err := rand.Read(keyBytes); err != nil {
return nil, nil, fmt.Errorf("generate keypair: %w", err)
}
privKey := secp256k1.PrivKeyFromBytes(keyBytes)
pubKey := privKey.PubKey()
return privKey, pubKey, nil
}
// SecretFromBytes creates a secret suitable for token issuance.
// The secret should be 32 bytes of random data.
func SecretFromBytes(data []byte) []byte {
// Just return a copy - secrets are arbitrary byte strings
secret := make([]byte, len(data))
copy(secret, data)
return secret
}
// GenerateSecret creates a new random 32-byte secret.
func GenerateSecret() ([]byte, error) {
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return nil, fmt.Errorf("generate secret: %w", err)
}
return secret, nil
}

View File

@@ -0,0 +1,348 @@
package bdhke
import (
"bytes"
"encoding/hex"
"testing"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
)
// Test vectors from Cashu NUT-00 specification
// https://github.com/cashubtc/nuts/blob/main/00.md
func TestHashToCurve(t *testing.T) {
tests := []struct {
name string
message string
expected string // Expected compressed public key in hex
}{
{
name: "test vector 1",
message: "0000000000000000000000000000000000000000000000000000000000000000",
expected: "024cce997d3b518f739663b757deaec95bcd9473c30a14ac2fd04023a739d1a725",
},
{
name: "test vector 2",
message: "0000000000000000000000000000000000000000000000000000000000000001",
expected: "022e7158e11c9506f1aa4248bf531298daa7febd6194f003edcd9b93ade6253acf",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msgBytes, err := hex.DecodeString(tt.message)
if err != nil {
t.Fatalf("failed to decode message: %v", err)
}
point, err := HashToCurve(msgBytes)
if err != nil {
t.Fatalf("HashToCurve failed: %v", err)
}
got := hex.EncodeToString(point.SerializeCompressed())
if got != tt.expected {
t.Errorf("HashToCurve(%s) = %s, want %s", tt.message, got, tt.expected)
}
})
}
}
func TestBlindSignUnblindVerify(t *testing.T) {
// Generate mint keypair
k, K, err := GenerateKeypair()
if err != nil {
t.Fatalf("failed to generate keypair: %v", err)
}
// Generate a secret
secret, err := GenerateSecret()
if err != nil {
t.Fatalf("failed to generate secret: %v", err)
}
// User blinds the secret
blindResult, err := Blind(secret)
if err != nil {
t.Fatalf("Blind failed: %v", err)
}
// Mint signs the blinded message
C_, err := Sign(blindResult.B, k)
if err != nil {
t.Fatalf("Sign failed: %v", err)
}
// User unblinds the signature
C, err := Unblind(C_, blindResult.R, K)
if err != nil {
t.Fatalf("Unblind failed: %v", err)
}
// Verify the token
valid, err := Verify(secret, C, k)
if err != nil {
t.Fatalf("Verify failed: %v", err)
}
if !valid {
t.Error("Verify returned false, expected true")
}
}
func TestVerifyWrongSecret(t *testing.T) {
k, K, _ := GenerateKeypair()
secret1, _ := GenerateSecret()
secret2, _ := GenerateSecret()
// Create token with secret1
blindResult, _ := Blind(secret1)
C_, _ := Sign(blindResult.B, k)
C, _ := Unblind(C_, blindResult.R, K)
// Try to verify with secret2
valid, err := Verify(secret2, C, k)
if err != nil {
t.Fatalf("Verify failed: %v", err)
}
if valid {
t.Error("Verify returned true for wrong secret")
}
}
func TestVerifyWrongKey(t *testing.T) {
k1, K1, _ := GenerateKeypair()
k2, _, _ := GenerateKeypair()
secret, _ := GenerateSecret()
// Create token with k1
blindResult, _ := Blind(secret)
C_, _ := Sign(blindResult.B, k1)
C, _ := Unblind(C_, blindResult.R, K1)
// Try to verify with k2
valid, err := Verify(secret, C, k2)
if err != nil {
t.Fatalf("Verify failed: %v", err)
}
if valid {
t.Error("Verify returned true for wrong key")
}
}
func TestBlindWithFactor(t *testing.T) {
k, K, _ := GenerateKeypair()
secret := []byte("test secret message")
// Use deterministic blinding factor
rBytes := make([]byte, 32)
for i := range rBytes {
rBytes[i] = byte(i)
}
blindResult, err := BlindWithFactor(secret, rBytes)
if err != nil {
t.Fatalf("BlindWithFactor failed: %v", err)
}
// Complete the protocol
C_, _ := Sign(blindResult.B, k)
C, _ := Unblind(C_, blindResult.R, K)
valid, _ := Verify(secret, C, k)
if !valid {
t.Error("BlindWithFactor: verification failed")
}
// Do it again with same factor - should get same B
blindResult2, _ := BlindWithFactor(secret, rBytes)
if !bytes.Equal(blindResult.B.SerializeCompressed(), blindResult2.B.SerializeCompressed()) {
t.Error("BlindWithFactor not deterministic")
}
}
func TestHashToCurveDeterministic(t *testing.T) {
message := []byte("deterministic test")
p1, err := HashToCurve(message)
if err != nil {
t.Fatalf("HashToCurve failed: %v", err)
}
p2, err := HashToCurve(message)
if err != nil {
t.Fatalf("HashToCurve failed: %v", err)
}
if !p1.IsEqual(p2) {
t.Error("HashToCurve not deterministic")
}
}
func TestSignNilInputs(t *testing.T) {
k, _, _ := GenerateKeypair()
_, err := Sign(nil, k)
if err == nil {
t.Error("Sign(nil, k) should error")
}
B, _ := HashToCurve([]byte("test"))
_, err = Sign(B, nil)
if err == nil {
t.Error("Sign(B, nil) should error")
}
}
func TestUnblindNilInputs(t *testing.T) {
k, K, _ := GenerateKeypair()
secret, _ := GenerateSecret()
blindResult, _ := Blind(secret)
C_, _ := Sign(blindResult.B, k)
_, err := Unblind(nil, blindResult.R, K)
if err == nil {
t.Error("Unblind(nil, r, K) should error")
}
_, err = Unblind(C_, nil, K)
if err == nil {
t.Error("Unblind(C_, nil, K) should error")
}
_, err = Unblind(C_, blindResult.R, nil)
if err == nil {
t.Error("Unblind(C_, r, nil) should error")
}
}
func TestVerifyNilInputs(t *testing.T) {
k, K, _ := GenerateKeypair()
secret, _ := GenerateSecret()
blindResult, _ := Blind(secret)
C_, _ := Sign(blindResult.B, k)
C, _ := Unblind(C_, blindResult.R, K)
_, err := Verify(secret, nil, k)
if err == nil {
t.Error("Verify(secret, nil, k) should error")
}
_, err = Verify(secret, C, nil)
if err == nil {
t.Error("Verify(secret, C, nil) should error")
}
}
// Benchmark functions
func BenchmarkHashToCurve(b *testing.B) {
secret, _ := GenerateSecret()
b.ResetTimer()
for i := 0; i < b.N; i++ {
HashToCurve(secret)
}
}
func BenchmarkBlind(b *testing.B) {
secret, _ := GenerateSecret()
b.ResetTimer()
for i := 0; i < b.N; i++ {
Blind(secret)
}
}
func BenchmarkSign(b *testing.B) {
k, _, _ := GenerateKeypair()
secret, _ := GenerateSecret()
blindResult, _ := Blind(secret)
b.ResetTimer()
for i := 0; i < b.N; i++ {
Sign(blindResult.B, k)
}
}
func BenchmarkUnblind(b *testing.B) {
k, K, _ := GenerateKeypair()
secret, _ := GenerateSecret()
blindResult, _ := Blind(secret)
C_, _ := Sign(blindResult.B, k)
b.ResetTimer()
for i := 0; i < b.N; i++ {
Unblind(C_, blindResult.R, K)
}
}
func BenchmarkVerify(b *testing.B) {
k, K, _ := GenerateKeypair()
secret, _ := GenerateSecret()
blindResult, _ := Blind(secret)
C_, _ := Sign(blindResult.B, k)
C, _ := Unblind(C_, blindResult.R, K)
b.ResetTimer()
for i := 0; i < b.N; i++ {
Verify(secret, C, k)
}
}
func BenchmarkFullProtocol(b *testing.B) {
k, K, _ := GenerateKeypair()
b.ResetTimer()
for i := 0; i < b.N; i++ {
secret, _ := GenerateSecret()
blindResult, _ := Blind(secret)
C_, _ := Sign(blindResult.B, k)
C, _ := Unblind(C_, blindResult.R, K)
Verify(secret, C, k)
}
}
// Test that serialization/deserialization works correctly
func TestPointSerialization(t *testing.T) {
k, K, _ := GenerateKeypair()
secret, _ := GenerateSecret()
blindResult, _ := Blind(secret)
C_, _ := Sign(blindResult.B, k)
C, _ := Unblind(C_, blindResult.R, K)
// Serialize and deserialize C
serialized := C.SerializeCompressed()
deserialized, err := secp256k1.ParsePubKey(serialized)
if err != nil {
t.Fatalf("failed to parse serialized point: %v", err)
}
// Verify with deserialized point
valid, err := Verify(secret, deserialized, k)
if err != nil {
t.Fatalf("Verify failed: %v", err)
}
if !valid {
t.Error("Verify failed after point serialization round-trip")
}
// Same for K
kSerialized := K.SerializeCompressed()
kDeserialized, err := secp256k1.ParsePubKey(kSerialized)
if err != nil {
t.Fatalf("failed to parse serialized K: %v", err)
}
// Unblind with deserialized K
C2, err := Unblind(C_, blindResult.R, kDeserialized)
if err != nil {
t.Fatalf("Unblind with deserialized K failed: %v", err)
}
if !C.IsEqual(C2) {
t.Error("Unblind result differs after K round-trip")
}
}

288
pkg/cashu/issuer/issuer.go Normal file
View File

@@ -0,0 +1,288 @@
// 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
}

View File

@@ -0,0 +1,296 @@
package issuer
import (
"context"
"testing"
"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"
)
func setupIssuer(authz cashuiface.AuthzChecker) (*Issuer, *keyset.Manager) {
store := keyset.NewMemoryStore()
manager := keyset.NewManager(store, keyset.DefaultActiveWindow, keyset.DefaultVerifyWindow)
manager.Init()
config := DefaultConfig()
issuer := New(manager, authz, config)
return issuer, manager
}
func TestIssueSuccess(t *testing.T) {
issuer, _ := setupIssuer(cashuiface.AllowAllChecker{})
// Generate user keypair
secret, err := bdhke.GenerateSecret()
if err != nil {
t.Fatalf("GenerateSecret failed: %v", err)
}
// Generate blinded message
blindResult, err := bdhke.Blind(secret)
if err != nil {
t.Fatalf("Blind failed: %v", err)
}
// User pubkey
pubkey := make([]byte, 32)
for i := range pubkey {
pubkey[i] = byte(i)
}
req := &IssueRequest{
BlindedMessage: blindResult.B.SerializeCompressed(),
Pubkey: pubkey,
Scope: token.ScopeRelay,
Kinds: []int{0, 1, 3},
KindRanges: [][]int{{30000, 39999}},
}
resp, err := issuer.Issue(context.Background(), req, "127.0.0.1")
if err != nil {
t.Fatalf("Issue failed: %v", err)
}
// Check response
if len(resp.BlindedSignature) != 33 {
t.Errorf("BlindedSignature length = %d, want 33", len(resp.BlindedSignature))
}
if resp.KeysetID == "" {
t.Error("KeysetID is empty")
}
if resp.Expiry <= time.Now().Unix() {
t.Error("Expiry should be in the future")
}
if len(resp.MintPubkey) != 33 {
t.Errorf("MintPubkey length = %d, want 33", len(resp.MintPubkey))
}
}
func TestIssueAuthorizationDenied(t *testing.T) {
issuer, _ := setupIssuer(cashuiface.DenyAllChecker{})
secret, _ := bdhke.GenerateSecret()
blindResult, _ := bdhke.Blind(secret)
pubkey := make([]byte, 32)
req := &IssueRequest{
BlindedMessage: blindResult.B.SerializeCompressed(),
Pubkey: pubkey,
Scope: token.ScopeRelay,
}
_, err := issuer.Issue(context.Background(), req, "127.0.0.1")
if err == nil {
t.Error("Issue should fail when authorization is denied")
}
}
func TestIssueInvalidBlindedMessage(t *testing.T) {
issuer, _ := setupIssuer(cashuiface.AllowAllChecker{})
pubkey := make([]byte, 32)
req := &IssueRequest{
BlindedMessage: []byte{1, 2, 3}, // Invalid
Pubkey: pubkey,
Scope: token.ScopeRelay,
}
_, err := issuer.Issue(context.Background(), req, "127.0.0.1")
if err == nil {
t.Error("Issue should fail with invalid blinded message")
}
}
func TestIssueInvalidPubkey(t *testing.T) {
issuer, _ := setupIssuer(cashuiface.AllowAllChecker{})
secret, _ := bdhke.GenerateSecret()
blindResult, _ := bdhke.Blind(secret)
req := &IssueRequest{
BlindedMessage: blindResult.B.SerializeCompressed(),
Pubkey: []byte{1, 2, 3}, // Invalid length
Scope: token.ScopeRelay,
}
_, err := issuer.Issue(context.Background(), req, "127.0.0.1")
if err == nil {
t.Error("Issue should fail with invalid pubkey")
}
}
func TestIssueInvalidScope(t *testing.T) {
store := keyset.NewMemoryStore()
manager := keyset.NewManager(store, keyset.DefaultActiveWindow, keyset.DefaultVerifyWindow)
manager.Init()
config := DefaultConfig()
config.AllowedScopes = []string{token.ScopeRelay} // Only relay scope allowed
issuer := New(manager, cashuiface.AllowAllChecker{}, config)
secret, _ := bdhke.GenerateSecret()
blindResult, _ := bdhke.Blind(secret)
pubkey := make([]byte, 32)
req := &IssueRequest{
BlindedMessage: blindResult.B.SerializeCompressed(),
Pubkey: pubkey,
Scope: token.ScopeNIP46, // Not allowed
}
_, err := issuer.Issue(context.Background(), req, "127.0.0.1")
if err == nil {
t.Error("Issue should fail with disallowed scope")
}
}
func TestIssueTTL(t *testing.T) {
issuer, _ := setupIssuer(cashuiface.AllowAllChecker{})
secret, _ := bdhke.GenerateSecret()
blindResult, _ := bdhke.Blind(secret)
pubkey := make([]byte, 32)
// Request with custom TTL
req := &IssueRequest{
BlindedMessage: blindResult.B.SerializeCompressed(),
Pubkey: pubkey,
Scope: token.ScopeRelay,
TTL: time.Hour,
}
resp, err := issuer.Issue(context.Background(), req, "127.0.0.1")
if err != nil {
t.Fatalf("Issue failed: %v", err)
}
// Expiry should be ~1 hour from now
expectedExpiry := time.Now().Add(time.Hour).Unix()
if resp.Expiry < expectedExpiry-60 || resp.Expiry > expectedExpiry+60 {
t.Errorf("Expiry %d not within expected range of %d", resp.Expiry, expectedExpiry)
}
}
func TestBuildToken(t *testing.T) {
issuer, manager := setupIssuer(cashuiface.AllowAllChecker{})
// Generate secret and blind it
secret, _ := bdhke.GenerateSecret()
blindResult, _ := bdhke.Blind(secret)
pubkey := make([]byte, 32)
for i := range pubkey {
pubkey[i] = byte(i)
}
// Issue token
req := &IssueRequest{
BlindedMessage: blindResult.B.SerializeCompressed(),
Pubkey: pubkey,
Scope: token.ScopeRelay,
Kinds: []int{1, 2, 3},
}
resp, err := issuer.Issue(context.Background(), req, "127.0.0.1")
if err != nil {
t.Fatalf("Issue failed: %v", err)
}
// Build complete token
tok, err := BuildToken(resp, secret, blindResult.R, pubkey, token.ScopeRelay, []int{1, 2, 3}, nil)
if err != nil {
t.Fatalf("BuildToken failed: %v", err)
}
// Verify token structure
if tok.KeysetID != resp.KeysetID {
t.Errorf("KeysetID mismatch: %s != %s", tok.KeysetID, resp.KeysetID)
}
if tok.Scope != token.ScopeRelay {
t.Errorf("Scope = %s, want %s", tok.Scope, token.ScopeRelay)
}
// Verify signature (using the keyset)
ks := manager.FindByID(tok.KeysetID)
if ks == nil {
t.Fatal("Keyset not found")
}
valid, err := bdhke.Verify(tok.Secret, mustParsePoint(tok.Signature), ks.PrivateKey)
if err != nil {
t.Fatalf("Verify failed: %v", err)
}
if !valid {
t.Error("Token signature is not valid")
}
}
func TestGetKeysetInfo(t *testing.T) {
issuer, _ := setupIssuer(cashuiface.AllowAllChecker{})
infos := issuer.GetKeysetInfo()
if len(infos) == 0 {
t.Error("GetKeysetInfo returned empty")
}
for _, info := range infos {
if info.ID == "" {
t.Error("KeysetInfo has empty ID")
}
if info.PublicKey == "" {
t.Error("KeysetInfo has empty PublicKey")
}
}
}
func TestGetActiveKeysetID(t *testing.T) {
issuer, _ := setupIssuer(cashuiface.AllowAllChecker{})
id := issuer.GetActiveKeysetID()
if id == "" {
t.Error("GetActiveKeysetID returned empty")
}
if len(id) != 14 {
t.Errorf("KeysetID length = %d, want 14", len(id))
}
}
func TestGetMintInfo(t *testing.T) {
store := keyset.NewMemoryStore()
manager := keyset.NewManager(store, keyset.DefaultActiveWindow, keyset.DefaultVerifyWindow)
manager.Init()
config := DefaultConfig()
config.AllowedScopes = []string{token.ScopeRelay, token.ScopeNIP46}
issuer := New(manager, cashuiface.AllowAllChecker{}, config)
info := issuer.GetMintInfo("Test Relay")
if info.Name != "Test Relay" {
t.Errorf("Name = %s, want Test Relay", info.Name)
}
if info.Version != "NIP-XX/1" {
t.Errorf("Version = %s, want NIP-XX/1", info.Version)
}
if len(info.SupportedScopes) != 2 {
t.Errorf("SupportedScopes length = %d, want 2", len(info.SupportedScopes))
}
}
// Helper to parse point for testing
func mustParsePoint(data []byte) *secp256k1.PublicKey {
pk, err := secp256k1.ParsePubKey(data)
if err != nil {
panic(err)
}
return pk
}

338
pkg/cashu/keyset/keyset.go Normal file
View File

@@ -0,0 +1,338 @@
// Package keyset manages Cashu mint keysets for blind signature tokens.
// Keysets rotate periodically to limit key exposure and provide forward secrecy.
package keyset
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"sync"
"time"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
)
// DefaultActiveWindow is how long a keyset is valid for issuing new tokens.
const DefaultActiveWindow = 7 * 24 * time.Hour // 1 week
// DefaultVerifyWindow is how long a keyset remains valid for verification.
const DefaultVerifyWindow = 21 * 24 * time.Hour // 3 weeks
// Keyset represents a signing keyset with lifecycle management.
type Keyset struct {
ID string // 14-char hex ID (7 bytes)
PrivateKey *secp256k1.PrivateKey // Signing key
PublicKey *secp256k1.PublicKey // Verification key
CreatedAt time.Time // When keyset was created
ActiveAt time.Time // When keyset becomes active for signing
ExpiresAt time.Time // When keyset can no longer sign (but can still verify)
VerifyEnd time.Time // When keyset can no longer verify
Active bool // Whether keyset is currently active for signing
}
// New creates a new keyset with generated keys.
func New() (*Keyset, error) {
return NewWithTTL(DefaultActiveWindow, DefaultVerifyWindow)
}
// NewWithTTL creates a new keyset with custom lifetimes.
func NewWithTTL(activeTTL, verifyTTL time.Duration) (*Keyset, error) {
// Generate random private key
keyBytes := make([]byte, 32)
if _, err := rand.Read(keyBytes); err != nil {
return nil, fmt.Errorf("keyset: failed to generate key: %w", err)
}
privKey := secp256k1.PrivKeyFromBytes(keyBytes)
pubKey := privKey.PubKey()
now := time.Now()
k := &Keyset{
PrivateKey: privKey,
PublicKey: pubKey,
CreatedAt: now,
ActiveAt: now,
ExpiresAt: now.Add(activeTTL),
VerifyEnd: now.Add(verifyTTL),
Active: true,
}
// Calculate ID from public key
k.ID = k.calculateID()
return k, nil
}
// NewFromPrivateKey creates a keyset from an existing private key.
func NewFromPrivateKey(privKeyBytes []byte, createdAt time.Time, activeTTL, verifyTTL time.Duration) (*Keyset, error) {
if len(privKeyBytes) != 32 {
return nil, fmt.Errorf("keyset: private key must be 32 bytes")
}
privKey := secp256k1.PrivKeyFromBytes(privKeyBytes)
pubKey := privKey.PubKey()
k := &Keyset{
PrivateKey: privKey,
PublicKey: pubKey,
CreatedAt: createdAt,
ActiveAt: createdAt,
ExpiresAt: createdAt.Add(activeTTL),
VerifyEnd: createdAt.Add(verifyTTL),
Active: true,
}
k.ID = k.calculateID()
return k, nil
}
// calculateID computes the keyset ID from the public key.
// ID = hex(SHA256(compressed_pubkey)[0:7])
func (k *Keyset) calculateID() string {
compressed := k.PublicKey.SerializeCompressed()
hash := sha256.Sum256(compressed)
return hex.EncodeToString(hash[:7])
}
// IsActiveForSigning returns true if keyset can be used to sign new tokens.
func (k *Keyset) IsActiveForSigning() bool {
now := time.Now()
return k.Active && now.After(k.ActiveAt) && now.Before(k.ExpiresAt)
}
// IsValidForVerification returns true if keyset can be used to verify tokens.
func (k *Keyset) IsValidForVerification() bool {
now := time.Now()
return now.After(k.ActiveAt) && now.Before(k.VerifyEnd)
}
// Deactivate marks the keyset as no longer active for signing.
func (k *Keyset) Deactivate() {
k.Active = false
}
// SerializePrivateKey returns the private key as bytes for storage.
func (k *Keyset) SerializePrivateKey() []byte {
return k.PrivateKey.Serialize()
}
// SerializePublicKey returns the compressed public key.
func (k *Keyset) SerializePublicKey() []byte {
return k.PublicKey.SerializeCompressed()
}
// KeysetInfo is a public view of a keyset (without private key).
type KeysetInfo struct {
ID string `json:"id"`
PublicKey string `json:"pubkey"`
Active bool `json:"active"`
CreatedAt int64 `json:"created_at"`
ExpiresAt int64 `json:"expires_at"`
VerifyEnd int64 `json:"verify_end"`
}
// Info returns public information about the keyset.
func (k *Keyset) Info() KeysetInfo {
return KeysetInfo{
ID: k.ID,
PublicKey: hex.EncodeToString(k.SerializePublicKey()),
Active: k.IsActiveForSigning(),
CreatedAt: k.CreatedAt.Unix(),
ExpiresAt: k.ExpiresAt.Unix(),
VerifyEnd: k.VerifyEnd.Unix(),
}
}
// Manager handles keyset lifecycle including rotation.
type Manager struct {
store Store
activeTTL time.Duration
verifyTTL time.Duration
mu sync.RWMutex
current *Keyset // Current active keyset for signing
verification []*Keyset // All keysets valid for verification (including current)
}
// NewManager creates a keyset manager.
func NewManager(store Store, activeTTL, verifyTTL time.Duration) *Manager {
return &Manager{
store: store,
activeTTL: activeTTL,
verifyTTL: verifyTTL,
verification: make([]*Keyset, 0),
}
}
// Init initializes the manager by loading existing keysets or creating a new one.
func (m *Manager) Init() error {
m.mu.Lock()
defer m.mu.Unlock()
// Load all valid keysets from store
keysets, err := m.store.ListVerificationKeysets()
if err != nil {
return fmt.Errorf("manager: failed to load keysets: %w", err)
}
// Find current active keyset
var active *Keyset
for _, k := range keysets {
if k.IsActiveForSigning() {
if active == nil || k.CreatedAt.After(active.CreatedAt) {
active = k
}
}
if k.IsValidForVerification() {
m.verification = append(m.verification, k)
}
}
// If no active keyset, create one
if active == nil {
newKeyset, err := NewWithTTL(m.activeTTL, m.verifyTTL)
if err != nil {
return fmt.Errorf("manager: failed to create initial keyset: %w", err)
}
if err := m.store.SaveKeyset(newKeyset); err != nil {
return fmt.Errorf("manager: failed to save initial keyset: %w", err)
}
active = newKeyset
m.verification = append(m.verification, newKeyset)
}
m.current = active
return nil
}
// GetSigningKeyset returns the current active keyset for signing.
func (m *Manager) GetSigningKeyset() *Keyset {
m.mu.RLock()
defer m.mu.RUnlock()
return m.current
}
// GetVerificationKeysets returns all keysets valid for verification.
func (m *Manager) GetVerificationKeysets() []*Keyset {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]*Keyset, 0, len(m.verification))
for _, k := range m.verification {
if k.IsValidForVerification() {
result = append(result, k)
}
}
return result
}
// FindByID returns the keyset with the given ID, if it's valid for verification.
func (m *Manager) FindByID(id string) *Keyset {
m.mu.RLock()
defer m.mu.RUnlock()
for _, k := range m.verification {
if k.ID == id && k.IsValidForVerification() {
return k
}
}
return nil
}
// RotateIfNeeded checks if rotation is needed and performs it.
// Returns true if a new keyset was created.
func (m *Manager) RotateIfNeeded() (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
// Check if current keyset is still active
if m.current != nil && m.current.IsActiveForSigning() {
return false, nil
}
// Create new keyset
newKeyset, err := NewWithTTL(m.activeTTL, m.verifyTTL)
if err != nil {
return false, fmt.Errorf("manager: failed to create new keyset: %w", err)
}
// Deactivate old keyset
if m.current != nil {
m.current.Deactivate()
}
// Save new keyset
if err := m.store.SaveKeyset(newKeyset); err != nil {
return false, fmt.Errorf("manager: failed to save new keyset: %w", err)
}
// Update manager state
m.current = newKeyset
m.verification = append(m.verification, newKeyset)
// Prune expired verification keysets
m.pruneExpired()
return true, nil
}
// pruneExpired removes keysets that are no longer valid for verification.
// Must be called with lock held.
func (m *Manager) pruneExpired() {
valid := make([]*Keyset, 0, len(m.verification))
for _, k := range m.verification {
if k.IsValidForVerification() {
valid = append(valid, k)
}
}
m.verification = valid
}
// ListKeysetInfo returns public info for all verification keysets.
func (m *Manager) ListKeysetInfo() []KeysetInfo {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]KeysetInfo, 0, len(m.verification))
for _, k := range m.verification {
if k.IsValidForVerification() {
result = append(result, k.Info())
}
}
return result
}
// StartRotationTicker starts a goroutine that rotates keysets periodically.
// Returns a channel that receives true on each rotation.
func (m *Manager) StartRotationTicker(interval time.Duration) (rotated <-chan bool, stop func()) {
ticker := time.NewTicker(interval)
ch := make(chan bool, 1)
done := make(chan struct{})
go func() {
for {
select {
case <-ticker.C:
rotated, err := m.RotateIfNeeded()
if err != nil {
// Log error but continue
continue
}
if rotated {
select {
case ch <- true:
default:
}
}
case <-done:
ticker.Stop()
close(ch)
return
}
}
}()
return ch, func() { close(done) }
}

View File

@@ -0,0 +1,278 @@
package keyset
import (
"testing"
"time"
)
func TestNewKeyset(t *testing.T) {
k, err := New()
if err != nil {
t.Fatalf("New() failed: %v", err)
}
// Check ID is 14 characters (7 bytes hex)
if len(k.ID) != 14 {
t.Errorf("ID length = %d, want 14", len(k.ID))
}
// Check keys are set
if k.PrivateKey == nil {
t.Error("PrivateKey is nil")
}
if k.PublicKey == nil {
t.Error("PublicKey is nil")
}
// Check times are set
if k.CreatedAt.IsZero() {
t.Error("CreatedAt is zero")
}
if !k.IsActiveForSigning() {
t.Error("New keyset should be active for signing")
}
if !k.IsValidForVerification() {
t.Error("New keyset should be valid for verification")
}
}
func TestKeysetIDDeterministic(t *testing.T) {
// Same private key should produce same ID
privKeyBytes := make([]byte, 32)
for i := range privKeyBytes {
privKeyBytes[i] = byte(i)
}
k1, err := NewFromPrivateKey(privKeyBytes, time.Now(), DefaultActiveWindow, DefaultVerifyWindow)
if err != nil {
t.Fatalf("NewFromPrivateKey failed: %v", err)
}
k2, err := NewFromPrivateKey(privKeyBytes, time.Now(), DefaultActiveWindow, DefaultVerifyWindow)
if err != nil {
t.Fatalf("NewFromPrivateKey failed: %v", err)
}
if k1.ID != k2.ID {
t.Errorf("IDs should match: %s != %s", k1.ID, k2.ID)
}
}
func TestKeysetExpiration(t *testing.T) {
// Create keyset with very short TTL
k, err := NewWithTTL(100*time.Millisecond, 200*time.Millisecond)
if err != nil {
t.Fatalf("NewWithTTL failed: %v", err)
}
// Should be active initially
if !k.IsActiveForSigning() {
t.Error("New keyset should be active for signing")
}
// Wait for signing to expire
time.Sleep(150 * time.Millisecond)
if k.IsActiveForSigning() {
t.Error("Keyset should not be active for signing after expiry")
}
if !k.IsValidForVerification() {
t.Error("Keyset should still be valid for verification")
}
// Wait for verification to expire
time.Sleep(100 * time.Millisecond)
if k.IsValidForVerification() {
t.Error("Keyset should not be valid for verification after verify expiry")
}
}
func TestKeysetDeactivate(t *testing.T) {
k, _ := New()
if !k.Active {
t.Error("New keyset should be active")
}
k.Deactivate()
if k.Active {
t.Error("Keyset should not be active after Deactivate()")
}
if k.IsActiveForSigning() {
t.Error("Deactivated keyset should not be active for signing")
}
}
func TestKeysetInfo(t *testing.T) {
k, _ := New()
info := k.Info()
if info.ID != k.ID {
t.Errorf("Info ID = %s, want %s", info.ID, k.ID)
}
if len(info.PublicKey) != 66 { // 33 bytes * 2 hex chars
t.Errorf("Info PublicKey length = %d, want 66", len(info.PublicKey))
}
if !info.Active {
t.Error("Info Active should be true for new keyset")
}
}
func TestManager(t *testing.T) {
store := NewMemoryStore()
manager := NewManager(store, DefaultActiveWindow, DefaultVerifyWindow)
if err := manager.Init(); err != nil {
t.Fatalf("Init failed: %v", err)
}
// Should have a signing keyset
signing := manager.GetSigningKeyset()
if signing == nil {
t.Fatal("GetSigningKeyset returned nil")
}
// Should have at least one verification keyset
verification := manager.GetVerificationKeysets()
if len(verification) == 0 {
t.Error("GetVerificationKeysets returned empty")
}
// Should find keyset by ID
found := manager.FindByID(signing.ID)
if found == nil {
t.Error("FindByID returned nil for signing keyset")
}
if found.ID != signing.ID {
t.Errorf("FindByID returned wrong keyset: %s != %s", found.ID, signing.ID)
}
}
func TestManagerRotation(t *testing.T) {
store := NewMemoryStore()
manager := NewManager(store, 50*time.Millisecond, 200*time.Millisecond)
if err := manager.Init(); err != nil {
t.Fatalf("Init failed: %v", err)
}
initialID := manager.GetSigningKeyset().ID
// Rotation should not happen yet
rotated, err := manager.RotateIfNeeded()
if err != nil {
t.Fatalf("RotateIfNeeded failed: %v", err)
}
if rotated {
t.Error("Should not rotate when keyset is still active")
}
// Wait for signing to expire
time.Sleep(60 * time.Millisecond)
// Now rotation should happen
rotated, err = manager.RotateIfNeeded()
if err != nil {
t.Fatalf("RotateIfNeeded failed: %v", err)
}
if !rotated {
t.Error("Should rotate when keyset is expired")
}
newID := manager.GetSigningKeyset().ID
if newID == initialID {
t.Error("New keyset should have different ID")
}
// Old keyset should still be valid for verification
old := manager.FindByID(initialID)
if old == nil {
t.Error("Old keyset should still be found for verification")
}
}
func TestManagerPersistence(t *testing.T) {
store := NewMemoryStore()
// First manager creates keyset
m1 := NewManager(store, DefaultActiveWindow, DefaultVerifyWindow)
if err := m1.Init(); err != nil {
t.Fatalf("Init failed: %v", err)
}
id := m1.GetSigningKeyset().ID
// Second manager should load existing keyset
m2 := NewManager(store, DefaultActiveWindow, DefaultVerifyWindow)
if err := m2.Init(); err != nil {
t.Fatalf("Init failed: %v", err)
}
if m2.GetSigningKeyset().ID != id {
t.Error("Second manager should use same keyset as first")
}
}
func TestManagerListKeysetInfo(t *testing.T) {
store := NewMemoryStore()
manager := NewManager(store, DefaultActiveWindow, DefaultVerifyWindow)
manager.Init()
infos := manager.ListKeysetInfo()
if len(infos) == 0 {
t.Error("ListKeysetInfo returned empty")
}
for _, info := range infos {
if info.ID == "" {
t.Error("KeysetInfo has empty ID")
}
if info.PublicKey == "" {
t.Error("KeysetInfo has empty PublicKey")
}
}
}
func TestMemoryStore(t *testing.T) {
store := NewMemoryStore()
k, _ := New()
// Save
if err := store.SaveKeyset(k); err != nil {
t.Fatalf("SaveKeyset failed: %v", err)
}
// Load
loaded, err := store.LoadKeyset(k.ID)
if err != nil {
t.Fatalf("LoadKeyset failed: %v", err)
}
if loaded == nil {
t.Fatal("LoadKeyset returned nil")
}
if loaded.ID != k.ID {
t.Errorf("Loaded ID = %s, want %s", loaded.ID, k.ID)
}
// List active
active, err := store.ListActiveKeysets()
if err != nil {
t.Fatalf("ListActiveKeysets failed: %v", err)
}
if len(active) != 1 {
t.Errorf("ListActiveKeysets returned %d, want 1", len(active))
}
// Delete
if err := store.DeleteKeyset(k.ID); err != nil {
t.Fatalf("DeleteKeyset failed: %v", err)
}
// Should be gone
loaded, _ = store.LoadKeyset(k.ID)
if loaded != nil {
t.Error("Keyset should be deleted")
}
}

74
pkg/cashu/keyset/store.go Normal file
View File

@@ -0,0 +1,74 @@
package keyset
// Store is the interface for persisting keysets.
// Implement this interface for your database backend.
type Store interface {
// SaveKeyset persists a keyset.
SaveKeyset(k *Keyset) error
// LoadKeyset loads a keyset by ID.
LoadKeyset(id string) (*Keyset, error)
// ListActiveKeysets returns all keysets that can be used for signing.
ListActiveKeysets() ([]*Keyset, error)
// ListVerificationKeysets returns all keysets that can be used for verification.
ListVerificationKeysets() ([]*Keyset, error)
// DeleteKeyset removes a keyset from storage.
DeleteKeyset(id string) error
}
// MemoryStore is an in-memory implementation of Store for testing.
type MemoryStore struct {
keysets map[string]*Keyset
}
// NewMemoryStore creates a new in-memory store.
func NewMemoryStore() *MemoryStore {
return &MemoryStore{
keysets: make(map[string]*Keyset),
}
}
// SaveKeyset saves a keyset to memory.
func (s *MemoryStore) SaveKeyset(k *Keyset) error {
s.keysets[k.ID] = k
return nil
}
// LoadKeyset loads a keyset by ID.
func (s *MemoryStore) LoadKeyset(id string) (*Keyset, error) {
if k, ok := s.keysets[id]; ok {
return k, nil
}
return nil, nil
}
// ListActiveKeysets returns all active keysets.
func (s *MemoryStore) ListActiveKeysets() ([]*Keyset, error) {
result := make([]*Keyset, 0)
for _, k := range s.keysets {
if k.IsActiveForSigning() {
result = append(result, k)
}
}
return result, nil
}
// ListVerificationKeysets returns all keysets valid for verification.
func (s *MemoryStore) ListVerificationKeysets() ([]*Keyset, error) {
result := make([]*Keyset, 0)
for _, k := range s.keysets {
if k.IsValidForVerification() {
result = append(result, k)
}
}
return result, nil
}
// DeleteKeyset removes a keyset.
func (s *MemoryStore) DeleteKeyset(id string) error {
delete(s.keysets, id)
return nil
}

345
pkg/cashu/token/token.go Normal file
View 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
}

View 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)
}
}

View File

@@ -0,0 +1,138 @@
package verifier
import (
"context"
"net/http"
"next.orly.dev/pkg/cashu/token"
)
// ContextKey is the type for context keys.
type ContextKey string
const (
// TokenContextKey is the context key for the verified token.
TokenContextKey ContextKey = "cashu_token"
// PubkeyContextKey is the context key for the user's pubkey.
PubkeyContextKey ContextKey = "cashu_pubkey"
)
// TokenFromContext extracts the verified token from the request context.
func TokenFromContext(ctx context.Context) *token.Token {
if tok, ok := ctx.Value(TokenContextKey).(*token.Token); ok {
return tok
}
return nil
}
// PubkeyFromContext extracts the user's pubkey from the request context.
func PubkeyFromContext(ctx context.Context) []byte {
if pubkey, ok := ctx.Value(PubkeyContextKey).([]byte); ok {
return pubkey
}
return nil
}
// Middleware creates an HTTP middleware that verifies Cashu tokens.
func Middleware(v *Verifier, requiredScope string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tok, err := v.VerifyRequest(r.Context(), r, requiredScope)
if err != nil {
writeError(w, err)
return
}
// Add token and pubkey to context
ctx := context.WithValue(r.Context(), TokenContextKey, tok)
ctx = context.WithValue(ctx, PubkeyContextKey, tok.Pubkey)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// MiddlewareForKind creates middleware that also checks kind permission.
func MiddlewareForKind(v *Verifier, requiredScope string, kind int) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tok, err := v.VerifyRequestForKind(r.Context(), r, requiredScope, kind)
if err != nil {
writeError(w, err)
return
}
ctx := context.WithValue(r.Context(), TokenContextKey, tok)
ctx = context.WithValue(ctx, PubkeyContextKey, tok.Pubkey)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// OptionalMiddleware creates middleware that verifies tokens if present,
// but allows requests without tokens to proceed.
func OptionalMiddleware(v *Verifier, requiredScope string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tok, err := v.VerifyRequest(r.Context(), r, requiredScope)
if err == nil {
// Token present and valid - add to context
ctx := context.WithValue(r.Context(), TokenContextKey, tok)
ctx = context.WithValue(ctx, PubkeyContextKey, tok.Pubkey)
r = r.WithContext(ctx)
} else if err != ErrMissingToken {
// Token present but invalid - reject
writeError(w, err)
return
}
// No token or valid token - proceed
next.ServeHTTP(w, r)
})
}
}
// writeError writes an appropriate HTTP error response.
func writeError(w http.ResponseWriter, err error) {
switch err {
case ErrMissingToken:
http.Error(w, "Missing token", http.StatusUnauthorized)
case ErrTokenExpired:
http.Error(w, "Token expired", http.StatusGone)
case ErrUnknownKeyset:
http.Error(w, "Unknown keyset", http.StatusMisdirectedRequest)
case ErrInvalidSignature:
http.Error(w, "Invalid signature", http.StatusUnauthorized)
case ErrScopeMismatch:
http.Error(w, "Scope mismatch", http.StatusForbidden)
case ErrKindNotPermitted:
http.Error(w, "Kind not permitted", http.StatusForbidden)
case ErrAccessRevoked:
http.Error(w, "Access revoked", http.StatusForbidden)
default:
http.Error(w, err.Error(), http.StatusUnauthorized)
}
}
// RequireToken is a helper that extracts and verifies a token inline.
// Returns the token or writes an error response and returns nil.
func RequireToken(v *Verifier, w http.ResponseWriter, r *http.Request, requiredScope string) *token.Token {
tok, err := v.VerifyRequest(r.Context(), r, requiredScope)
if err != nil {
writeError(w, err)
return nil
}
return tok
}
// RequireKind is a helper that also checks kind permission inline.
func RequireKind(v *Verifier, w http.ResponseWriter, r *http.Request, requiredScope string, kind int) *token.Token {
tok, err := v.VerifyRequestForKind(r.Context(), r, requiredScope, kind)
if err != nil {
writeError(w, err)
return nil
}
return tok
}

View File

@@ -0,0 +1,186 @@
// Package verifier implements Cashu token verification with optional re-authorization.
package verifier
import (
"context"
"errors"
"fmt"
"net/http"
"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 (
ErrTokenExpired = errors.New("verifier: token expired")
ErrUnknownKeyset = errors.New("verifier: unknown keyset")
ErrInvalidSignature = errors.New("verifier: invalid signature")
ErrScopeMismatch = errors.New("verifier: scope mismatch")
ErrKindNotPermitted = errors.New("verifier: kind not permitted")
ErrAccessRevoked = errors.New("verifier: access revoked")
ErrMissingToken = errors.New("verifier: missing token")
)
// Config holds verifier configuration.
type Config struct {
// Reauthorize enables re-checking authorization on each verification.
// This provides "stateless revocation" at the cost of an extra check.
Reauthorize bool
}
// DefaultConfig returns sensible default configuration.
func DefaultConfig() Config {
return Config{
Reauthorize: true, // Enable stateless revocation by default
}
}
// Verifier validates Cashu tokens and checks permissions.
type Verifier struct {
keysets *keyset.Manager
authz cashuiface.AuthzChecker
claimValidator cashuiface.ClaimValidator
config Config
}
// New creates a new verifier.
func New(keysets *keyset.Manager, authz cashuiface.AuthzChecker, config Config) *Verifier {
return &Verifier{
keysets: keysets,
authz: authz,
config: config,
}
}
// SetClaimValidator sets an optional claim validator.
func (v *Verifier) SetClaimValidator(cv cashuiface.ClaimValidator) {
v.claimValidator = cv
}
// Verify validates a token's cryptographic signature and checks expiry.
func (v *Verifier) Verify(ctx context.Context, tok *token.Token, remoteAddr string) error {
// Basic validation
if err := tok.Validate(); err != nil {
return err
}
// Check expiry
if tok.IsExpired() {
return ErrTokenExpired
}
// Find keyset
ks := v.keysets.FindByID(tok.KeysetID)
if ks == nil {
return fmt.Errorf("%w: %s", ErrUnknownKeyset, tok.KeysetID)
}
// Verify signature
valid, err := v.verifySignature(tok, ks)
if err != nil {
return fmt.Errorf("verifier: signature check failed: %w", err)
}
if !valid {
return ErrInvalidSignature
}
// Re-check authorization if enabled
if v.config.Reauthorize && v.authz != nil {
if err := v.authz.CheckAuthorization(ctx, tok.Pubkey, tok.Scope, remoteAddr); err != nil {
return fmt.Errorf("%w: %v", ErrAccessRevoked, err)
}
}
return nil
}
// VerifyForScope verifies a token and checks that it has the required scope.
func (v *Verifier) VerifyForScope(ctx context.Context, tok *token.Token, requiredScope string, remoteAddr string) error {
if err := v.Verify(ctx, tok, remoteAddr); err != nil {
return err
}
if !tok.MatchesScope(requiredScope) {
return fmt.Errorf("%w: expected %s, got %s", ErrScopeMismatch, requiredScope, tok.Scope)
}
return nil
}
// VerifyForKind verifies a token and checks that the specified kind is permitted.
func (v *Verifier) VerifyForKind(ctx context.Context, tok *token.Token, kind int, remoteAddr string) error {
if err := v.Verify(ctx, tok, remoteAddr); err != nil {
return err
}
if !tok.IsKindPermitted(kind) {
return fmt.Errorf("%w: kind %d", ErrKindNotPermitted, kind)
}
return nil
}
// verifySignature checks the BDHKE signature.
func (v *Verifier) verifySignature(tok *token.Token, ks *keyset.Keyset) (bool, error) {
// Parse signature as curve point
C, err := secp256k1.ParsePubKey(tok.Signature)
if err != nil {
return false, fmt.Errorf("invalid signature format: %w", err)
}
// Verify: C == k * HashToCurve(secret)
return bdhke.Verify(tok.Secret, C, ks.PrivateKey)
}
// ExtractFromRequest extracts and parses a token from an HTTP request.
// Checks headers in order: X-Cashu-Token, Authorization (Cashu scheme).
func (v *Verifier) ExtractFromRequest(r *http.Request) (*token.Token, error) {
// Try X-Cashu-Token header first
if header := r.Header.Get("X-Cashu-Token"); header != "" {
return token.ParseFromHeader(header)
}
// Try Authorization header
if header := r.Header.Get("Authorization"); header != "" {
return token.ParseFromHeader(header)
}
return nil, ErrMissingToken
}
// VerifyRequest extracts, parses, and verifies a token from an HTTP request.
func (v *Verifier) VerifyRequest(ctx context.Context, r *http.Request, requiredScope string) (*token.Token, error) {
tok, err := v.ExtractFromRequest(r)
if err != nil {
return nil, err
}
if err := v.VerifyForScope(ctx, tok, requiredScope, r.RemoteAddr); err != nil {
return nil, err
}
return tok, nil
}
// VerifyRequestForKind extracts, parses, and verifies a token for a specific kind.
func (v *Verifier) VerifyRequestForKind(ctx context.Context, r *http.Request, requiredScope string, kind int) (*token.Token, error) {
tok, err := v.ExtractFromRequest(r)
if err != nil {
return nil, err
}
if err := v.VerifyForScope(ctx, tok, requiredScope, r.RemoteAddr); err != nil {
return nil, err
}
if !tok.IsKindPermitted(kind) {
return nil, fmt.Errorf("%w: kind %d", ErrKindNotPermitted, kind)
}
return tok, nil
}

View File

@@ -0,0 +1,396 @@
package verifier
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"next.orly.dev/pkg/cashu/bdhke"
"next.orly.dev/pkg/cashu/issuer"
"next.orly.dev/pkg/cashu/keyset"
"next.orly.dev/pkg/cashu/token"
cashuiface "next.orly.dev/pkg/interfaces/cashu"
)
func setupVerifier() (*Verifier, *issuer.Issuer, *keyset.Manager) {
store := keyset.NewMemoryStore()
manager := keyset.NewManager(store, keyset.DefaultActiveWindow, keyset.DefaultVerifyWindow)
manager.Init()
issuerConfig := issuer.DefaultConfig()
iss := issuer.New(manager, cashuiface.AllowAllChecker{}, issuerConfig)
verifierConfig := DefaultConfig()
ver := New(manager, cashuiface.AllowAllChecker{}, verifierConfig)
return ver, iss, manager
}
func issueTestToken(iss *issuer.Issuer, scope string, kinds []int) (*token.Token, error) {
secret, err := bdhke.GenerateSecret()
if err != nil {
return nil, err
}
blindResult, err := bdhke.Blind(secret)
if err != nil {
return nil, err
}
pubkey := make([]byte, 32)
for i := range pubkey {
pubkey[i] = byte(i)
}
req := &issuer.IssueRequest{
BlindedMessage: blindResult.B.SerializeCompressed(),
Pubkey: pubkey,
Scope: scope,
Kinds: kinds,
}
resp, err := iss.Issue(context.Background(), req, "127.0.0.1")
if err != nil {
return nil, err
}
return issuer.BuildToken(resp, secret, blindResult.R, pubkey, scope, kinds, nil)
}
func TestVerifySuccess(t *testing.T) {
ver, iss, _ := setupVerifier()
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1, 2, 3})
if err != nil {
t.Fatalf("issueTestToken failed: %v", err)
}
err = ver.Verify(context.Background(), tok, "127.0.0.1")
if err != nil {
t.Errorf("Verify failed: %v", err)
}
}
func TestVerifyExpired(t *testing.T) {
ver, iss, _ := setupVerifier()
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
if err != nil {
t.Fatalf("issueTestToken failed: %v", err)
}
// Expire the token
tok.Expiry = time.Now().Add(-time.Hour).Unix()
err = ver.Verify(context.Background(), tok, "127.0.0.1")
if err == nil {
t.Error("Verify should fail for expired token")
}
}
func TestVerifyInvalidSignature(t *testing.T) {
ver, iss, _ := setupVerifier()
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
if err != nil {
t.Fatalf("issueTestToken failed: %v", err)
}
// Corrupt the signature
tok.Signature[10] ^= 0xFF
err = ver.Verify(context.Background(), tok, "127.0.0.1")
if err == nil {
t.Error("Verify should fail for invalid signature")
}
}
func TestVerifyUnknownKeyset(t *testing.T) {
ver, iss, _ := setupVerifier()
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
if err != nil {
t.Fatalf("issueTestToken failed: %v", err)
}
// Change keyset ID
tok.KeysetID = "00000000000000"
err = ver.Verify(context.Background(), tok, "127.0.0.1")
if err == nil {
t.Error("Verify should fail for unknown keyset")
}
}
func TestVerifyForScope(t *testing.T) {
ver, iss, _ := setupVerifier()
tok, err := issueTestToken(iss, token.ScopeNIP46, []int{24133})
if err != nil {
t.Fatalf("issueTestToken failed: %v", err)
}
// Should pass for correct scope
err = ver.VerifyForScope(context.Background(), tok, token.ScopeNIP46, "127.0.0.1")
if err != nil {
t.Errorf("VerifyForScope failed for correct scope: %v", err)
}
// Should fail for wrong scope
err = ver.VerifyForScope(context.Background(), tok, token.ScopeRelay, "127.0.0.1")
if err == nil {
t.Error("VerifyForScope should fail for wrong scope")
}
}
func TestVerifyForKind(t *testing.T) {
ver, iss, _ := setupVerifier()
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1, 2, 3})
if err != nil {
t.Fatalf("issueTestToken failed: %v", err)
}
// Should pass for permitted kind
err = ver.VerifyForKind(context.Background(), tok, 1, "127.0.0.1")
if err != nil {
t.Errorf("VerifyForKind failed for permitted kind: %v", err)
}
// Should fail for non-permitted kind
err = ver.VerifyForKind(context.Background(), tok, 100, "127.0.0.1")
if err == nil {
t.Error("VerifyForKind should fail for non-permitted kind")
}
}
func TestVerifyReauthorization(t *testing.T) {
store := keyset.NewMemoryStore()
manager := keyset.NewManager(store, keyset.DefaultActiveWindow, keyset.DefaultVerifyWindow)
manager.Init()
iss := issuer.New(manager, cashuiface.AllowAllChecker{}, issuer.DefaultConfig())
// Create verifier that denies authorization
config := DefaultConfig()
config.Reauthorize = true
ver := New(manager, cashuiface.DenyAllChecker{}, config)
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
if err != nil {
t.Fatalf("issueTestToken failed: %v", err)
}
// Should fail due to reauthorization check
err = ver.Verify(context.Background(), tok, "127.0.0.1")
if err == nil {
t.Error("Verify should fail when reauthorization fails")
}
}
func TestExtractFromRequest(t *testing.T) {
ver, iss, _ := setupVerifier()
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
if err != nil {
t.Fatalf("issueTestToken failed: %v", err)
}
encoded, _ := tok.Encode()
tests := []struct {
name string
header string
value string
}{
{"X-Cashu-Token", "X-Cashu-Token", encoded},
{"Authorization Cashu", "Authorization", "Cashu " + encoded},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set(tt.header, tt.value)
extracted, err := ver.ExtractFromRequest(req)
if err != nil {
t.Fatalf("ExtractFromRequest failed: %v", err)
}
if extracted.KeysetID != tok.KeysetID {
t.Errorf("KeysetID mismatch: %s != %s", extracted.KeysetID, tok.KeysetID)
}
})
}
}
func TestExtractFromRequestMissing(t *testing.T) {
ver, _, _ := setupVerifier()
req := httptest.NewRequest("GET", "/", nil)
_, err := ver.ExtractFromRequest(req)
if err != ErrMissingToken {
t.Errorf("Expected ErrMissingToken, got %v", err)
}
}
func TestVerifyRequest(t *testing.T) {
ver, iss, _ := setupVerifier()
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
if err != nil {
t.Fatalf("issueTestToken failed: %v", err)
}
encoded, _ := tok.Encode()
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Cashu-Token", encoded)
verified, err := ver.VerifyRequest(context.Background(), req, token.ScopeRelay)
if err != nil {
t.Fatalf("VerifyRequest failed: %v", err)
}
if verified.KeysetID != tok.KeysetID {
t.Error("VerifyRequest returned wrong token")
}
}
func TestMiddleware(t *testing.T) {
ver, iss, _ := setupVerifier()
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
if err != nil {
t.Fatalf("issueTestToken failed: %v", err)
}
encoded, _ := tok.Encode()
// Handler that checks context
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctxTok := TokenFromContext(r.Context())
if ctxTok == nil {
t.Error("Token not in context")
}
pubkey := PubkeyFromContext(r.Context())
if pubkey == nil {
t.Error("Pubkey not in context")
}
w.WriteHeader(http.StatusOK)
})
wrapped := Middleware(ver, token.ScopeRelay)(handler)
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Cashu-Token", encoded)
rec := httptest.NewRecorder()
wrapped.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("Status = %d, want 200", rec.Code)
}
}
func TestMiddlewareUnauthorized(t *testing.T) {
ver, _, _ := setupVerifier()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
wrapped := Middleware(ver, token.ScopeRelay)(handler)
// Request without token
req := httptest.NewRequest("GET", "/", nil)
rec := httptest.NewRecorder()
wrapped.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("Status = %d, want 401", rec.Code)
}
}
func TestOptionalMiddleware(t *testing.T) {
ver, iss, _ := setupVerifier()
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
if err != nil {
t.Fatalf("issueTestToken failed: %v", err)
}
encoded, _ := tok.Encode()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
wrapped := OptionalMiddleware(ver, token.ScopeRelay)(handler)
// With token
req1 := httptest.NewRequest("GET", "/", nil)
req1.Header.Set("X-Cashu-Token", encoded)
rec1 := httptest.NewRecorder()
wrapped.ServeHTTP(rec1, req1)
if rec1.Code != http.StatusOK {
t.Errorf("With token: Status = %d, want 200", rec1.Code)
}
// Without token
req2 := httptest.NewRequest("GET", "/", nil)
rec2 := httptest.NewRecorder()
wrapped.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusOK {
t.Errorf("Without token: Status = %d, want 200", rec2.Code)
}
}
func TestRequireToken(t *testing.T) {
ver, iss, _ := setupVerifier()
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
if err != nil {
t.Fatalf("issueTestToken failed: %v", err)
}
encoded, _ := tok.Encode()
// With valid token
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Cashu-Token", encoded)
rec := httptest.NewRecorder()
result := RequireToken(ver, rec, req, token.ScopeRelay)
if result == nil {
t.Error("RequireToken should return token")
}
// Without token
req2 := httptest.NewRequest("GET", "/", nil)
rec2 := httptest.NewRecorder()
result2 := RequireToken(ver, rec2, req2, token.ScopeRelay)
if result2 != nil {
t.Error("RequireToken should return nil for missing token")
}
if rec2.Code != http.StatusUnauthorized {
t.Errorf("Status = %d, want 401", rec2.Code)
}
}
// Helper to parse point
func mustParsePoint(data []byte) *secp256k1.PublicKey {
pk, err := secp256k1.ParsePubKey(data)
if err != nil {
panic(err)
}
return pk
}