package api import ( "context" "errors" "maps" "os" "slices" "strings" "sync" "git.julianfamily.org/keepassgo/clipboard" "git.julianfamily.org/keepassgo/passwords" keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1" "git.julianfamily.org/keepassgo/session" "git.julianfamily.org/keepassgo/vault" "git.julianfamily.org/keepassgo/webdav" "google.golang.org/grpc" "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 } 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 } func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer) *Server { return &Server{ model: model, profiles: profiles, clipboard: clipboardWriter, } } 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) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) { 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(_ context.Context, req *keepassgov1.OpenVaultRequest) (*keepassgov1.OpenVaultResponse, error) { 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(_ context.Context, req *keepassgov1.OpenRemoteVaultRequest) (*keepassgov1.OpenRemoteVaultResponse, error) { 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(_ context.Context, _ *keepassgov1.SaveVaultRequest) (*keepassgov1.SaveVaultResponse, error) { 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(_ context.Context, _ *keepassgov1.LockVaultRequest) (*keepassgov1.LockVaultResponse, error) { 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(_ context.Context, req *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) { 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 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(_ context.Context, req *keepassgov1.ListEntriesRequest) (*keepassgov1.ListEntriesResponse, error) { s.mu.RLock() defer s.mu.RUnlock() if s.locked { return nil, status.Error(codes.FailedPrecondition, "vault is locked") } var entries []vault.Entry if strings.TrimSpace(req.GetQuery()) != "" { results := s.model.Search(req.GetQuery()) entries = make([]vault.Entry, 0, len(results)) for _, result := range results { entries = append(entries, result.Entry) } } else { entries = s.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(_ context.Context, req *keepassgov1.ListGroupsRequest) (*keepassgov1.ListGroupsResponse, error) { s.mu.RLock() defer s.mu.RUnlock() if s.locked { return nil, status.Error(codes.FailedPrecondition, "vault is locked") } return &keepassgov1.ListGroupsResponse{ Names: s.model.ChildGroups(req.GetPath()), }, nil } func (s *Server) CreateGroup(_ context.Context, req *keepassgov1.CreateGroupRequest) (*keepassgov1.CreateGroupResponse, error) { 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(_ context.Context, req *keepassgov1.RenameGroupRequest) (*keepassgov1.RenameGroupResponse, error) { 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) UpsertEntry(_ context.Context, req *keepassgov1.UpsertEntryRequest) (*keepassgov1.UpsertEntryResponse, error) { if req.GetEntry() == nil { return nil, status.Error(codes.InvalidArgument, "missing entry") } entry := entryFromProto(req.GetEntry()) 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(_ context.Context, req *keepassgov1.DeleteEntryRequest) (*keepassgov1.DeleteEntryResponse, error) { s.mu.Lock() defer s.mu.Unlock() if s.locked { return nil, status.Error(codes.FailedPrecondition, "vault is locked") } 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(_ context.Context, req *keepassgov1.RestoreEntryRequest) (*keepassgov1.RestoreEntryResponse, error) { s.mu.Lock() defer s.mu.Unlock() if s.locked { return nil, status.Error(codes.FailedPrecondition, "vault is locked") } var restored vault.Entry for _, entry := range s.model.RecycleBin { if entry.ID == req.GetId() { restored = entry break } } 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(_ context.Context, req *keepassgov1.ListEntryHistoryRequest) (*keepassgov1.ListEntryHistoryResponse, error) { s.mu.RLock() defer s.mu.RUnlock() if s.locked { return nil, status.Error(codes.FailedPrecondition, "vault is locked") } entry, err := findEntryByID(s.model, req.GetId()) if err != nil { return nil, status.Error(codes.NotFound, err.Error()) } 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(_ context.Context, req *keepassgov1.RestoreEntryHistoryRequest) (*keepassgov1.RestoreEntryHistoryResponse, error) { s.mu.Lock() defer s.mu.Unlock() if s.locked { return nil, status.Error(codes.FailedPrecondition, "vault is locked") } 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(_ context.Context, _ *keepassgov1.ListTemplatesRequest) (*keepassgov1.ListTemplatesResponse, error) { 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(_ context.Context, req *keepassgov1.UpsertTemplateRequest) (*keepassgov1.UpsertTemplateResponse, error) { if req.GetTemplate() == nil { return nil, status.Error(codes.InvalidArgument, "missing template") } 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(_ context.Context, req *keepassgov1.DeleteTemplateRequest) (*keepassgov1.DeleteTemplateResponse, error) { 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(_ context.Context, req *keepassgov1.InstantiateTemplateRequest) (*keepassgov1.InstantiateTemplateResponse, error) { if req.GetOverrides() == nil { return nil, status.Error(codes.InvalidArgument, "missing overrides") } 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(_ context.Context, req *keepassgov1.ListAttachmentsRequest) (*keepassgov1.ListAttachmentsResponse, error) { s.mu.RLock() defer s.mu.RUnlock() if s.locked { return nil, status.Error(codes.FailedPrecondition, "vault is locked") } entry, err := findEntryByID(s.model, req.GetEntryId()) if err != nil { return nil, status.Error(codes.NotFound, err.Error()) } 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(_ context.Context, req *keepassgov1.UploadAttachmentRequest) (*keepassgov1.UploadAttachmentResponse, error) { s.mu.Lock() defer s.mu.Unlock() if s.locked { return nil, status.Error(codes.FailedPrecondition, "vault is locked") } 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(_ context.Context, req *keepassgov1.DownloadAttachmentRequest) (*keepassgov1.DownloadAttachmentResponse, error) { s.mu.RLock() defer s.mu.RUnlock() if s.locked { return nil, status.Error(codes.FailedPrecondition, "vault is locked") } entry, err := findEntryByID(s.model, req.GetEntryId()) if err != nil { return nil, status.Error(codes.NotFound, err.Error()) } 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(_ context.Context, req *keepassgov1.DeleteAttachmentRequest) (*keepassgov1.DeleteAttachmentResponse, error) { s.mu.Lock() defer s.mu.Unlock() if s.locked { return nil, status.Error(codes.FailedPrecondition, "vault is locked") } 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(_ context.Context, req *keepassgov1.CopyEntryFieldRequest) (*keepassgov1.CopyEntryFieldResponse, error) { s.mu.RLock() model := s.model locked := s.locked s.mu.RUnlock() if locked { return nil, status.Error(codes.FailedPrecondition, "vault is locked") } 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(_ context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) { s.mu.RLock() defer s.mu.RUnlock() if s.locked { return nil, status.Error(codes.FailedPrecondition, "vault is locked") } profile, ok := s.profiles[req.GetProfile()] if !ok { return nil, status.Errorf(codes.InvalidArgument, "unknown password profile %q", req.GetProfile()) } 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...), } } 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()...), } } 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 BearerTokenInterceptor(expectedToken string) grpc.UnaryServerInterceptor { return func( ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler, ) (any, error) { md, ok := metadata.FromIncomingContext(ctx) if !ok { return nil, status.Error(codes.Unauthenticated, "missing metadata") } values := md.Get("authorization") if len(values) == 0 { return nil, status.Error(codes.Unauthenticated, "missing authorization") } if values[0] != "Bearer "+expectedToken { return nil, status.Error(codes.Unauthenticated, "invalid bearer token") } return handler(ctx, req) } }