diff --git a/apitokens/tokens.go b/apitokens/tokens.go new file mode 100644 index 0000000..0ad9c05 --- /dev/null +++ b/apitokens/tokens.go @@ -0,0 +1,298 @@ +package apitokens + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "time" + + "git.julianfamily.org/keepassgo/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") +) + +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" + OperationReadEntry Operation = "read_entry" + OperationCopyPassword Operation = "copy_password" + OperationCopyUsername Operation = "copy_username" + OperationCopyURL Operation = "copy_url" + OperationMutateEntry Operation = "mutate_entry" +) + +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 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 + } +} diff --git a/apitokens/tokens_test.go b/apitokens/tokens_test.go new file mode 100644 index 0000000..e2c732f --- /dev/null +++ b/apitokens/tokens_test.go @@ -0,0 +1,228 @@ +package apitokens + +import ( + "slices" + "testing" + "time" + + "git.julianfamily.org/keepassgo/vault" +) + +func TestTokenEntryRoundTripsThroughVaultEntry(t *testing.T) { + t.Parallel() + + expiresAt := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC) + token := Token{ + ID: "token-1", + Name: "Browser Connector", + ClientName: "browser-extension", + SecretHash: "deadbeef", + CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC), + ExpiresAt: &expiresAt, + Disabled: true, + Policies: []PolicyRule{ + {Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}}, + {Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank-token"}}, + }, + } + + entry := token.Entry([]string{"Root", "API Tokens"}) + if entry.Fields[FieldType] != EntryTypeAPIToken { + t.Fatalf("FieldType = %q, want %q", entry.Fields[FieldType], EntryTypeAPIToken) + } + + got, ok, err := TokenFromEntry(entry) + if err != nil { + t.Fatalf("TokenFromEntry() error = %v", err) + } + if !ok { + t.Fatal("TokenFromEntry() ok = false, want true") + } + if got.ID != token.ID || got.Name != token.Name || got.ClientName != token.ClientName || got.SecretHash != token.SecretHash || !got.Disabled { + t.Fatalf("TokenFromEntry() = %#v, want %#v", got, token) + } + if got.ExpiresAt == nil || !got.ExpiresAt.Equal(expiresAt) { + t.Fatalf("ExpiresAt = %#v, want %v", got.ExpiresAt, expiresAt) + } + if len(got.Policies) != 2 { + t.Fatalf("len(Policies) = %d, want 2", len(got.Policies)) + } +} + +func TestEntriesFiltersOnlyAPITokens(t *testing.T) { + t.Parallel() + + model := vault.Model{ + Entries: []vault.Entry{ + {ID: "entry-1", Title: "Ordinary Entry"}, + Token{ID: "token-1", Name: "CLI", SecretHash: "hash-1", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)}.Entry([]string{"Root", "API Tokens"}), + Token{ID: "token-2", Name: "Browser", SecretHash: "hash-2", CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)}.Entry([]string{"Root", "API Tokens"}), + }, + } + + got, err := Entries(model) + if err != nil { + t.Fatalf("Entries() error = %v", err) + } + if len(got) != 2 { + t.Fatalf("len(Entries()) = %d, want 2", len(got)) + } + if got[0].Name != "Browser" || got[1].Name != "CLI" { + t.Fatalf("Entries() = %#v, want Browser then CLI by name sort", got) + } +} + +func TestIssueRotateDisableAndRevokeToken(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) + token, secret, err := Issue("Browser Connector", "browser-extension", nil, now) + if err != nil { + t.Fatalf("Issue() error = %v", err) + } + if token.ID == "" || secret == "" { + t.Fatalf("Issue() returned empty token or secret: %#v %q", token, secret) + } + if token.SecretHash == secret { + t.Fatal("SecretHash should not equal cleartext secret") + } + if token.Disabled { + t.Fatal("Disabled = true, want false after Issue") + } + + rotated, newSecret, err := Rotate(token, now.Add(time.Hour)) + if err != nil { + t.Fatalf("Rotate() error = %v", err) + } + if rotated.ID != token.ID { + t.Fatalf("Rotate() changed ID from %q to %q", token.ID, rotated.ID) + } + if newSecret == secret { + t.Fatal("Rotate() returned the same cleartext secret, want a new one") + } + if rotated.SecretHash == token.SecretHash { + t.Fatal("Rotate() left SecretHash unchanged") + } + + disabled := Disable(rotated) + if !disabled.Disabled { + t.Fatal("Disable() did not set Disabled") + } + revoked := Revoke(disabled, now.Add(2*time.Hour)) + if !revoked.Disabled { + t.Fatal("Revoke() should leave token disabled") + } + if revoked.RevokedAt == nil || !revoked.RevokedAt.Equal(now.Add(2*time.Hour)) { + t.Fatalf("RevokedAt = %#v, want %v", revoked.RevokedAt, now.Add(2*time.Hour)) + } +} + +func TestAuthenticateRejectsDisabledExpiredAndWrongSecret(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC) + valid, secret, err := Issue("CLI", "cli-tool", nil, now) + if err != nil { + t.Fatalf("Issue() error = %v", err) + } + expired, expiredSecret, err := Issue("Expired", "cli-tool", nil, now) + if err != nil { + t.Fatalf("Issue(expired) error = %v", err) + } + expiredAt := now.Add(-time.Minute) + expired.ExpiresAt = &expiredAt + disabled, disabledSecret, err := Issue("Disabled", "cli-tool", nil, now) + if err != nil { + t.Fatalf("Issue(disabled) error = %v", err) + } + disabled = Disable(disabled) + + tokens := []Token{expired, disabled, valid} + if _, err := Authenticate(tokens, "wrong-secret", now); err != ErrInvalidToken { + t.Fatalf("Authenticate(wrong-secret) error = %v, want %v", err, ErrInvalidToken) + } + if _, err := Authenticate([]Token{expired}, expiredSecret, now); err != ErrExpiredToken { + t.Fatalf("Authenticate(expired) error = %v, want %v", err, ErrExpiredToken) + } + if _, err := Authenticate([]Token{disabled}, disabledSecret, now); err != ErrDisabledToken { + t.Fatalf("Authenticate(disabled) error = %v, want %v", err, ErrDisabledToken) + } + got, err := Authenticate(tokens, secret, now) + if err != nil { + t.Fatalf("Authenticate(valid) error = %v", err) + } + if got.ID != valid.ID { + t.Fatalf("Authenticate(valid).ID = %q, want %q", got.ID, valid.ID) + } +} + +func TestEvaluatePolicyDistinguishesAllowDenyAndPrompt(t *testing.T) { + t.Parallel() + + token := Token{ + ID: "token-1", + Policies: []PolicyRule{ + {Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}}, + {Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "amazon"}}, + }, + } + + decision := Evaluate(token, OperationListEntries, Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}) + if decision != DecisionAllow { + t.Fatalf("Evaluate(allow) = %q, want %q", decision, DecisionAllow) + } + + decision = Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "amazon", Path: []string{"Root", "Internet"}}) + if decision != DecisionDeny { + t.Fatalf("Evaluate(deny) = %q, want %q", decision, DecisionDeny) + } + + decision = Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "github", Path: []string{"Root", "Internet"}}) + if decision != DecisionPrompt { + t.Fatalf("Evaluate(prompt) = %q, want %q", decision, DecisionPrompt) + } +} + +func TestEntryScopedDenyOverridesGroupAllow(t *testing.T) { + t.Parallel() + + token := Token{ + ID: "token-1", + Policies: []PolicyRule{ + {Effect: EffectAllow, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}}, + {Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank", Path: []string{"Root", "Internet"}}}, + }, + } + + allowed := Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "forum", Path: []string{"Root", "Internet"}}) + denied := Evaluate(token, OperationCopyPassword, Resource{Kind: ResourceEntry, EntryID: "bank", Path: []string{"Root", "Internet"}}) + if allowed != DecisionAllow || denied != DecisionDeny { + t.Fatalf("Evaluate() allow/deny = %q/%q, want %q/%q", allowed, denied, DecisionAllow, DecisionDeny) + } +} + +func TestTokenEntryKeepsPoliciesStableAcrossRoundTripOrdering(t *testing.T) { + t.Parallel() + + token := Token{ + ID: "token-1", + Name: "CLI", + ClientName: "cli", + SecretHash: "hash", + CreatedAt: time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC), + Policies: []PolicyRule{ + {Effect: EffectDeny, Operation: OperationCopyPassword, Resource: Resource{Kind: ResourceEntry, EntryID: "bank"}}, + {Effect: EffectAllow, Operation: OperationListEntries, Resource: Resource{Kind: ResourceGroup, Path: []string{"Root", "Internet"}}}, + }, + } + + got, ok, err := TokenFromEntry(token.Entry([]string{"Root", "API Tokens"})) + if err != nil || !ok { + t.Fatalf("TokenFromEntry() error = %v, ok=%v", err, ok) + } + if !slices.EqualFunc(got.Policies, token.Policies, func(a, b PolicyRule) bool { + return a.Effect == b.Effect && a.Operation == b.Operation && a.Resource.Kind == b.Resource.Kind && a.Resource.EntryID == b.Resource.EntryID && slices.Equal(a.Resource.Path, b.Resource.Path) + }) { + t.Fatalf("Policies after round trip = %#v, want %#v", got.Policies, token.Policies) + } +}