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
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
package captokens

import (
	"crypto/ed25519"
	"encoding/base64"
	"errors"
	"fmt"
	"strings"
	"time"
)

// VerifyTokenSignature verifies the cryptographic signature and basic token structure.
// This is the universal validation that every service should perform.
// Returns the token's expiration time if successful.
//
// Validates:
// 1. _sig field exists and is valid base64
// 2. Token can be canonicalized
// 3. ED25519 signature is valid
// 4. _expires field exists and is valid RFC3339 timestamp
// 5. Token has not expired
//
// Does NOT validate:
// - Session validity (service-specific)
// - Token scope (method-specific)
func VerifyTokenSignature(token map[string]any, publicKey ed25519.PublicKey) (expiresAt time.Time, err error) {
	// 1. Extract signature
	sigB64, ok := token["_sig"].(string)
	if !ok {
		return time.Time{}, errors.New("missing _sig field")
	}

	signature, err := base64.StdEncoding.DecodeString(sigB64)
	if err != nil {
		return time.Time{}, errors.New("invalid signature encoding")
	}

	// 2. Generate canonical bytes (without _sig)
	canonical, err := CanonicalTokenBytes(token)
	if err != nil {
		return time.Time{}, fmt.Errorf("canonicalization failed: %w", err)
	}

	// 3. Verify ED25519 signature
	if !ed25519.Verify(publicKey, canonical, signature) {
		return time.Time{}, errors.New("signature verification failed")
	}

	// 4. Check token expiration
	expiresStr, ok := token["_expires"].(string)
	if !ok {
		return time.Time{}, errors.New("missing _expires field")
	}

	tokenExpiry, err := time.Parse(time.RFC3339, expiresStr)
	if err != nil {
		return time.Time{}, errors.New("invalid expiration format")
	}

	// 5. Check token not expired
	if time.Now().After(tokenExpiry) {
		return time.Time{}, errors.New("token expired")
	}

	return tokenExpiry, nil
}

// ExtractSessionID extracts the session UUID from a token.
// This is a convenience function since all tokens should have a _session field.
func ExtractSessionID(token map[string]any) (string, error) {
	sessionID, ok := token["_session"].(string)
	if !ok {
		return "", errors.New("missing _session field")
	}
	return sessionID, nil
}

// ExtractTokenID extracts the token type identifier from a token.
// This is a convenience function since all tokens should have a $tok_id field.
func ExtractTokenID(token map[string]any) (string, error) {
	tokenID, ok := token["$tok_id"].(string)
	if !ok {
		return "", errors.New("missing $tok_id field")
	}
	return tokenID, nil
}

// CompareTokenScope compares non-metadata fields between actual token and expected scope.
// Metadata fields ($ or _ prefix) are validated separately and NOT compared here.
// This does exact matching - the actual token fields must match expected exactly.
//
// Expected scope should contain:
// - $tok_id: the token type (required)
// - Any scope fields the method needs (e.g., mailbox_dir)
//
// Returns error describing the first mismatch found.
func CompareTokenScope(actualToken, expectedScope map[string]any) error {
	// Check $tok_id first since it's critical
	expectedTokenID, ok := expectedScope["$tok_id"].(string)
	if !ok {
		return errors.New("expected scope missing $tok_id")
	}

	actualTokenID, err := ExtractTokenID(actualToken)
	if err != nil {
		return err
	}

	if actualTokenID != expectedTokenID {
		return fmt.Errorf("token type mismatch: expected %s, got %s", expectedTokenID, actualTokenID)
	}

	// Compare all other non-metadata fields
	for key, expectedValue := range expectedScope {
		// Skip $tok_id (already checked) and all metadata fields
		if key == "$tok_id" || strings.HasPrefix(key, "_") {
			continue
		}

		// Skip other $ prefixed fields that might be in expected
		if strings.HasPrefix(key, "$") {
			continue
		}

		actualValue, ok := actualToken[key]
		if !ok {
			return fmt.Errorf("token missing required field: %s", key)
		}

		if actualValue != expectedValue {
			return fmt.Errorf("field %s mismatch: expected %v, got %v",
				key, expectedValue, actualValue)
		}
	}

	return nil
}