package service import ( "context" "crypto/rsa" "encoding/base64" "errors" "fmt" "strings" "time" "golang.org/x/crypto/ssh" "gorm.io/gorm" "ws_usernode/internal/config" "ws_usernode/internal/model" "ws_usernode/internal/system" ) // 密钥服务错误。 var ( ErrKeyNotFound = errors.New("service: 密钥不存在") ErrKeyDuplicate = errors.New("service: 该公钥已存在") ErrKeyInvalid = errors.New("service: 公钥格式不合法") ErrUserNotActive = errors.New("service: 用户未处于可用状态,无法添加密钥") ) // maxPublicKeyLen 公钥输入上限(正常公钥约 100~700 字节,防止异常大输入)。 const maxPublicKeyLen = 8192 // KeyService SSH 公钥管理。密钥仅用户自行上传(管理员不代签,PLAN §2.3); // 每次变更后以 DB 状态全量重写 authorized_keys(system 层原子写 + 并发锁), // 吊销密钥即从文件移除、立即失效(PLAN F3)。 type KeyService struct { db *gorm.DB sys system.Manager cfg *config.Config } // NewKeyService 创建密钥服务。 func NewKeyService(db *gorm.DB, sys system.Manager, cfg *config.Config) *KeyService { return &KeyService{db: db, sys: sys, cfg: cfg} } // user 查询外部用户记录。 func (s *KeyService) user(ctx context.Context, id uint) (*model.User, error) { var u model.User if err := s.db.WithContext(ctx).First(&u, "id = ?", id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrUserNotFound } return nil, err } return &u, nil } // ListByUser 返回用户全部密钥(最新在前)。用户不存在时报 ErrUserNotFound。 func (s *KeyService) ListByUser(ctx context.Context, userID uint) ([]model.SSHKey, error) { if _, err := s.user(ctx, userID); err != nil { return nil, err } var keys []model.SSHKey if err := s.db.WithContext(ctx).Where("user_id = ?", userID).Order("id DESC").Find(&keys).Error; err != nil { return nil, err } return keys, nil } // Create 上传公钥:校验(类型/长度/重复)→ 落 DB → 全量同步 authorized_keys。 // 同步失败时回滚 DB 记录,保证两侧一致(同 user.Create 模式)。 func (s *KeyService) Create(ctx context.Context, userID uint, name, publicKey string, createdBy uint) (*model.SSHKey, error) { name = strings.TrimSpace(name) if name == "" || len(name) > 64 { return nil, fmt.Errorf("%w: 密钥名称需为 1~64 字符", ErrKeyInvalid) } keyType, fingerprint, body, err := parsePublicKey(publicKey) if err != nil { return nil, err } u, err := s.user(ctx, userID) if err != nil { return nil, err } if u.Status != model.UserStatusActive { return nil, ErrUserNotActive } // 同用户下已存在该公钥(active)→ 拒绝重复;已吊销的密钥允许重新上传 var n int64 if err := s.db.WithContext(ctx).Model(&model.SSHKey{}). Where("user_id = ? AND fingerprint = ? AND status = ?", userID, fingerprint, model.StatusActive). Count(&n).Error; err != nil { return nil, err } if n > 0 { return nil, ErrKeyDuplicate } k := &model.SSHKey{ UserID: userID, Name: name, KeyType: keyType, PublicKey: body, Fingerprint: fingerprint, Status: model.StatusActive, Source: "user_uploaded", CreatedBy: createdBy, } if err := s.db.WithContext(ctx).Create(k).Error; err != nil { return nil, err } if err := s.syncUserKeys(ctx, u); err != nil { _ = s.db.WithContext(ctx).Delete(k).Error return nil, err } return k, nil } // Rename 重命名密钥(仅元数据,不影响 authorized_keys)。 func (s *KeyService) Rename(ctx context.Context, keyID, userID uint, name string) (*model.SSHKey, error) { name = strings.TrimSpace(name) if name == "" || len(name) > 64 { return nil, fmt.Errorf("%w: 密钥名称需为 1~64 字符", ErrKeyInvalid) } k, err := s.owned(ctx, keyID, userID) if err != nil { return nil, err } if err := s.db.WithContext(ctx).Model(&model.SSHKey{}).Where("id = ?", keyID).Update("name", name).Error; err != nil { return nil, err } k.Name = name return k, nil } // Revoke 吊销密钥(软删除,保留记录供审计)。先以剩余有效密钥(排除本次 // 吊销的密钥)重写 authorized_keys(吊销立即失效),成功后再落 DB;同步失败 // 则中止,文件与 DB 保持一致(密钥仍为 active)。已吊销时幂等。 func (s *KeyService) Revoke(ctx context.Context, keyID, userID uint) (*model.SSHKey, error) { k, err := s.owned(ctx, keyID, userID) if err != nil { return nil, err } if k.Status == model.StatusRevoked { return k, nil // 幂等 } u, err := s.user(ctx, userID) if err != nil { return nil, err } // 剩余有效密钥(不含本次吊销的),先同步文件再落 DB(同 Disable 的 fail-closed 模式) keys, err := activeUserKeys(s.db, ctx, u.ID, k.ID) if err != nil { return nil, err } if err := s.sys.SyncAuthorizedKeys(ctx, u.Username, keys); err != nil { return nil, err } now := time.Now() if err := s.db.WithContext(ctx).Model(&model.SSHKey{}).Where("id = ?", keyID). Updates(map[string]any{"status": model.StatusRevoked, "revoked_at": &now}).Error; err != nil { return nil, err } k.Status = model.StatusRevoked k.RevokedAt = &now return k, nil } // owned 返回属于 userID 的密钥;跨用户访问视为不存在,不泄露存在性。 func (s *KeyService) owned(ctx context.Context, keyID, userID uint) (*model.SSHKey, error) { var k model.SSHKey if err := s.db.WithContext(ctx).First(&k, "id = ? AND user_id = ?", keyID, userID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrKeyNotFound } return nil, err } return &k, nil } // syncUserKeys 以 DB 当前 active 密钥全量重写 authorized_keys。 func (s *KeyService) syncUserKeys(ctx context.Context, u *model.User) error { keys, err := activeUserKeys(s.db, ctx, u.ID, 0) if err != nil { return err } return s.sys.SyncAuthorizedKeys(ctx, u.Username, keys) } // parsePublicKey 校验并解析 OpenSSH 公钥行,返回类型 / SHA256 指纹 / base64 主体。 // 仅接受标准单行 "类型 base64 [注释]";拒绝 ssh-dss(弱算法)与 <2048 位 RSA。 func parsePublicKey(input string) (keyType, fingerprint, body string, err error) { if len(input) > maxPublicKeyLen { return "", "", "", fmt.Errorf("%w: 公钥内容过长", ErrKeyInvalid) } line := strings.TrimSpace(input) if line == "" || strings.ContainsAny(line, "\r\n") { return "", "", "", fmt.Errorf("%w: 公钥必须为单行", ErrKeyInvalid) } pub, _, options, rest, perr := ssh.ParseAuthorizedKey([]byte(line)) if perr != nil { return "", "", "", fmt.Errorf("%w: %v", ErrKeyInvalid, perr) } if len(options) > 0 || len(rest) > 0 { return "", "", "", fmt.Errorf("%w: 仅支持标准公钥行,不能带选项或多余内容", ErrKeyInvalid) } keyType = pub.Type() switch keyType { case "ssh-dss": return "", "", "", fmt.Errorf("%w: 不支持 ssh-dss 密钥", ErrKeyInvalid) case "ssh-rsa": if cp, ok := pub.(ssh.CryptoPublicKey); ok { if rsaPub, ok := cp.CryptoPublicKey().(*rsa.PublicKey); ok && rsaPub.N.BitLen() < 2048 { return "", "", "", fmt.Errorf("%w: RSA 密钥长度至少 2048 位", ErrKeyInvalid) } } } fingerprint = ssh.FingerprintSHA256(pub) body = base64.StdEncoding.EncodeToString(pub.Marshal()) return keyType, fingerprint, body, nil }