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