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{}
}
}
|