Files
2026-04-10 23:36:56 -07:00

339 lines
8.3 KiB
Go

package apitokens
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
"git.julianfamily.org/keepassgo/internal/vault"
)
const (
EntryTypeAPIToken = "api-token"
FieldType = "KeePassGO-Type"
FieldTokenID = "KeePassGO-API-Token-ID"
FieldClientName = "KeePassGO-API-Client-Name"
FieldCreatedAt = "KeePassGO-API-Created-At"
FieldExpiresAt = "KeePassGO-API-Expires-At"
FieldDisabled = "KeePassGO-API-Disabled"
FieldRevokedAt = "KeePassGO-API-Revoked-At"
FieldSecretHash = "KeePassGO-API-Secret-Hash"
FieldPolicies = "KeePassGO-API-Policies"
)
var (
ErrNotAToken = errors.New("entry is not an api token")
ErrInvalidToken = errors.New("invalid api token")
ErrExpiredToken = errors.New("expired api token")
ErrDisabledToken = errors.New("disabled api token")
ErrTokenNotFound = errors.New("api token not found")
)
var EntryPath = []string{"Root", "API Tokens"}
type Effect string
type Operation string
type ResourceKind string
type Decision string
const (
EffectAllow Effect = "allow"
EffectDeny Effect = "deny"
ResourceGroup ResourceKind = "group"
ResourceEntry ResourceKind = "entry"
DecisionAllow Decision = "allow"
DecisionDeny Decision = "deny"
DecisionPrompt Decision = "prompt"
OperationListEntries Operation = "list_entries"
OperationListGroups Operation = "list_groups"
OperationListTemplates Operation = "list_templates"
OperationReadEntry Operation = "read_entry"
OperationCopyPassword Operation = "copy_password"
OperationCopyUsername Operation = "copy_username"
OperationCopyURL Operation = "copy_url"
OperationMutateEntry Operation = "mutate_entry"
OperationMutateGroup Operation = "mutate_group"
OperationMutateTemplate Operation = "mutate_template"
OperationGeneratePassword Operation = "generate_password"
OperationManageVault Operation = "manage_vault"
)
type Resource struct {
Kind ResourceKind `json:"kind"`
Path []string `json:"path,omitempty"`
EntryID string `json:"entry_id,omitempty"`
}
type PolicyRule struct {
Effect Effect `json:"effect"`
Operation Operation `json:"operation"`
Resource Resource `json:"resource"`
}
type Token struct {
ID string
Name string
ClientName string
SecretHash string
CreatedAt time.Time
ExpiresAt *time.Time
RevokedAt *time.Time
Disabled bool
Policies []PolicyRule
}
func Issue(name, clientName string, expiresAt *time.Time, now time.Time) (Token, string, error) {
clear, hashed, err := newSecret()
if err != nil {
return Token{}, "", err
}
id, _, err := newSecret()
if err != nil {
return Token{}, "", err
}
return Token{
ID: id,
Name: strings.TrimSpace(name),
ClientName: strings.TrimSpace(clientName),
SecretHash: hashed,
CreatedAt: now.UTC(),
ExpiresAt: cloneTime(expiresAt),
}, clear, nil
}
func Rotate(token Token, now time.Time) (Token, string, error) {
clear, hashed, err := newSecret()
if err != nil {
return Token{}, "", err
}
token.SecretHash = hashed
token.Disabled = false
token.RevokedAt = nil
if token.CreatedAt.IsZero() {
token.CreatedAt = now.UTC()
}
return token, clear, nil
}
func Disable(token Token) Token {
token.Disabled = true
return token
}
func Revoke(token Token, when time.Time) Token {
token.Disabled = true
t := when.UTC()
token.RevokedAt = &t
return token
}
func Authenticate(tokens []Token, presentedSecret string, now time.Time) (Token, error) {
hashed := hashSecret(presentedSecret)
for _, token := range tokens {
if token.SecretHash != hashed {
continue
}
if token.Disabled || token.RevokedAt != nil {
return Token{}, ErrDisabledToken
}
if token.ExpiresAt != nil && !token.ExpiresAt.After(now.UTC()) {
return Token{}, ErrExpiredToken
}
return token, nil
}
return Token{}, ErrInvalidToken
}
func Evaluate(token Token, operation Operation, resource Resource) Decision {
decision := DecisionPrompt
for _, rule := range token.Policies {
if rule.Operation != operation {
continue
}
if !matches(rule.Resource, resource) {
continue
}
if rule.Effect == EffectDeny {
return DecisionDeny
}
if rule.Effect == EffectAllow {
decision = DecisionAllow
}
}
return decision
}
func Entries(model vault.Model) ([]Token, error) {
var out []Token
for _, entry := range model.Entries {
token, ok, err := TokenFromEntry(entry)
if err != nil {
return nil, err
}
if ok {
out = append(out, token)
}
}
slices.SortFunc(out, func(a, b Token) int {
switch {
case a.Name < b.Name:
return -1
case a.Name > b.Name:
return 1
default:
return strings.Compare(a.ID, b.ID)
}
})
return out, nil
}
func Find(model vault.Model, id string) (Token, error) {
tokens, err := Entries(model)
if err != nil {
return Token{}, err
}
for _, token := range tokens {
if token.ID == id {
return token, nil
}
}
return Token{}, ErrTokenNotFound
}
func Upsert(model *vault.Model, token Token) {
model.UpsertEntry(token.Entry(EntryPath))
model.CreateGroup([]string{"Root"}, "API Tokens")
}
func Delete(model *vault.Model, id string) error {
for i, entry := range model.Entries {
token, ok, err := TokenFromEntry(entry)
if err != nil {
return err
}
if ok && token.ID == id {
model.Entries = append(model.Entries[:i], model.Entries[i+1:]...)
return nil
}
}
return ErrTokenNotFound
}
func TokenFromEntry(entry vault.Entry) (Token, bool, error) {
if entry.Fields[FieldType] != EntryTypeAPIToken {
return Token{}, false, nil
}
createdAt, err := time.Parse(time.RFC3339, entry.Fields[FieldCreatedAt])
if err != nil {
return Token{}, true, fmt.Errorf("parse created at: %w", err)
}
var expiresAt *time.Time
if raw := strings.TrimSpace(entry.Fields[FieldExpiresAt]); raw != "" {
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return Token{}, true, fmt.Errorf("parse expires at: %w", err)
}
expiresAt = &t
}
var revokedAt *time.Time
if raw := strings.TrimSpace(entry.Fields[FieldRevokedAt]); raw != "" {
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return Token{}, true, fmt.Errorf("parse revoked at: %w", err)
}
revokedAt = &t
}
policies := []PolicyRule{}
if raw := strings.TrimSpace(entry.Fields[FieldPolicies]); raw != "" {
if err := json.Unmarshal([]byte(raw), &policies); err != nil {
return Token{}, true, fmt.Errorf("parse policies: %w", err)
}
}
return Token{
ID: entry.Fields[FieldTokenID],
Name: entry.Title,
ClientName: entry.Fields[FieldClientName],
SecretHash: entry.Fields[FieldSecretHash],
CreatedAt: createdAt,
ExpiresAt: expiresAt,
RevokedAt: revokedAt,
Disabled: strings.EqualFold(entry.Fields[FieldDisabled], "true"),
Policies: policies,
}, true, nil
}
func (t Token) Entry(path []string) vault.Entry {
fields := map[string]string{
FieldType: EntryTypeAPIToken,
FieldTokenID: t.ID,
FieldClientName: t.ClientName,
FieldCreatedAt: t.CreatedAt.UTC().Format(time.RFC3339),
FieldDisabled: fmt.Sprintf("%t", t.Disabled),
FieldSecretHash: t.SecretHash,
}
if t.ExpiresAt != nil {
fields[FieldExpiresAt] = t.ExpiresAt.UTC().Format(time.RFC3339)
}
if t.RevokedAt != nil {
fields[FieldRevokedAt] = t.RevokedAt.UTC().Format(time.RFC3339)
}
if len(t.Policies) > 0 {
data, _ := json.Marshal(t.Policies)
fields[FieldPolicies] = string(data)
}
return vault.Entry{
ID: t.ID,
Title: t.Name,
Username: t.ClientName,
Path: slices.Clone(path),
Fields: fields,
}
}
func hashSecret(secret string) string {
sum := sha256.Sum256([]byte(secret))
return hex.EncodeToString(sum[:])
}
func newSecret() (string, string, error) {
buf := make([]byte, 24)
if _, err := rand.Read(buf); err != nil {
return "", "", fmt.Errorf("generate secret: %w", err)
}
clear := base64.RawURLEncoding.EncodeToString(buf)
return clear, hashSecret(clear), nil
}
func cloneTime(in *time.Time) *time.Time {
if in == nil {
return nil
}
t := in.UTC()
return &t
}
func matches(rule, resource Resource) bool {
switch rule.Kind {
case ResourceEntry:
return rule.EntryID != "" && rule.EntryID == resource.EntryID
case ResourceGroup:
if len(rule.Path) > len(resource.Path) {
return false
}
return slices.Equal(rule.Path, resource.Path[:len(rule.Path)])
default:
return false
}
}