Files
usernode/internal/service/key.go
T
cao.wangrenbo a5f501dba4 feat(M2): SSH 密钥管理 — 公钥上传/重命名/吊销、authorized_keys 原子同步与吊销即时失效
- KeyService:crypto/ssh 解析校验(单行/类型/长度/去重指纹,拒 ssh-dss 与 RSA<2048),
  Create/Rename/Revoke/List,变更后以 DB 状态全量重写 authorized_keys(同步失败回滚)
- system 层:SyncAuthorizedKeys 完善 —— sudo 模式经白名单命令(mkdir/chown/chmod/install)
  落位并修正属主(sshd StrictModes),direct 模式 root 时同样修正属主;dry-run 计划日志
- API:GET/POST /me/keys、PATCH/DELETE /me/keys/:id(user 会话)、GET /users/:id/keys(admin),
  密钥操作带审计;deploy/sudoers.example 补充密钥同步白名单
- 版本 0.3.0-m2;测试:service 单元(校验/生命周期/回滚/权限)、system 直写落盘、
  API 全流程集成;容器 E2E 32 项 PASS(真实 useradd/authorized_keys/吊销即时失效/禁用清空/删除回收)
2026-08-29 23:55:39 +08:00

220 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_keyssystem 层原子写 + 并发锁),
// 吊销密钥即从文件移除、立即失效(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
}