Files
keepassgo/internal/api/server.go
T
2026-04-11 00:52:01 -07:00

1176 lines
37 KiB
Go

package api
import (
"context"
"errors"
"fmt"
"maps"
"net/url"
"os"
"slices"
"strings"
"sync"
"time"
"git.julianfamily.org/keepassgo/internal/apiapproval"
"git.julianfamily.org/keepassgo/internal/apiaudit"
"git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/clipboard"
"git.julianfamily.org/keepassgo/internal/passwords"
"git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/webdav"
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
type Server struct {
keepassgov1.UnimplementedVaultServiceServer
mu sync.RWMutex
model vault.Model
locked bool
dirty bool
lifecycle lifecycleBackend
profiles map[string]passwords.Profile
clipboard clipboard.Writer
approvals *apiapproval.Broker
audit *apiaudit.Log
}
type lifecycleBackend interface {
Current() (vault.Model, error)
Open(string, vault.MasterKey) error
OpenRemote(webdav.Client, string, vault.MasterKey) error
Save() error
Lock() error
Unlock(vault.MasterKey) error
}
type modelReplaceableLifecycle interface {
lifecycleBackend
Replace(vault.Model)
}
func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer) *Server {
return &Server{
model: model,
profiles: profiles,
clipboard: clipboardWriter,
approvals: apiapproval.NewBroker(30 * time.Second),
audit: apiaudit.New(200),
}
}
func NewServerWithLifecycle(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, lifecycle lifecycleBackend) *Server {
server := NewServer(model, profiles, clipboardWriter)
server.lifecycle = lifecycle
return server
}
func (s *Server) ApprovalBroker() *apiapproval.Broker {
return s.approvals
}
func (s *Server) AuditLog() *apiaudit.Log {
return s.audit
}
func (s *Server) ResolveApproval(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
return s.approvals.Resolve(id, outcome)
}
func (s *Server) SetSessionState(model vault.Model, locked, dirty bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.model = model
s.locked = locked
s.dirty = dirty
}
func (s *Server) GetSessionStatus(ctx context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
return nil, err
}
s.mu.RLock()
defer s.mu.RUnlock()
return &keepassgov1.GetSessionStatusResponse{
Locked: s.locked,
Dirty: s.dirty,
EntryCount: uint32(len(s.model.Entries)),
}, nil
}
func (s *Server) OpenVault(ctx context.Context, req *keepassgov1.OpenVaultRequest) (*keepassgov1.OpenVaultResponse, error) {
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
return nil, err
}
if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
}
key := vault.MasterKey{Password: req.GetPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
if err := s.lifecycle.Open(req.GetPath(), key); err != nil {
return nil, mapLifecycleError("open vault", err)
}
model, err := s.lifecycle.Current()
if err != nil {
return nil, mapLifecycleError("load opened vault", err)
}
s.mu.Lock()
s.model = model
s.locked = false
s.dirty = false
s.mu.Unlock()
return &keepassgov1.OpenVaultResponse{}, nil
}
func (s *Server) OpenRemoteVault(ctx context.Context, req *keepassgov1.OpenRemoteVaultRequest) (*keepassgov1.OpenRemoteVaultResponse, error) {
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
return nil, err
}
if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
}
client := webdav.Client{
BaseURL: req.GetBaseUrl(),
Username: req.GetUsername(),
Password: req.GetPassword(),
}
key := vault.MasterKey{Password: req.GetMasterPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
if err := s.lifecycle.OpenRemote(client, req.GetPath(), key); err != nil {
return nil, mapLifecycleError("open remote vault", err)
}
model, err := s.lifecycle.Current()
if err != nil {
return nil, mapLifecycleError("load opened remote vault", err)
}
s.mu.Lock()
s.model = model
s.locked = false
s.dirty = false
s.mu.Unlock()
return &keepassgov1.OpenRemoteVaultResponse{}, nil
}
func (s *Server) SaveVault(ctx context.Context, _ *keepassgov1.SaveVaultRequest) (*keepassgov1.SaveVaultResponse, error) {
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
return nil, err
}
if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
}
if err := s.lifecycle.Save(); err != nil {
return nil, mapLifecycleError("save vault", err)
}
s.mu.Lock()
s.dirty = false
s.mu.Unlock()
return &keepassgov1.SaveVaultResponse{}, nil
}
func (s *Server) LockVault(ctx context.Context, _ *keepassgov1.LockVaultRequest) (*keepassgov1.LockVaultResponse, error) {
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
return nil, err
}
if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
}
if err := s.lifecycle.Lock(); err != nil {
return nil, mapLifecycleError("lock vault", err)
}
s.mu.Lock()
s.locked = true
s.mu.Unlock()
return &keepassgov1.LockVaultResponse{}, nil
}
func (s *Server) UnlockVault(ctx context.Context, req *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) {
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
return nil, err
}
if s.lifecycle == nil {
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
}
key := vault.MasterKey{Password: req.GetPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)}
if err := s.lifecycle.Unlock(key); err != nil {
return nil, mapLifecycleError("unlock vault", err)
}
model, err := s.lifecycle.Current()
if err != nil {
return nil, mapLifecycleError("load unlocked vault", err)
}
s.mu.Lock()
s.model = model
s.locked = false
s.mu.Unlock()
return &keepassgov1.UnlockVaultResponse{}, nil
}
func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBrowserLoginsRequest) (*keepassgov1.FindBrowserLoginsResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
token, err := s.authorizeVaultRequest(ctx, apitokens.OperationListEntries)
if err != nil {
return nil, err
}
pageHost, err := normalizedBrowserHost(req.GetPageUrl())
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
type rankedMatch struct {
match *keepassgov1.BrowserLoginMatch
score int
}
var matches []rankedMatch
for _, entry := range visibleModel(model).Entries {
quality, score := classifyBrowserEntryMatch(pageHost, entry.URL)
if score == 0 {
continue
}
matches = append(matches, rankedMatch{
match: &keepassgov1.BrowserLoginMatch{
Id: entry.ID,
Title: entry.Title,
Username: entry.Username,
Url: entry.URL,
Path: append([]string(nil), entry.Path...),
Quality: quality,
},
score: score,
})
}
slices.SortFunc(matches, func(a, b rankedMatch) int {
switch {
case a.score != b.score:
return b.score - a.score
case a.match.GetTitle() != b.match.GetTitle():
return strings.Compare(a.match.GetTitle(), b.match.GetTitle())
case a.match.GetUsername() != b.match.GetUsername():
return strings.Compare(a.match.GetUsername(), b.match.GetUsername())
default:
return strings.Compare(a.match.GetId(), b.match.GetId())
}
})
out := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches))
for _, match := range matches {
out = append(out, match.match)
}
switch len(out) {
case 1:
s.audit.Record(apiaudit.Event{
Type: apiaudit.EventAutofillFound,
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Operation: apitokens.OperationListEntries,
Message: "browser login match found for " + pageHost,
})
case 2, 3, 4, 5:
s.audit.Record(apiaudit.Event{
Type: apiaudit.EventAutofillAmbiguous,
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Operation: apitokens.OperationListEntries,
Message: "browser login search returned multiple matches for " + pageHost,
})
}
return &keepassgov1.FindBrowserLoginsResponse{Matches: out}, nil
}
func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetBrowserCredentialRequest) (*keepassgov1.GetBrowserCredentialResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
token, err := s.authenticateRequest(ctx)
if err != nil {
return nil, err
}
entry, err := findEntryByID(model, req.GetId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if pageURL := strings.TrimSpace(req.GetPageUrl()); pageURL != "" {
pageHost, err := normalizedBrowserHost(pageURL)
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
if _, score := classifyBrowserEntryMatch(pageHost, entry.URL); score == 0 {
return nil, status.Error(codes.InvalidArgument, "entry url does not match requested page")
}
}
if strings.TrimSpace(entry.Username) != "" {
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyUsername, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
return nil, err
}
}
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyPassword, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
return nil, err
}
if strings.TrimSpace(entry.URL) != "" {
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyURL, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
return nil, err
}
}
s.audit.Record(apiaudit.Event{
Type: apiaudit.EventAutofillFound,
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Operation: apitokens.OperationCopyPassword,
Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path},
Message: "browser credential returned for " + entry.ID,
})
return &keepassgov1.GetBrowserCredentialResponse{
Id: entry.ID,
Username: entry.Username,
Password: entry.Password,
Url: entry.URL,
}, nil
}
func mapLifecycleError(operation string, err error) error {
switch {
case errors.Is(err, os.ErrNotExist):
return status.Errorf(codes.NotFound, "%s: %v", operation, err)
case errors.Is(err, vault.ErrInvalidMasterKey):
return status.Errorf(codes.InvalidArgument, "%s: %v", operation, err)
case errors.Is(err, session.ErrLocked), errors.Is(err, session.ErrNoPath):
return status.Errorf(codes.FailedPrecondition, "%s: %v", operation, err)
case errors.Is(err, webdav.ErrConflict):
return status.Errorf(codes.Aborted, "%s: %v", operation, err)
default:
return status.Errorf(codes.Internal, "%s: %v", operation, err)
}
}
func (s *Server) ListEntries(ctx context.Context, req *keepassgov1.ListEntriesRequest) (*keepassgov1.ListEntriesResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListEntries, req.GetPath()); err != nil {
return nil, err
}
model = visibleModel(model)
var entries []vault.Entry
if strings.TrimSpace(req.GetQuery()) != "" {
results := model.Search(req.GetQuery())
entries = make([]vault.Entry, 0, len(results))
for _, result := range results {
entries = append(entries, result.Entry)
}
} else {
entries = model.EntriesInPath(req.GetPath())
}
resp := &keepassgov1.ListEntriesResponse{
Entries: make([]*keepassgov1.Entry, 0, len(entries)),
}
for _, entry := range entries {
resp.Entries = append(resp.Entries, entryToProto(entry))
}
return resp, nil
}
func (s *Server) ListGroups(ctx context.Context, req *keepassgov1.ListGroupsRequest) (*keepassgov1.ListGroupsResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListGroups, req.GetPath()); err != nil {
return nil, err
}
return &keepassgov1.ListGroupsResponse{
Names: visibleModel(model).ChildGroups(req.GetPath()),
}, nil
}
func (s *Server) CreateGroup(ctx context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) {
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, req.GetParentPath()); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
s.model.CreateGroup(req.GetParentPath(), req.GetName())
s.dirty = true
return &keepassgov1.CreateGroupResponse{}, nil
}
func (s *Server) RenameGroup(ctx context.Context, req *keepassgov1.RenameGroupRequest) (*keepassgov1.RenameGroupResponse, error) {
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, req.GetPath()); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if err := s.model.RenameGroup(req.GetPath(), req.GetNewName()); err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, "rename group: %v", err)
}
s.dirty = true
return &keepassgov1.RenameGroupResponse{}, nil
}
func (s *Server) DeleteGroup(ctx context.Context, req *keepassgov1.DeleteGroupRequest) (*keepassgov1.DeleteGroupResponse, error) {
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateGroup, req.GetPath()); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if err := s.model.DeleteGroup(req.GetPath()); err != nil {
switch {
case errors.Is(err, vault.ErrEntryNotFound):
return nil, status.Error(codes.NotFound, err.Error())
case errors.Is(err, vault.ErrGroupNotEmpty):
return nil, status.Error(codes.FailedPrecondition, err.Error())
default:
return nil, status.Errorf(codes.Internal, "delete group: %v", err)
}
}
s.dirty = true
return &keepassgov1.DeleteGroupResponse{}, nil
}
func (s *Server) UpsertEntry(ctx context.Context, req *keepassgov1.UpsertEntryRequest) (*keepassgov1.UpsertEntryResponse, error) {
if req.GetEntry() == nil {
return nil, status.Error(codes.InvalidArgument, "missing entry")
}
entry := entryFromProto(req.GetEntry())
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
return nil, err
}
s.mu.Lock()
if s.locked {
s.mu.Unlock()
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
s.model.UpsertEntry(entry)
s.dirty = true
s.mu.Unlock()
return &keepassgov1.UpsertEntryResponse{Entry: entryToProto(entry)}, nil
}
func (s *Server) DeleteEntry(ctx context.Context, req *keepassgov1.DeleteEntryRequest) (*keepassgov1.DeleteEntryResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if entry, err := findEntryByID(model, req.GetId()); err == nil {
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
return nil, err
}
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.model.DeleteEntry(req.GetId()); err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, "delete entry: %v", err)
}
s.dirty = true
return &keepassgov1.DeleteEntryResponse{}, nil
}
func (s *Server) RestoreEntry(ctx context.Context, req *keepassgov1.RestoreEntryRequest) (*keepassgov1.RestoreEntryResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
var restored vault.Entry
for _, entry := range model.RecycleBin {
if entry.ID == req.GetId() {
restored = entry
break
}
}
if restored.ID != "" {
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, restored); err != nil {
return nil, err
}
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.model.RestoreEntry(req.GetId()); err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, "restore entry: %v", err)
}
s.dirty = true
return &keepassgov1.RestoreEntryResponse{Entry: entryToProto(restored)}, nil
}
func (s *Server) ListEntryHistory(ctx context.Context, req *keepassgov1.ListEntryHistoryRequest) (*keepassgov1.ListEntryHistoryResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(model, req.GetId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationReadEntry, entry); err != nil {
return nil, err
}
resp := &keepassgov1.ListEntryHistoryResponse{
Entries: make([]*keepassgov1.Entry, 0, len(entry.History)),
}
for _, historical := range entry.History {
resp.Entries = append(resp.Entries, entryToProto(historical))
}
return resp, nil
}
func (s *Server) RestoreEntryHistory(ctx context.Context, req *keepassgov1.RestoreEntryHistoryRequest) (*keepassgov1.RestoreEntryHistoryResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(model, req.GetId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.model.RestoreEntryVersion(req.GetId(), int(req.GetHistoryIndex())); err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, "restore entry history: %v", err)
}
entry, err = findEntryByID(s.model, req.GetId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
s.dirty = true
return &keepassgov1.RestoreEntryHistoryResponse{Entry: entryToProto(entry)}, nil
}
func (s *Server) ListTemplates(ctx context.Context, _ *keepassgov1.ListTemplatesRequest) (*keepassgov1.ListTemplatesResponse, error) {
if _, err := s.authorizeTemplateCollectionRequest(ctx, apitokens.OperationListTemplates); err != nil {
return nil, err
}
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
resp := &keepassgov1.ListTemplatesResponse{
Templates: make([]*keepassgov1.Entry, 0, len(s.model.Templates)),
}
for _, template := range s.model.Templates {
resp.Templates = append(resp.Templates, entryToProto(template))
}
return resp, nil
}
func (s *Server) UpsertTemplate(ctx context.Context, req *keepassgov1.UpsertTemplateRequest) (*keepassgov1.UpsertTemplateResponse, error) {
if req.GetTemplate() == nil {
return nil, status.Error(codes.InvalidArgument, "missing template")
}
if _, err := s.authorizeTemplateRequest(ctx, apitokens.OperationMutateTemplate, req.GetTemplate().GetId()); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry := entryFromProto(req.GetTemplate())
s.model.UpsertTemplate(entry)
s.dirty = true
return &keepassgov1.UpsertTemplateResponse{Template: entryToProto(entry)}, nil
}
func (s *Server) DeleteTemplate(ctx context.Context, req *keepassgov1.DeleteTemplateRequest) (*keepassgov1.DeleteTemplateResponse, error) {
if _, err := s.authorizeTemplateRequest(ctx, apitokens.OperationMutateTemplate, req.GetId()); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
if err := s.model.DeleteTemplate(req.GetId()); err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, "delete template: %v", err)
}
s.dirty = true
return &keepassgov1.DeleteTemplateResponse{}, nil
}
func (s *Server) InstantiateTemplate(ctx context.Context, req *keepassgov1.InstantiateTemplateRequest) (*keepassgov1.InstantiateTemplateResponse, error) {
if req.GetOverrides() == nil {
return nil, status.Error(codes.InvalidArgument, "missing overrides")
}
if _, err := s.authorizeTemplateRequest(ctx, apitokens.OperationListTemplates, req.GetTemplateId()); err != nil {
return nil, err
}
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateEntry, req.GetOverrides().GetPath()); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := s.model.InstantiateTemplate(req.GetTemplateId(), entryFromProto(req.GetOverrides()))
if err != nil {
if errors.Is(err, vault.ErrEntryNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
}
return nil, status.Errorf(codes.Internal, "instantiate template: %v", err)
}
s.dirty = true
return &keepassgov1.InstantiateTemplateResponse{Entry: entryToProto(entry)}, nil
}
func (s *Server) ListAttachments(ctx context.Context, req *keepassgov1.ListAttachmentsRequest) (*keepassgov1.ListAttachmentsResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(model, req.GetEntryId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationReadEntry, entry); err != nil {
return nil, err
}
names := make([]string, 0, len(entry.Attachments))
for name := range entry.Attachments {
names = append(names, name)
}
slices.Sort(names)
return &keepassgov1.ListAttachmentsResponse{Names: names}, nil
}
func (s *Server) UploadAttachment(ctx context.Context, req *keepassgov1.UploadAttachmentRequest) (*keepassgov1.UploadAttachmentResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(model, req.GetEntryId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
entry, index, err := findMutableEntryByID(&s.model, req.GetEntryId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if entry.Attachments == nil {
entry.Attachments = map[string][]byte{}
}
entry.Attachments[req.GetName()] = append([]byte(nil), req.GetContent()...)
s.model.Entries[index] = entry
s.dirty = true
return &keepassgov1.UploadAttachmentResponse{}, nil
}
func (s *Server) DownloadAttachment(ctx context.Context, req *keepassgov1.DownloadAttachmentRequest) (*keepassgov1.DownloadAttachmentResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(model, req.GetEntryId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationReadEntry, entry); err != nil {
return nil, err
}
content, ok := entry.Attachments[req.GetName()]
if !ok {
return nil, status.Error(codes.NotFound, "attachment not found")
}
return &keepassgov1.DownloadAttachmentResponse{
Content: append([]byte(nil), content...),
}, nil
}
func (s *Server) DeleteAttachment(ctx context.Context, req *keepassgov1.DeleteAttachmentRequest) (*keepassgov1.DeleteAttachmentResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(model, req.GetEntryId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if _, err := s.authorizeEntryRequest(ctx, apitokens.OperationMutateEntry, entry); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
entry, index, err := findMutableEntryByID(&s.model, req.GetEntryId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if _, ok := entry.Attachments[req.GetName()]; !ok {
return nil, status.Error(codes.NotFound, "attachment not found")
}
delete(entry.Attachments, req.GetName())
if len(entry.Attachments) == 0 {
entry.Attachments = nil
}
s.model.Entries[index] = entry
s.dirty = true
return &keepassgov1.DeleteAttachmentResponse{}, nil
}
func (s *Server) CopyEntryField(ctx context.Context, req *keepassgov1.CopyEntryFieldRequest) (*keepassgov1.CopyEntryFieldResponse, error) {
model, locked := s.snapshotModel()
if locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
entry, err := findEntryByID(model, req.GetId())
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if _, err := s.authorizeEntryRequest(ctx, copyOperation(req.GetTarget()), entry); err != nil {
return nil, err
}
service := clipboard.Service{Writer: s.clipboard}
if err := service.Copy(model, req.GetId(), clipboard.Target(req.GetTarget())); err != nil {
switch {
case errors.Is(err, vault.ErrEntryNotFound):
return nil, status.Error(codes.NotFound, err.Error())
case errors.Is(err, clipboard.ErrUnsupportedTarget):
return nil, status.Error(codes.InvalidArgument, err.Error())
default:
return nil, status.Errorf(codes.Internal, "copy entry field: %v", err)
}
}
return &keepassgov1.CopyEntryFieldResponse{}, nil
}
func (s *Server) GeneratePassword(ctx context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) {
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationGeneratePassword); err != nil {
return nil, err
}
s.mu.RLock()
defer s.mu.RUnlock()
if s.locked {
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
}
profile, err := passwords.LookupProfile(req.GetProfile(), s.profiles)
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
password, err := passwords.Generate(profile)
if err != nil {
return nil, status.Errorf(codes.Internal, "generate password: %v", err)
}
return &keepassgov1.GeneratePasswordResponse{Password: password}, nil
}
func entryToProto(entry vault.Entry) *keepassgov1.Entry {
return &keepassgov1.Entry{
Id: entry.ID,
Title: entry.Title,
Username: entry.Username,
Password: entry.Password,
Url: entry.URL,
Notes: entry.Notes,
Tags: append([]string(nil), entry.Tags...),
Path: append([]string(nil), entry.Path...),
Fields: maps.Clone(entry.Fields),
}
}
func entryFromProto(entry *keepassgov1.Entry) vault.Entry {
return vault.Entry{
ID: entry.GetId(),
Title: entry.GetTitle(),
Username: entry.GetUsername(),
Password: entry.GetPassword(),
URL: entry.GetUrl(),
Notes: entry.GetNotes(),
Tags: append([]string(nil), entry.GetTags()...),
Path: append([]string(nil), entry.GetPath()...),
Fields: maps.Clone(entry.GetFields()),
}
}
func findEntryByID(model vault.Model, id string) (vault.Entry, error) {
for _, entry := range model.Entries {
if entry.ID == id {
return entry, nil
}
}
return vault.Entry{}, vault.ErrEntryNotFound
}
func findMutableEntryByID(model *vault.Model, id string) (vault.Entry, int, error) {
for i, entry := range model.Entries {
if entry.ID == id {
entry.Attachments = maps.Clone(entry.Attachments)
return entry, i, nil
}
}
return vault.Entry{}, -1, vault.ErrEntryNotFound
}
func normalizedBrowserHost(raw string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return "", fmt.Errorf("parse page url: %w", err)
}
host := strings.ToLower(parsed.Hostname())
if host == "" {
return "", fmt.Errorf("page url must include a hostname")
}
return host, nil
}
func classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) {
parsed, err := url.Parse(strings.TrimSpace(rawEntryURL))
if err != nil {
return "", 0
}
entryHost := strings.ToLower(parsed.Hostname())
if entryHost == "" {
return "", 0
}
switch {
case pageHost == entryHost:
return "exact-host", 3
case strings.HasSuffix(pageHost, "."+entryHost):
return "subdomain", 2
case strings.HasSuffix(entryHost, "."+pageHost):
return "parent-domain", 1
default:
return "", 0
}
}
func visibleModel(model vault.Model) vault.Model {
out := model
out.Entries = nil
for _, entry := range model.Entries {
token, ok, err := apitokens.TokenFromEntry(entry)
if err == nil && ok && token.ID != "" {
continue
}
out.Entries = append(out.Entries, entry)
}
out.Groups = nil
for _, path := range model.Groups {
if len(path) >= 2 && path[0] == "Root" && path[1] == "API Tokens" {
continue
}
out.Groups = append(out.Groups, path)
}
return out
}
func (s *Server) snapshotModel() (vault.Model, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.model, s.locked
}
var timeNow = func() time.Time { return time.Now().UTC() }
var (
vaultPolicyPath = []string{"Root"}
templatePolicyPath = []string{"Root", "Templates"}
)
func (s *Server) authenticateRequest(ctx context.Context) (apitokens.Token, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return apitokens.Token{}, status.Error(codes.Unauthenticated, "missing metadata")
}
values := md.Get("authorization")
if len(values) == 0 {
s.audit.Record(apiaudit.Event{Type: apiaudit.EventAuthRejected, Message: "missing authorization"})
return apitokens.Token{}, status.Error(codes.Unauthenticated, "missing authorization")
}
const prefix = "Bearer "
if !strings.HasPrefix(values[0], prefix) {
s.audit.Record(apiaudit.Event{Type: apiaudit.EventAuthRejected, Message: "invalid bearer token"})
return apitokens.Token{}, status.Error(codes.Unauthenticated, "invalid bearer token")
}
s.mu.RLock()
tokens, err := apitokens.Entries(s.model)
s.mu.RUnlock()
if err != nil {
return apitokens.Token{}, status.Errorf(codes.Internal, "load api tokens: %v", err)
}
token, err := apitokens.Authenticate(tokens, strings.TrimSpace(strings.TrimPrefix(values[0], prefix)), timeNow())
if err != nil {
switch err {
case apitokens.ErrInvalidToken, apitokens.ErrExpiredToken, apitokens.ErrDisabledToken:
s.audit.Record(apiaudit.Event{Type: apiaudit.EventAuthRejected, Message: err.Error()})
return apitokens.Token{}, status.Error(codes.Unauthenticated, err.Error())
default:
return apitokens.Token{}, status.Errorf(codes.Internal, "authenticate api token: %v", err)
}
}
return token, nil
}
func (s *Server) authorizePathRequest(ctx context.Context, op apitokens.Operation, path []string) (apitokens.Token, error) {
token, err := s.authenticateRequest(ctx)
if err != nil {
return apitokens.Token{}, err
}
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path})
}
func (s *Server) authorizeVaultRequest(ctx context.Context, op apitokens.Operation) (apitokens.Token, error) {
return s.authorizePathRequest(ctx, op, vaultPolicyPath)
}
func (s *Server) authorizeTemplateCollectionRequest(ctx context.Context, op apitokens.Operation) (apitokens.Token, error) {
return s.authorizePathRequest(ctx, op, templatePolicyPath)
}
func (s *Server) authorizeEntryRequest(ctx context.Context, op apitokens.Operation, entry vault.Entry) (apitokens.Token, error) {
token, err := s.authenticateRequest(ctx)
if err != nil {
return apitokens.Token{}, err
}
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path})
}
func (s *Server) authorizeTemplateRequest(ctx context.Context, op apitokens.Operation, templateID string) (apitokens.Token, error) {
token, err := s.authenticateRequest(ctx)
if err != nil {
return apitokens.Token{}, err
}
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{
Kind: apitokens.ResourceEntry,
Path: templatePolicyPath,
EntryID: templateID,
})
}
func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (apitokens.Token, error) {
switch apitokens.Evaluate(token, op, resource) {
case apitokens.DecisionAllow:
return token, nil
case apitokens.DecisionDeny:
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access is not allowed for this token")
case apitokens.DecisionPrompt:
s.audit.Record(apiaudit.Event{
Type: apiaudit.EventApprovalRequested,
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Operation: op,
Resource: resource,
})
result, err := s.approvals.Request(ctx, token, op, resource)
if result.Rule != nil {
if persistErr := s.persistApprovalRule(token.ID, *result.Rule); persistErr != nil {
return apitokens.Token{}, status.Errorf(codes.Internal, "persist approval decision: %v", persistErr)
}
}
switch {
case err == nil:
s.audit.Record(apiaudit.Event{
Type: apiaudit.EventApprovalAllowed,
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Operation: op,
Resource: resource,
})
return token, nil
case errors.Is(err, apiapproval.ErrRequestDenied):
s.audit.Record(apiaudit.Event{
Type: apiaudit.EventApprovalDenied,
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Operation: op,
Resource: resource,
})
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access denied by user approval")
case errors.Is(err, apiapproval.ErrRequestCanceled):
s.audit.Record(apiaudit.Event{
Type: apiaudit.EventApprovalCanceled,
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Operation: op,
Resource: resource,
})
return apitokens.Token{}, status.Error(codes.Unauthenticated, "authorization request canceled")
case errors.Is(err, apiapproval.ErrRequestTimedOut):
s.audit.Record(apiaudit.Event{
Type: apiaudit.EventApprovalTimedOut,
TokenID: token.ID,
TokenName: token.Name,
ClientName: token.ClientName,
Operation: op,
Resource: resource,
})
return apitokens.Token{}, status.Error(codes.DeadlineExceeded, "authorization request timed out")
case errors.Is(err, context.Canceled):
return apitokens.Token{}, status.Error(codes.Canceled, "authorization request canceled")
case errors.Is(err, context.DeadlineExceeded):
return apitokens.Token{}, status.Error(codes.DeadlineExceeded, "authorization request timed out")
default:
return apitokens.Token{}, status.Errorf(codes.Internal, "await authorization request: %v", err)
}
default:
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access is not allowed for this token")
}
}
func (s *Server) persistApprovalRule(tokenID string, rule apitokens.PolicyRule) error {
s.mu.Lock()
defer s.mu.Unlock()
for i, entry := range s.model.Entries {
token, ok, err := apitokens.TokenFromEntry(entry)
if err != nil || !ok || token.ID != tokenID {
continue
}
if !hasPolicyRule(token.Policies, rule) {
token.Policies = append(token.Policies, rule)
}
s.model.Entries[i] = token.Entry(entry.Path)
s.dirty = true
if lifecycle, ok := s.lifecycle.(modelReplaceableLifecycle); ok {
lifecycle.Replace(s.model)
}
return nil
}
return status.Error(codes.NotFound, "api token entry not found")
}
func hasPolicyRule(rules []apitokens.PolicyRule, target apitokens.PolicyRule) bool {
for _, rule := range rules {
if rule.Effect != target.Effect || rule.Operation != target.Operation {
continue
}
if rule.Resource.Kind != target.Resource.Kind || rule.Resource.EntryID != target.Resource.EntryID {
continue
}
if slices.Equal(rule.Resource.Path, target.Resource.Path) {
return true
}
}
return false
}
func copyOperation(target string) apitokens.Operation {
switch clipboard.Target(target) {
case clipboard.TargetUsername:
return apitokens.OperationCopyUsername
case clipboard.TargetURL:
return apitokens.OperationCopyURL
default:
return apitokens.OperationCopyPassword
}
}