Files
cyi-box/backend/internal/service/vault.go
T
chiyiandDevin 834ecacdb9 cyi-box: Memphis-style toolbox admin platform
Go(fun)+SQLite backend, Vue 3 frontend. Modules: dashboard, pay gateway
(Epay-compatible + CDK stock), license/card system (per-app appid, domain
binding), AI memory bank with built-in MCP server (/mcp, FTS5 search,
signed read links), server SSH monitor + terminal + SFTP, proxy pool with
protocol-level alive checks, DB manager (mysql/pg/sqlite + SQL
autocomplete + import/export), disk share, vault (AES-GCM + TOTP),
bookmarks, dev tools.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-01 01:58:47 +08:00

189 lines
4.9 KiB
Go

package service
import (
"errors"
"fmt"
"strings"
"time"
"github.com/cyi-cc/fun"
"github.com/cyi-cc/cyi-box/backend/internal/config"
"github.com/cyi-cc/cyi-box/backend/internal/dto"
"github.com/cyi-cc/cyi-box/backend/internal/store"
"github.com/cyi-cc/cyi-box/backend/internal/vault"
)
// VaultSvc 密码箱:密文只在服务端出现;列表只回元数据,
// 密码经 Reveal 显式取回,2FA 只回当前验证码不回密钥。
type VaultSvc struct {
fun.Ctx
Store *store.Store
Cfg *config.Config `fun:"auto"`
}
func (s *VaultSvc) owner() (store.SessionUser, error) {
return sessionFrom(s.RequestCtx)
}
func (s *VaultSvc) List() ([]dto.VaultItemView, error) {
su, err := s.owner()
if err != nil {
return nil, err
}
items, err := s.Store.ListVault(su.User.Id, "")
if err != nil {
return nil, fmt.Errorf("vault: list failed: %w", err)
}
views := make([]dto.VaultItemView, 0, len(items))
for _, e := range items {
views = append(views, vaultView(e))
}
return views, nil
}
func (s *VaultSvc) Save(d dto.SaveVaultDto) (dto.VaultItemView, error) {
su, err := s.owner()
if err != nil {
return dto.VaultItemView{}, err
}
title := strings.TrimSpace(d.Title)
if title == "" || len(title) > 64 {
return dto.VaultItemView{}, fun.Error(4001, "标题必填且不超过 64 字符")
}
e := store.VaultEntry{UserID: su.User.Id, Title: title}
if d.Id != nil {
old, err := s.Store.GetVault(su.User.Id, *d.Id)
if errors.Is(err, store.ErrNotFound) {
return dto.VaultItemView{}, fun.Error(4001, "条目不存在")
}
if err != nil {
return dto.VaultItemView{}, err
}
e = old
e.Title = title
}
if d.Username != nil {
e.Username = strings.TrimSpace(*d.Username)
}
if d.Url != nil {
e.Url = strings.TrimSpace(*d.Url)
}
if d.Note != nil {
e.Note = strings.TrimSpace(*d.Note)
}
if d.Password != nil {
enc, err := vault.Encrypt(s.Cfg.VaultKey, *d.Password)
if err != nil {
return dto.VaultItemView{}, err
}
e.PasswordEnc = enc
}
if d.TotpSecret != nil {
secret := strings.TrimSpace(*d.TotpSecret)
if secret == "" {
e.TotpEnc = "" // 空字符串 = 清除 2FA
} else {
parsed, err := vault.ParseSecret(secret)
if err != nil {
return dto.VaultItemView{}, fun.Error(4001, "2FA 密钥无效:支持 base32 或 otpauth:// 链接")
}
enc, err := vault.Encrypt(s.Cfg.VaultKey, parsed)
if err != nil {
return dto.VaultItemView{}, err
}
e.TotpEnc = enc
}
}
if err := s.Store.SaveVault(&e); err != nil {
return dto.VaultItemView{}, fmt.Errorf("vault: save failed: %w", err)
}
return vaultView(e), nil
}
// Reveal 显式取回明文密码(前端点「查看」才调用,列表永不携带)
func (s *VaultSvc) Reveal(d dto.VaultIdDto) (dto.SecretView, error) {
su, err := s.owner()
if err != nil {
return dto.SecretView{}, err
}
e, err := s.Store.GetVault(su.User.Id, d.Id)
if errors.Is(err, store.ErrNotFound) {
return dto.SecretView{}, fun.Error(4001, "条目不存在")
}
if err != nil {
return dto.SecretView{}, err
}
if e.PasswordEnc == "" {
return dto.SecretView{}, fun.Error(4001, "该条目没有密码")
}
pt, err := vault.Decrypt(s.Cfg.VaultKey, e.PasswordEnc)
if err != nil {
return dto.SecretView{}, fmt.Errorf("vault: decrypt failed: %w", err)
}
return dto.SecretView{Value: pt}, nil
}
// Totp 返回当前 6 位验证码与剩余秒数;密钥不出服务端
func (s *VaultSvc) Totp(d dto.VaultIdDto) (dto.TotpView, error) {
su, err := s.owner()
if err != nil {
return dto.TotpView{}, err
}
e, err := s.Store.GetVault(su.User.Id, d.Id)
if errors.Is(err, store.ErrNotFound) {
return dto.TotpView{}, fun.Error(4001, "条目不存在")
}
if err != nil {
return dto.TotpView{}, err
}
if e.TotpEnc == "" {
return dto.TotpView{}, fun.Error(4001, "该条目未设置 2FA")
}
secret, err := vault.Decrypt(s.Cfg.VaultKey, e.TotpEnc)
if err != nil {
return dto.TotpView{}, fmt.Errorf("vault: decrypt totp failed: %w", err)
}
code, left, err := vault.TOTP(secret, time.Now())
if err != nil {
return dto.TotpView{}, fun.Error(4001, "2FA 密钥无效")
}
return dto.TotpView{Code: code, SecondsLeft: left}, nil
}
func (s *VaultSvc) Delete(d dto.VaultIdDto) (dto.OkView, error) {
su, err := s.owner()
if err != nil {
return dto.OkView{}, err
}
if err := s.Store.DeleteVault(su.User.Id, d.Id); err != nil {
if errors.Is(err, store.ErrNotFound) {
return dto.OkView{}, fun.Error(4001, "条目不存在")
}
return dto.OkView{}, fmt.Errorf("vault: delete failed: %w", err)
}
return dto.OkView{Message: "已删除"}, nil
}
func vaultView(e store.VaultEntry) dto.VaultItemView {
v := dto.VaultItemView{
Id: e.Id,
Title: e.Title,
HasPassword: e.PasswordEnc != "",
HasTotp: e.TotpEnc != "",
UpdatedAt: e.UpdatedAt,
}
if e.Username != "" {
v.Username = &e.Username
}
if e.Url != "" {
v.Url = &e.Url
}
if e.Note != "" {
v.Note = &e.Note
}
return v
}