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/vaultview" "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 notify func() } 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) } type rankedBrowserMatch struct { match *keepassgov1.BrowserLoginMatch score int resource apitokens.Resource decision apitokens.Decision } 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) SetChangeNotifier(notify func()) { if s == nil { return } s.mu.Lock() defer s.mu.Unlock() s.notify = notify } 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) { token, err := s.authenticateRequest(ctx) if err != nil { return nil, err } pendingApprovals := s.approvals.Pending() var tokenPending uint32 for _, pending := range pendingApprovals { if pending.TokenID == token.ID { tokenPending++ } } s.mu.RLock() locked := s.locked dirty := s.dirty entryCount := uint32(len(s.model.Entries)) s.mu.RUnlock() return &keepassgov1.GetSessionStatusResponse{ Locked: locked, Dirty: dirty, EntryCount: entryCount, PendingApprovalCount: uint32(len(pendingApprovals)), TokenPendingApprovalCount: tokenPending, }, 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") } displayModel := visibleModel(model) token, err := s.authenticateRequest(ctx) if err != nil { return nil, err } pageHost, err := normalizedBrowserHost(req.GetPageUrl()) if err != nil { return nil, status.Error(codes.InvalidArgument, err.Error()) } var matches []rankedBrowserMatch for _, entry := range displayModel.Entries { quality, score := classifyBrowserEntryMatch(pageHost, entry.URL) if score == 0 { continue } resource := apitokens.Resource{Kind: apitokens.ResourceGroup, Path: entry.Path} matches = append(matches, rankedBrowserMatch{ 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, resource: resource, decision: evaluateAuthorization(model, token, apitokens.OperationListEntries, resource), }) } slices.SortFunc(matches, func(a, b rankedBrowserMatch) 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, err := s.authorizedBrowserMatches(ctx, token, matches) if err != nil { return nil, err } 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) authorizedBrowserMatches(ctx context.Context, token apitokens.Token, matches []rankedBrowserMatch) ([]*keepassgov1.BrowserLoginMatch, error) { out := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches)) for _, match := range matches { if match.decision == apitokens.DecisionAllow { out = append(out, match.match) } } if len(out) != 0 { return out, nil } for _, match := range matches { if match.decision != apitokens.DecisionPrompt { continue } if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationListEntries, match.resource); err != nil { return nil, err } return s.authorizedBrowserMatchesWithinPath(ctx, token, matches, match.resource.Path) } return out, nil } func (s *Server) authorizedBrowserMatchesWithinPath(_ context.Context, _ apitokens.Token, matches []rankedBrowserMatch, path []string) ([]*keepassgov1.BrowserLoginMatch, error) { out := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches)) for _, match := range matches { if len(path) > len(match.resource.Path) { continue } if !slices.Equal(path, match.resource.Path[:len(path)]) { continue } if match.decision == apitokens.DecisionDeny { continue } out = append(out, match.match) } return 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") } displayModel := visibleModel(model) internalPath := expandClientPath(displayModel, req.GetPath()) if _, err := s.authorizePathRequest(ctx, apitokens.OperationListEntries, internalPath); err != nil { return nil, err } model = displayModel 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(internalPath) } resp := &keepassgov1.ListEntriesResponse{ Entries: make([]*keepassgov1.Entry, 0, len(entries)), } for _, entry := range entries { resp.Entries = append(resp.Entries, entryToProtoWithModel(model, 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") } displayModel := visibleModel(model) internalPath := expandClientPath(displayModel, req.GetPath()) if _, err := s.authorizePathRequest(ctx, apitokens.OperationListGroups, internalPath); err != nil { return nil, err } return &keepassgov1.ListGroupsResponse{ Names: displayModel.ChildGroups(internalPath), }, nil } func (s *Server) mutateAuthorizedVisiblePath(ctx context.Context, clientPath []string, op apitokens.Operation, mutate func(*vault.Model, []string) error) error { model, locked := s.snapshotModel() if locked { return status.Error(codes.FailedPrecondition, "vault is locked") } internalPath := expandClientPath(visibleModel(model), clientPath) if _, err := s.authorizePathRequest(ctx, op, internalPath); err != nil { return err } return s.mutateAuthorizedModel(func() error { return nil }, func(model *vault.Model) error { return mutate(model, internalPath) }) } func (s *Server) mutateAuthorizedModel(authorize func() error, mutate func(*vault.Model) error) error { if err := authorize(); err != nil { return err } s.mu.Lock() defer s.mu.Unlock() if err := mutate(&s.model); err != nil { return err } s.dirty = true s.syncMutationLocked() return nil } func (s *Server) CreateGroup(ctx context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) { if err := s.mutateAuthorizedVisiblePath(ctx, req.GetParentPath(), apitokens.OperationMutateGroup, func(model *vault.Model, parentPath []string) error { model.CreateGroup(parentPath, req.GetName()) return nil }); err != nil { return nil, err } return &keepassgov1.CreateGroupResponse{}, nil } func (s *Server) RenameGroup(ctx context.Context, req *keepassgov1.RenameGroupRequest) (*keepassgov1.RenameGroupResponse, error) { if err := s.mutateAuthorizedVisiblePath(ctx, req.GetPath(), apitokens.OperationMutateGroup, func(model *vault.Model, groupPath []string) error { if err := model.RenameGroup(groupPath, req.GetNewName()); err != nil { if errors.Is(err, vault.ErrEntryNotFound) { return status.Error(codes.NotFound, err.Error()) } return status.Errorf(codes.Internal, "rename group: %v", err) } return nil }); err != nil { return nil, err } return &keepassgov1.RenameGroupResponse{}, nil } func (s *Server) DeleteGroup(ctx context.Context, req *keepassgov1.DeleteGroupRequest) (*keepassgov1.DeleteGroupResponse, error) { if err := s.mutateAuthorizedVisiblePath(ctx, req.GetPath(), apitokens.OperationMutateGroup, func(model *vault.Model, groupPath []string) error { if err := model.DeleteGroup(groupPath); err != nil { switch { case errors.Is(err, vault.ErrEntryNotFound): return status.Error(codes.NotFound, err.Error()) case errors.Is(err, vault.ErrGroupNotEmpty): return status.Error(codes.FailedPrecondition, err.Error()) default: return status.Errorf(codes.Internal, "delete group: %v", err) } } return nil }); err != nil { return nil, err } 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") } model, locked := s.snapshotModel() if locked { return nil, status.Error(codes.FailedPrecondition, "vault is locked") } entry := entryFromProtoWithModel(visibleModel(model), req.GetEntry()) if _, err := s.authorizeUpsertEntryRequest(ctx, entry); err != nil { return nil, err } if err := s.mutateAuthorizedModel(func() error { return nil }, func(model *vault.Model) error { model.UpsertEntry(entry) return nil }); err != nil { return nil, err } return &keepassgov1.UpsertEntryResponse{Entry: entryToProtoWithModel(visibleModel(model), 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 s.syncMutationLocked() 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 s.syncMutationLocked() return &keepassgov1.RestoreEntryResponse{Entry: entryToProtoWithModel(visibleModel(model), 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, entryToProtoWithModel(visibleModel(model), 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 s.syncMutationLocked() return &keepassgov1.RestoreEntryHistoryResponse{Entry: entryToProtoWithModel(visibleModel(s.model), 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, entryToProtoWithModel(visibleModel(s.model), 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 := entryFromProtoWithModel(visibleModel(s.model), req.GetTemplate()) s.model.UpsertTemplate(entry) s.dirty = true s.syncMutationLocked() return &keepassgov1.UpsertTemplateResponse{Template: entryToProtoWithModel(visibleModel(s.model), 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 s.syncMutationLocked() 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 } overridePath := expandClientPath(visibleModel(s.model), req.GetOverrides().GetPath()) if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateEntry, overridePath); err != nil { return nil, err } s.mu.Lock() defer s.mu.Unlock() if s.locked { return nil, status.Error(codes.FailedPrecondition, "vault is locked") } overrides := entryFromProtoWithModel(visibleModel(s.model), req.GetOverrides()) entry, err := s.model.InstantiateTemplate(req.GetTemplateId(), overrides) 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 s.syncMutationLocked() return &keepassgov1.InstantiateTemplateResponse{Entry: entryToProtoWithModel(visibleModel(s.model), 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 s.syncMutationLocked() 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 s.syncMutationLocked() 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 entryToProtoWithModel(model vault.Model, 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: collapseInternalPath(model, entry.Path), Fields: maps.Clone(entry.Fields), } } func entryFromProtoWithModel(model vault.Model, 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: expandClientPath(model, entry.GetPath()), Fields: maps.Clone(entry.GetFields()), } } func expandClientPath(model vault.Model, path []string) []string { root := vaultview.HiddenRoot(model) if root == "" { return append([]string(nil), path...) } if len(path) == 0 { return []string{root} } if path[0] == root { return append([]string(nil), path...) } return append([]string{root}, path...) } func collapseInternalPath(model vault.Model, path []string) []string { root := vaultview.HiddenRoot(model) if root == "" || len(path) == 0 || path[0] != root { return append([]string(nil), path...) } return append([]string(nil), path[1:]...) } 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 normalizedBrowserEntryHost(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } parsed, err := url.Parse(raw) if err == nil { if host := strings.ToLower(parsed.Hostname()); host != "" { return host } } if !strings.Contains(raw, "://") { parsed, err = url.Parse("https://" + raw) if err == nil { return strings.ToLower(parsed.Hostname()) } } return "" } func classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) { entryHost := normalizedBrowserEntryHost(rawEntryURL) if entryHost == "" { return "", 0 } switch { case pageHost == entryHost: return "exact-host", 3 case strings.HasSuffix(pageHost, "."+entryHost): return "subdomain", 2 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) authorizeUpsertEntryRequest(ctx context.Context, entry vault.Entry) (apitokens.Token, error) { token, err := s.authenticateRequest(ctx) if err != nil { return apitokens.Token{}, err } model, locked := s.snapshotModel() if locked { return apitokens.Token{}, status.Error(codes.FailedPrecondition, "vault is locked") } existing, err := findEntryByID(model, entry.ID) switch { case err == nil: if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationMutateEntry, apitokens.Resource{ Kind: apitokens.ResourceEntry, EntryID: existing.ID, Path: existing.Path, }); err != nil { return apitokens.Token{}, err } if !slices.Equal(existing.Path, entry.Path) { if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationMutateEntry, apitokens.Resource{ Kind: apitokens.ResourceGroup, Path: entry.Path, }); err != nil { return apitokens.Token{}, err } } return token, nil case errors.Is(err, vault.ErrEntryNotFound): return s.authorizeResourceRequest(ctx, token, apitokens.OperationMutateEntry, apitokens.Resource{ Kind: apitokens.ResourceGroup, Path: entry.Path, }) default: return apitokens.Token{}, status.Errorf(codes.Internal, "lookup existing entry: %v", err) } } 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) { model, _ := s.snapshotModel() displayResource := displayAuthorizationResource(resource) switch evaluateAuthorization(model, 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: displayResource, }) result, err := s.approvals.Request(ctx, token, op, displayResource) 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: displayResource, }) 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: displayResource, }) 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: displayResource, }) 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: displayResource, }) 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 s.syncMutationLocked() return nil } return status.Error(codes.NotFound, "api token entry not found") } func (s *Server) syncMutationLocked() { if lifecycle, ok := s.lifecycle.(modelReplaceableLifecycle); ok { lifecycle.Replace(s.model) } if s.notify != nil { s.notify() } } 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 evaluateAuthorization(model vault.Model, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) apitokens.Decision { return apitokens.Evaluate(canonicalizeTokenForAuthorization(model, token), op, canonicalizeAuthorizationResource(model, resource)) } func canonicalizeTokenForAuthorization(model vault.Model, token apitokens.Token) apitokens.Token { token.Policies = append([]apitokens.PolicyRule(nil), token.Policies...) for i := range token.Policies { token.Policies[i].Resource = canonicalizeAuthorizationResource(model, token.Policies[i].Resource) } return token } func canonicalizeAuthorizationResource(model vault.Model, resource apitokens.Resource) apitokens.Resource { resource.Path = canonicalAuthorizationPath(model, resource.Path) return resource } func displayAuthorizationResource(resource apitokens.Resource) apitokens.Resource { resource.Path = displayAuthorizationPath(resource.Path) return resource } func canonicalAuthorizationPath(model vault.Model, path []string) []string { if len(path) == 0 { return nil } if path[0] == vaultview.KeepassRoot { return append([]string(nil), path...) } if path[0] == "Root" { if len(path) > 1 && (path[1] == "Templates" || path[1] == "API Tokens") { return append([]string(nil), path...) } return vaultview.VaultRoot(model).ToPhysicalPath(path[1:]) } if path[0] == "Templates" || path[0] == "API Tokens" { return append([]string(nil), path...) } return vaultview.VaultRoot(model).ToPhysicalPath(path) } func displayAuthorizationPath(path []string) []string { if len(path) == 0 { return nil } if path[0] == vaultview.KeepassRoot { return append([]string{"Root"}, append([]string(nil), path[1:]...)...) } if path[0] == "Root" { return append([]string(nil), path...) } return append([]string(nil), path...) } 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 } }