Profpatsch/users/Profpatsch/capability-token-service/signer.go
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
package main

import (
	captokens "codeberg.org/Profpatsch/Profpatsch/users/Profpatsch/capability-tokens-lib"
	"crypto/ed25519"
	"encoding/base64"
	"errors"
	"fmt"
	"strings"
	"time"
)

// TokenSigner signs capability tokens using ED25519
type TokenSigner struct {
	keyPair *KeyPair
}

// NewTokenSigner creates a new token signer with the given key pair
func NewTokenSigner(keyPair *KeyPair) *TokenSigner {
	return &TokenSigner{
		keyPair: keyPair,
	}
}

// SignToken signs a token and returns the base64-encoded signature.
// The token must not contain a "_sig" field yet.
func (s *TokenSigner) SignToken(token map[string]any) (string, error) {
	// Generate canonical bytes
	canonical, err := captokens.CanonicalTokenBytes(token)
	if err != nil {
		return "", fmt.Errorf("canonicalization failed: %w", err)
	}

	// Sign with ED25519
	signature := ed25519.Sign(s.keyPair.Private, canonical)

	// Encode as base64
	return base64.StdEncoding.EncodeToString(signature), nil
}

// IssueToken creates and signs a new capability token.
// Parameters:
//   - tokenID: Token type identifier (e.g., "maildir:open-mailbox")
//   - scope: Token scope fields (e.g., {"mailbox_dir": "/home/user/Mail"})
//   - sessionID: Session UUID from maildir service
//   - ttl: Token lifetime (tokens expire after this duration)
//
// Returns a complete token with signature.
func (s *TokenSigner) IssueToken(
	tokenID string,
	scope map[string]any,
	sessionID string,
	ttl time.Duration,
) (map[string]any, error) {
	now := time.Now()

	// NOTE: We don't validate token types/scopes - that's the resource service's job!
	// Capability service is a dumb signer that trusts the user's approval.

	// Normalize paths in scope (security-critical!)
	// Explicitly declare which fields are paths based on token type
	pathFields := getPathFieldsForTokenType(tokenID)
	if err := captokens.NormalizePathsInFields(scope, pathFields); err != nil {
		return nil, fmt.Errorf("path normalization failed: %w", err)
	}

	// Build token
	token := map[string]any{
		"$tok_id":  tokenID,
		"_session": sessionID,
		"_issued":  now.Format(time.RFC3339),
		"_expires": now.Add(ttl).Format(time.RFC3339),
	}

	// Add scope fields
	for key, value := range scope {
		if strings.HasPrefix(key, "$") || strings.HasPrefix(key, "_") {
			return nil, errors.New("scope fields cannot start with $ or _")
		}
		token[key] = value
	}

	// Sign and add signature
	sig, err := s.SignToken(token)
	if err != nil {
		return nil, fmt.Errorf("signing failed: %w", err)
	}
	token["_sig"] = sig

	return token, nil
}

// GetPublicKey returns the public key as base64-encoded string
func (s *TokenSigner) GetPublicKey() string {
	return s.keyPair.PublicKeyBase64()
}

// getPathFieldsForTokenType returns the list of fields that contain paths
// for a given token type. This is service-specific knowledge.
func getPathFieldsForTokenType(tokenID string) []string {
	switch tokenID {
	// Old fine-grained tokens
	case "maildir:list-mailboxes":
		return []string{"root_dir"}
	case "maildir:open-mailbox", "maildir:read-metadata", "maildir:read-message", "maildir:search-mailbox":
		return []string{"mailbox_dir"}

	// New intent-based tokens
	case "maildir:investigate-recent-mail", "maildir:search-and-read", "maildir:quick-scan-only":
		return []string{"mailbox_dir"}
	case "maildir:enumerate-mailboxes":
		return []string{"root_dir"}

	default:
		return []string{}
	}
}