Files
orbit/internal/storage/mysql/credentials.go

521 lines
21 KiB
Go
Raw Permalink 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 mysql
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
"unicode/utf8"
"git.yuntuo.hk/k2safe/orbit/internal/credentialvault"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
const (
credentialOperationVersion = 2
credentialRotateAction = "ROTATE"
credentialRevokeAction = "REVOKE"
credentialExpiryAction = "SET_EXPIRY"
credentialFinalizeAction = "FINALIZE"
credentialOperationName = "credential.change.v1"
)
// CredentialStatus 是管理读取的脱敏凭据状态;永远不包含秘密或摘要。
// Enabled 字段分别表示所属命名空间和应用当前是否允许业务认证。
type CredentialStatus struct {
// NamespaceID 凭据所属命名空间标识。
NamespaceID core.ID `json:"namespace_id"`
// CredentialID 凭据记录标识,轮换时保持不变。
CredentialID core.ID `json:"credential_id"`
// Name 凭据的管理名称。
Name string `json:"name"`
// Revision 凭据的乐观并发版本,所有变更都必须核对它。
Revision uint64 `json:"revision"`
// PreviousValidUntil 上一代令牌可用的 UTC 截止;空表示无旧令牌。
PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"`
// ExpiresAt 当前秘密的到期时间;空值表示未设置到期时间。
ExpiresAt *time.Time `json:"expires_at,omitempty"`
// RevokedAt 撤销时间;空值表示尚未撤销。
RevokedAt *time.Time `json:"revoked_at,omitempty"`
// NamespaceEnabled 命名空间是否启用。
NamespaceEnabled bool `json:"namespace_enabled"`
// ApplicationEnabled 所属应用是否启用。
ApplicationEnabled bool `json:"application_enabled"`
}
// CredentialChangeInput 描述一次待固定的管理变更。
// ExpectedRevision 防止准备和提交之间的并发变更被覆盖。
type CredentialChangeInput struct {
// NamespaceID 凭据所属命名空间。
NamespaceID core.ID
// CredentialID 要变更的凭据。
CredentialID core.ID
// ExpectedRevision 准备操作时观察到的版本。
ExpectedRevision uint64
// Action 为 ROTATE、REVOKE、SET_EXPIRY 或 FINALIZE。
Action string
// GraceFor 轮换时旧令牌的共存时长;零为立即失效,最多 30 天。
GraceFor time.Duration
// ValidFor 新令牌或原令牌的新有效时长;零为长期,撤销/结束过渡时必须为零。
ValidFor time.Duration
// Reason 必填审计原因。
Reason string
}
// CredentialOperation 是可安全重放的私有操作文件内容。
// ROTATE 文件包含新令牌;服务端保存摘要、可选密文和不含秘密的操作回执。
type CredentialOperation struct {
// Version 操作文件格式版本,升级时不得静默改变语义。
Version int `json:"version"`
// OperationID 稳定操作身份,同时作为审计和幂等记录的身份。
OperationID core.ID `json:"operation_id"`
// NamespaceID 凭据所属命名空间。
NamespaceID core.ID `json:"namespace_id"`
// CredentialID 变更目标凭据。
CredentialID core.ID `json:"credential_id"`
// ExpectedRevision 只允许在该版本上首次应用。
ExpectedRevision uint64 `json:"expected_revision"`
// Action 固定变更类型;格式 v1 仅支持立即 ROTATE 和 REVOKE。
Action string `json:"action"`
// Reason 写入管理审计,不能包含控制字符或秘密。
Reason string `json:"reason"`
// Token 轮换产生的一次性新令牌;撤销操作为空。
Token string `json:"token,omitempty"`
// PreviousValidUntil ROTATE 请求的固定过渡截止;实际不超过旧令牌原到期时间。
PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"`
// ExpiresAt 轮换/调整有效期后的固定绝对时间;v2 为空表示长期,重放不能重新计算。
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// CredentialChangeReceipt 是服务端保存并可重复返回的脱敏结果。
// Deduplicated 只表示本次调用命中了已有回执,不表示当前凭据仍处于该状态。
type CredentialChangeReceipt struct {
// OperationID 稳定操作身份。
OperationID core.ID `json:"operation_id"`
// NamespaceID 操作所属命名空间。
NamespaceID core.ID `json:"namespace_id"`
// CredentialID 变更目标凭据。
CredentialID core.ID `json:"credential_id"`
// Action 已应用的变更类型。
Action string `json:"action"`
// Revision 该操作提交后凭据版本。
Revision uint64 `json:"revision"`
// AppliedAt 操作提交事务使用的数据库时间。
AppliedAt time.Time `json:"applied_at"`
// PreviousValidUntil 操作后的旧令牌截止;重报返回首次提交时的值。
PreviousValidUntil *time.Time `json:"previous_valid_until,omitempty"`
// ExpiresAt 当前令牌的固定到期时间,空为长期。
ExpiresAt *time.Time `json:"expires_at,omitempty"`
// RevokedAt 撤销提交时间。
RevokedAt *time.Time `json:"revoked_at,omitempty"`
// Deduplicated 当前调用是否读取了既有回执。
Deduplicated bool `json:"deduplicated"`
}
// GetCredentialStatus 读取脱敏状态,不需要使用该凭据认证。
func GetCredentialStatus(ctx context.Context, db *sql.DB, namespace, credential core.ID) (CredentialStatus, error) {
var out CredentialStatus
if err := validateCredentialIDs(namespace, credential); err != nil {
return out, err
}
if err := CheckSchema(ctx, db); err != nil {
return out, err
}
var expires, revoked, previousUntil sql.NullTime
err := db.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,c.previous_valid_until,n.enabled,a.enabled
FROM orbit_credentials c
JOIN orbit_namespaces n ON n.id=c.namespace_id
JOIN orbit_applications a ON a.id=n.application_id
WHERE c.namespace_id=? AND c.id=?`, namespace, credential).
Scan(&out.Name, &out.Revision, &expires, &revoked, &previousUntil, &out.NamespaceEnabled, &out.ApplicationEnabled)
if err == sql.ErrNoRows {
return out, fault.NotFound
}
if err != nil {
return out, err
}
out.NamespaceID, out.CredentialID = namespace, credential
out.ExpiresAt = nullableTime(expires)
out.PreviousValidUntil = nullableTime(previousUntil)
out.RevokedAt = nullableTime(revoked)
return out, nil
}
// PrepareCredentialOperation 只读取当前状态并生成一次秘密,不修改数据库。
// 调用方应先把返回值写入受保护的新文件,再调用 ApplyCredentialOperation。
func PrepareCredentialOperation(ctx context.Context, db *sql.DB, in CredentialChangeInput) (CredentialOperation, error) {
var out CredentialOperation
if err := validateCredentialChangeInput(in); err != nil {
return out, err
}
if err := CheckSchema(ctx, db); err != nil {
return out, err
}
status, err := GetCredentialStatus(ctx, db, in.NamespaceID, in.CredentialID)
if err != nil {
return out, err
}
if status.Revision != in.ExpectedRevision || status.RevokedAt != nil {
return out, fault.Conflict
}
var now time.Time
if err := db.QueryRowContext(ctx, "SELECT UTC_TIMESTAMP(6)").Scan(&now); err != nil {
return out, err
}
now = now.UTC()
if in.Action == credentialRotateAction {
secret, _, err := auth.Issue(in.NamespaceID, in.CredentialID)
if err != nil {
return out, err
}
out.Token = secret
if in.GraceFor > 0 {
out.PreviousValidUntil = timePtr(now.Add(in.GraceFor))
}
}
if in.ValidFor > 0 {
out.ExpiresAt = timePtr(now.Add(in.ValidFor))
}
out.Version = credentialOperationVersion
out.NamespaceID = in.NamespaceID
out.CredentialID = in.CredentialID
out.ExpectedRevision = in.ExpectedRevision
out.Action = in.Action
out.Reason = in.Reason
out.OperationID, err = core.NewID()
if err != nil {
return out, err
}
return out, ValidateCredentialOperation(out)
}
// ValidateCredentialOperation validates the exact private operation shape.
// It is exported so the admin CLI can reject tampered files before opening a DB.
func ValidateCredentialOperation(op CredentialOperation) error {
if (op.Version != 1 && op.Version != credentialOperationVersion) || op.OperationID.Validate() != nil || op.NamespaceID.Validate() != nil || op.CredentialID.Validate() != nil {
return fault.Invalid("credential operation identity or version is invalid")
}
if op.ExpectedRevision == 0 || !validCredentialAction(op.Action) {
return fault.Invalid("credential operation action or revision is invalid")
}
if err := validCredentialReason(op.Reason); err != nil {
return err
}
if op.Version == 1 && ((op.Action != credentialRotateAction && op.Action != credentialRevokeAction) || op.PreviousValidUntil != nil || (op.Action == credentialRotateAction && op.ExpiresAt == nil)) {
return fault.Invalid("legacy operation requires its original expiry and immediate rotation")
}
if op.ExpiresAt != nil && op.ExpiresAt.IsZero() || op.PreviousValidUntil != nil && op.PreviousValidUntil.IsZero() {
return fault.Invalid("fixed times must not be zero")
}
if op.Action == credentialRotateAction {
parsed, err := auth.Parse(op.Token)
if err != nil || parsed.NamespaceID != op.NamespaceID || parsed.CredentialID != op.CredentialID {
return fault.Invalid("rotation token does not match credential identity")
}
} else if op.Token != "" || op.PreviousValidUntil != nil || (op.Action != credentialExpiryAction && op.ExpiresAt != nil) {
return fault.Invalid("invalid fields for credential change")
}
return nil
}
// ApplyCredentialOperation applies one frozen operation or returns its original
// receipt. No callback or file I/O occurs inside the transaction. A commit error
// is intentionally returned unchanged; replaying the same operation reconciles it.
func ApplyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOperation, vault ...*credentialvault.Keyring) (CredentialChangeReceipt, error) {
return applyCredentialOperation(ctx, db, op, nil, vault...)
}
// applyCredentialOperation 允许控制台在同一事务先核验管理员,并使用真实人类审计主体。
func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOperation, authorize func(*sql.Tx) (string, error), vault ...*credentialvault.Keyring) (CredentialChangeReceipt, error) {
var ring *credentialvault.Keyring
if len(vault) > 0 {
ring = vault[0]
}
var out CredentialChangeReceipt
if err := ValidateCredentialOperation(op); err != nil {
return out, err
}
if err := CheckSchema(ctx, db); err != nil {
return out, err
}
err := WithTx(ctx, db, func(tx *sql.Tx) error {
actor := ""
if authorize != nil {
var err error
actor, err = authorize(tx)
if err != nil {
return err
}
}
var name string
var revision uint64
var expires, revoked, previousUntil sql.NullTime
var namespaceEnabled, applicationEnabled bool
err := tx.QueryRowContext(ctx, `SELECT c.name,c.revision,c.expires_at,c.revoked_at,c.previous_valid_until,n.enabled,a.enabled
FROM orbit_credentials c
JOIN orbit_namespaces n ON n.id=c.namespace_id
JOIN orbit_applications a ON a.id=n.application_id
WHERE c.namespace_id=? AND c.id=? FOR UPDATE`, op.NamespaceID, op.CredentialID).
Scan(&name, &revision, &expires, &revoked, &previousUntil, &namespaceEnabled, &applicationEnabled)
if err == sql.ErrNoRows {
return fault.NotFound
}
if err != nil {
return err
}
identity, cached, err := reserveCredentialOperation(ctx, tx, op)
if err != nil {
return err
}
if cached != nil {
if err := json.Unmarshal(cached, &out); err != nil {
return fmt.Errorf("stored credential receipt is invalid: %w", err)
}
out.Deduplicated = true
return nil
}
if revision != op.ExpectedRevision || revoked.Valid {
return fault.Conflict
}
if revision == ^uint64(0) {
return fault.Conflict
}
now, err := dbNow(ctx, tx)
if err != nil {
return err
}
before := map[string]any{"name": name, "revision": revision, "expires_at": nullableTime(expires), "revoked_at": nullableTime(revoked), "previous_valid_until": nullableTime(previousUntil), "namespace_enabled": namespaceEnabled, "application_enabled": applicationEnabled}
after := map[string]any{"name": name, "revision": revision + 1, "namespace_enabled": namespaceEnabled, "application_enabled": applicationEnabled}
if op.ExpiresAt != nil && (!op.ExpiresAt.After(now) || op.ExpiresAt.After(now.Add(365*24*time.Hour))) {
return fault.Conflict
}
out.ExpiresAt = nullableTime(expires)
out.PreviousValidUntil = nullableTime(previousUntil)
switch op.Action {
case credentialRotateAction:
parsed, _ := auth.Parse(op.Token)
// 仍在过渡期时不覆盖上一代,避免把尚未切换的服务突然踢下线。
if previousUntil.Valid && previousUntil.Time.After(now) {
return fault.Conflict
}
var previousDigest any
out.PreviousValidUntil = nil
if op.PreviousValidUntil != nil {
if !op.PreviousValidUntil.After(now) || op.PreviousValidUntil.After(now.Add(30*24*time.Hour)) {
return fault.Conflict
}
until := *op.PreviousValidUntil
if expires.Valid && expires.Time.Before(until) {
until = expires.Time
}
if until.After(now) {
var old core.Digest
if err := tx.QueryRowContext(ctx, `SELECT secret_digest FROM orbit_credentials WHERE namespace_id=? AND id=?`, op.NamespaceID, op.CredentialID).Scan(&old); err != nil {
return err
}
if subtleCompareDigest(old, parsed.Digest) == 1 {
return fault.Invalid("rotation must use a new secret")
}
previousDigest = old
out.PreviousValidUntil = timePtr(until)
}
}
keyID, ciphertext, err := ring.Seal(op.NamespaceID, op.CredentialID, op.Token)
if err != nil {
return err
}
// 未配置保管密钥的旧 CLI 轮换也必须清除旧密文,不能回看过时代次。
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials
SET secret_digest=?,expires_at=?,previous_secret_digest=?,previous_valid_until=?,secret_key_id=?,secret_ciphertext=?,revision=revision+1,updated_at=?
WHERE namespace_id=? AND id=? AND revision=?`, parsed.Digest, op.ExpiresAt, previousDigest, out.PreviousValidUntil, keyID, ciphertext, now, op.NamespaceID, op.CredentialID, revision); err != nil {
return err
}
out.ExpiresAt = op.ExpiresAt
case credentialExpiryAction:
// 只变更当前令牌有效期;不能延长上一代截止或改变任何秘密。
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET expires_at=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, op.ExpiresAt, now, op.NamespaceID, op.CredentialID, revision); err != nil {
return err
}
out.ExpiresAt = op.ExpiresAt
case credentialFinalizeAction:
if !previousUntil.Valid {
return fault.Conflict
}
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET previous_secret_digest=NULL,previous_valid_until=NULL,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, now, op.NamespaceID, op.CredentialID, revision); err != nil {
return err
}
out.PreviousValidUntil = nil
case credentialRevokeAction:
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET revoked_at=?,previous_secret_digest=NULL,previous_valid_until=NULL,secret_key_id=NULL,secret_ciphertext=NULL,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, now, now, op.NamespaceID, op.CredentialID, revision); err != nil {
return err
}
out.RevokedAt = timePtr(now)
out.PreviousValidUntil = nil
}
after["expires_at"] = out.ExpiresAt
after["previous_valid_until"] = out.PreviousValidUntil
after["revoked_at"] = out.RevokedAt
out.OperationID = op.OperationID
out.NamespaceID = op.NamespaceID
out.CredentialID = op.CredentialID
out.Action = op.Action
out.Revision = revision + 1
out.AppliedAt = now
if actor == "" {
actor, err = databaseActor(ctx, tx)
if err != nil {
return err
}
}
if err := appendCredentialAudit(ctx, tx, actor, op, before, after); err != nil {
return err
}
encoded, err := json.Marshal(out)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `UPDATE orbit_idempotency_keys SET response=?,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=?`, encoded, now, op.NamespaceID, identity); err != nil {
return err
}
return nil
})
return out, err
}
// reserveCredentialOperation stores the frozen request digest before mutation.
// The operation ID is also the idempotency row ID, so retries never mint a new
// secret or a second audit event.
func reserveCredentialOperation(ctx context.Context, tx *sql.Tx, op CredentialOperation) (core.ID, []byte, error) {
digest, err := requestDigest(op)
if err != nil {
return "", nil, err
}
keyDigest := core.Digest(sha256.Sum256([]byte(op.OperationID)))
_, err = tx.ExecContext(ctx, `INSERT INTO orbit_idempotency_keys(namespace_id,id,operation,scope_key,key_digest,request_digest,resource_kind,resource_id,response,expires_at)
VALUES (?,?,?,?,?,?,?,?,JSON_OBJECT(),DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 90 DAY))
ON DUPLICATE KEY UPDATE id=orbit_idempotency_keys.id`, op.NamespaceID, op.OperationID, credentialOperationName, string(op.CredentialID), keyDigest, digest, "credential", op.CredentialID)
if err != nil {
return "", nil, err
}
var storedID core.ID
var storedDigest core.Digest
var response []byte
if err := tx.QueryRowContext(ctx, `SELECT id,request_digest,response FROM orbit_idempotency_keys
WHERE namespace_id=? AND operation=? AND scope_key=? AND key_digest=? FOR UPDATE`, op.NamespaceID, credentialOperationName, string(op.CredentialID), keyDigest).
Scan(&storedID, &storedDigest, &response); err != nil {
return "", nil, err
}
if subtleCompareDigest(digest, storedDigest) != 1 {
return "", nil, fault.IdempotencyConflict
}
trimmed := strings.TrimSpace(string(response))
if trimmed != "" && trimmed != "{}" && trimmed != "null" {
return storedID, response, nil
}
return storedID, nil, nil
}
func appendCredentialAudit(ctx context.Context, tx *sql.Tx, actor string, op CredentialOperation, before, after any) error {
return appendAuditActor(ctx, tx, op.NamespaceID, actor, op.OperationID, strings.ToLower("credential."+strings.ToLower(op.Action)), op.CredentialID, op.Reason, before, after)
}
func appendAuditActor(ctx context.Context, tx *sql.Tx, namespace core.ID, actor string, operation core.ID, action string, resource core.ID, reason string, before, after any) error {
old, err := json.Marshal(before)
if err != nil {
return err
}
current, err := json.Marshal(after)
if err != nil {
return err
}
id, err := core.NewID()
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `INSERT INTO orbit_audit_events(namespace_id,id,operation_id,actor_id,action,resource_kind,resource_id,reason,evidence_ref,`+"`before`"+`,`+"`after`"+`)
VALUES (?,?,?,?,?,?,?,?,NULL,?,?)`, namespace, id, operation, actor, action, "credential", resource, reason, string(old), string(current))
return err
}
func databaseActor(ctx context.Context, tx *sql.Tx) (string, error) {
var principal string
if err := tx.QueryRowContext(ctx, "SELECT CURRENT_USER()").Scan(&principal); err != nil {
return "", err
}
principal = "mysql:" + principal
if len(principal) <= 128 {
return principal, nil
}
sum := sha256.Sum256([]byte(principal))
return fmt.Sprintf("mysql-sha256:%x", sum[:]), nil
}
func subtleCompareDigest(a, b core.Digest) int {
var result byte
for i := range a {
result |= a[i] ^ b[i]
}
if result == 0 {
return 1
}
return 0
}
func validateCredentialIDs(namespace, credential core.ID) error {
if namespace.Validate() != nil || credential.Validate() != nil {
return fault.Invalid("credential identity is invalid")
}
return nil
}
func validateCredentialChangeInput(in CredentialChangeInput) error {
if err := validateCredentialIDs(in.NamespaceID, in.CredentialID); err != nil {
return err
}
if in.ExpectedRevision == 0 || !validCredentialAction(in.Action) {
return fault.Invalid("credential action and expected revision are required")
}
if err := validCredentialReason(in.Reason); err != nil {
return err
}
if in.ValidFor != 0 && (in.ValidFor < time.Hour || in.ValidFor > 365*24*time.Hour) {
return fault.Invalid("validity must be zero (permanent) or between one hour and one year")
}
if (in.Action == credentialRevokeAction || in.Action == credentialFinalizeAction) && in.ValidFor != 0 {
return fault.Invalid("this operation cannot set validity")
}
if in.GraceFor < 0 || in.GraceFor > 30*24*time.Hour || (in.Action != credentialRotateAction && in.GraceFor != 0) {
return fault.Invalid("rotation grace must be between zero and 30 days")
}
return nil
}
func validCredentialReason(reason string) error {
if strings.TrimSpace(reason) == "" || !utf8.ValidString(reason) || len([]rune(reason)) > 1024 || strings.ContainsAny(reason, "\x00\r\n") {
return fault.Invalid("credential reason is required and must not contain control newlines")
}
return nil
}
func nullableTime(v sql.NullTime) *time.Time {
if !v.Valid {
return nil
}
t := v.Time.UTC()
return &t
}
func timePtr(v time.Time) *time.Time {
t := v.UTC()
return &t
}
func validCredentialAction(action string) bool {
return action == credentialRotateAction || action == credentialRevokeAction || action == credentialExpiryAction || action == credentialFinalizeAction
}