feat: let administrators view and copy encrypted integration credentials

This commit is contained in:
2026-09-27 13:48:26 +08:00
parent 5555a7b342
commit d54bdd9847
38 changed files with 905 additions and 71 deletions

View File

@@ -96,6 +96,7 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) {
// 已经存在的 v29/v30 表误当成迁移重复执行。
f.exec(`DROP TABLE orbit_archive_jobs,orbit_archive_plans,orbit_retention_policies,orbit_console_sessions,orbit_console_users`)
f.exec(`ALTER TABLE orbit_credentials DROP CHECK ck_credential_overlap, DROP COLUMN previous_secret_digest, DROP COLUMN previous_valid_until`)
f.exec(`ALTER TABLE orbit_credentials DROP CHECK ck_credential_ciphertext, DROP COLUMN secret_key_id, DROP COLUMN secret_ciphertext`)
f.exec(`DELETE FROM orbit_schema_migrations WHERE version>=10`)
if err := Migrate(ctx, db); err != nil {
t.Fatal(err)

View File

@@ -1,4 +1,4 @@
-- Orbit fresh-install baseline, generated from migration version 32.
-- Orbit fresh-install baseline, generated from migration version 33.
-- This file intentionally contains no database-level relation constraints.
CREATE TABLE `orbit_application_resources` (
`application_id` binary(16) NOT NULL COMMENT '所属应用标识及主键,跨命名空间共享额度',
@@ -59,6 +59,8 @@ CREATE TABLE `orbit_credentials` (
`updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后写入时间,统一 UTC;不能代替并发版本',
`name` varchar(128) COLLATE utf8mb4_0900_as_cs NOT NULL COMMENT '可读名称,唯一性范围由对应数据表约束',
`secret_digest` binary(32) NOT NULL COMMENT '凭据秘密的 SHA-256 摘要,不保存或输出原文',
`secret_key_id` varchar(64) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL COMMENT '当前 Token 密文使用的主密钥标识;空表示尚未保管,主密钥不存数据库',
`secret_ciphertext` varbinary(1024) DEFAULT NULL COMMENT '当前 Token 的 AES-256-GCM nonce 与认证密文;空表示旧凭据仅存摘要',
`previous_secret_digest` binary(32) DEFAULT NULL COMMENT '上一代秘密的 SHA-256 摘要;为空表示无过渡令牌,不保存原文',
`previous_valid_until` datetime(6) DEFAULT NULL COMMENT '上一代令牌过渡截止,UTC;不晚于其原到期时间,与旧摘要同时为空或非空',
`permissions` json NOT NULL COMMENT '授权范围的 JSON 快照,必须由服务端解释和校验',
@@ -67,8 +69,9 @@ CREATE TABLE `orbit_credentials` (
PRIMARY KEY (`namespace_id`,`id`),
UNIQUE KEY `uq_credential_name` (`namespace_id`,`name`),
CONSTRAINT `ck_orbit_credentials_0` CHECK ((`revision` > 0)),
CONSTRAINT `ck_credential_ciphertext` CHECK ((secret_key_id IS NULL AND secret_ciphertext IS NULL) OR (secret_key_id IS NOT NULL AND secret_ciphertext IS NOT NULL AND LENGTH(secret_key_id)>0 AND LENGTH(secret_ciphertext)>=28)),
CONSTRAINT `ck_credential_overlap` CHECK ((previous_secret_digest IS NULL AND previous_valid_until IS NULL) OR (previous_secret_digest IS NOT NULL AND previous_valid_until IS NOT NULL))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='命名空间凭据,只保存秘密摘要和授权范围';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='命名空间凭据、认证摘要、授权范围及可选的当前 Token 密文';
CREATE TABLE `orbit_deliveries` (
`namespace_id` binary(16) NOT NULL COMMENT '资源所属命名空间,参与主键、授权与关联校验',
`id` binary(16) NOT NULL COMMENT '记录标识;与命名空间一起定位运行资源',

View File

@@ -8,6 +8,7 @@ import (
"strings"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/credentialvault"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
"git.yuntuo.hk/k2safe/orbit/pulse/service"
@@ -49,7 +50,11 @@ type BootstrapResult struct {
ConsoleUsername string `json:"console_username,omitempty"` // ConsoleUsername 可选的首个控制台账号,不包含密码。
}
func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(BootstrapResult) error) error {
func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(BootstrapResult) error, vault ...*credentialvault.Keyring) error {
var ring *credentialvault.Keyring
if len(vault) > 0 {
ring = vault[0]
}
if in.ValidFor != 0 && (in.ValidFor < time.Hour || in.ValidFor > 365*24*time.Hour) {
return fmt.Errorf("validity must be zero or between one hour and one year")
}
@@ -96,6 +101,10 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
return err
}
out.Token = secret
keyID, ciphertext, err := ring.Seal(out.NamespaceID, out.CredentialID, secret)
if err != nil {
return err
}
permissions := auth.Permissions{Actions: []string{"tasks:submit", "tasks:read", "tasks:cancel", "queues:read", "queues:manage"}, Queues: []string{"*"}}
if in.WorkerAccess {
permissions.Actions = append(permissions.Actions, "workers:connect", "workers:consume")
@@ -180,7 +189,7 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
if _, err = tx.ExecContext(ctx, "INSERT INTO pulse_queues(namespace_id,id,name,max_concurrency,rate_per_second,policy) VALUES (?,?,?,4,10,'{}')", out.NamespaceID, out.QueueID, in.Queue); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at) VALUES (?,?,'bootstrap',?,?,?)", out.NamespaceID, out.CredentialID, digest, string(raw), out.ExpiresAt); err != nil {
if _, err = tx.ExecContext(ctx, "INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at,secret_key_id,secret_ciphertext) VALUES (?,?,'bootstrap',?,?,?,?,?)", out.NamespaceID, out.CredentialID, digest, string(raw), out.ExpiresAt, keyID, ciphertext); err != nil {
return err
}
actor, err := databaseActor(ctx, tx)

View File

@@ -9,6 +9,7 @@ import (
"time"
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
"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"
@@ -21,10 +22,15 @@ import (
type ConsoleApplicationStore struct {
db *sql.DB
username string
vault *credentialvault.Keyring
}
func NewConsoleApplicationStore(db *sql.DB, username string) *ConsoleApplicationStore {
return &ConsoleApplicationStore{db: db, username: username}
func NewConsoleApplicationStore(db *sql.DB, username string, vault ...*credentialvault.Keyring) *ConsoleApplicationStore {
s := &ConsoleApplicationStore{db: db, username: username}
if len(vault) > 0 {
s.vault = vault[0]
}
return s
}
var _ consoleauth.Applications = (*ConsoleApplicationStore)(nil)
@@ -32,7 +38,7 @@ var _ consoleauth.Applications = (*ConsoleApplicationStore)(nil)
// admin 在实际读写事务中锁定人类账号及会话;普通 SDK Token 无对应会话,无法通过。
// 不锁业务凭据,轮换当前账号所绑定的 SDK 凭据也不会产生共享锁升级。
func (s *ConsoleApplicationStore) admin(ctx context.Context, tx *sql.Tx, token auth.Token) (string, error) {
if s.username == "" {
if s.username == "" || token.NamespaceID.Validate() != nil || token.CredentialID.Validate() != nil {
return "", fault.Forbidden
}
var user core.ID
@@ -117,7 +123,8 @@ func (s *ConsoleApplicationStore) GetApplication(ctx context.Context, token auth
if err != nil {
return err
}
rows, err = tx.QueryContext(ctx, `SELECT id,name,revision,expires_at,revoked_at,previous_valid_until,permissions FROM orbit_credentials WHERE namespace_id=? ORDER BY id LIMIT 101`, namespace)
out.TokenVaultEnabled = s.vault != nil
rows, err = tx.QueryContext(ctx, `SELECT id,name,revision,expires_at,revoked_at,previous_valid_until,permissions,secret_ciphertext IS NOT NULL FROM orbit_credentials WHERE namespace_id=? ORDER BY id LIMIT 101`, namespace)
if err != nil {
return err
}
@@ -126,7 +133,7 @@ func (s *ConsoleApplicationStore) GetApplication(ctx context.Context, token auth
var c consoleauth.AccessCredential
var expires, revoked, previous sql.NullTime
var raw []byte
if err := rows.Scan(&c.ID, &c.Name, &c.Revision, &expires, &revoked, &previous, &raw); err != nil {
if err := rows.Scan(&c.ID, &c.Name, &c.Revision, &expires, &revoked, &previous, &raw, &c.TokenStored); err != nil {
return err
}
if err := json.Unmarshal(raw, &c.Permissions); err != nil {
@@ -299,7 +306,7 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au
return "", fault.Invalid("application identity mismatch")
}
return actor, nil
})
}, s.vault)
return consoleauth.ApplicationReceipt{OperationID: op.OperationID, ApplicationID: op.ApplicationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, Revision: receipt.Revision, Deduplicated: receipt.Deduplicated}, err
}
parsed, err := auth.Parse(op.Token)
@@ -352,6 +359,10 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au
if op.ExpiresAt != nil && (!op.ExpiresAt.After(now) || op.ExpiresAt.After(now.Add(365*24*time.Hour))) {
return fault.Invalid("expired or excessive credential lifetime")
}
keyID, ciphertext, err := s.vault.Seal(op.NamespaceID, op.CredentialID, op.Token)
if err != nil {
return err
}
statements := []struct {
query string
args []any
@@ -360,7 +371,7 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au
{`INSERT INTO orbit_application_resources(application_id) VALUES (?)`, []any{op.ApplicationID}},
{`INSERT INTO orbit_namespaces(id,application_id,name,environment) VALUES (?,?,?,?)`, []any{op.NamespaceID, op.ApplicationID, op.Input.Namespace, op.Input.Environment}},
{`INSERT INTO pulse_queues(namespace_id,id,name,max_concurrency,rate_per_second,policy) VALUES (?,?,?,4,10,'{}')`, []any{op.NamespaceID, op.QueueID, op.Input.Queue}},
{`INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at) VALUES (?,?,'console-issued',?,?,?)`, []any{op.NamespaceID, op.CredentialID, parsed.Digest, string(raw), op.ExpiresAt}},
{`INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at,secret_key_id,secret_ciphertext) VALUES (?,?,'console-issued',?,?,?,?,?)`, []any{op.NamespaceID, op.CredentialID, parsed.Digest, string(raw), op.ExpiresAt, keyID, ciphertext}},
}
for _, st := range statements {
if _, err := tx.ExecContext(ctx, st.query, st.args...); err != nil {

View File

@@ -0,0 +1,85 @@
package mysql
import (
"context"
"database/sql"
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
var _ consoleauth.CredentialTokens = (*ConsoleApplicationStore)(nil)
// AccessCredentialToken 认证、版本核对、密文读取/补存和审计在同一事务完成。
// 导入相同当前秘密可重复调用,不变更认证 revision、到期时间或旧代过渡截止。
func (s *ConsoleApplicationStore) AccessCredentialToken(ctx context.Context, session auth.Token, namespace, credential core.ID, in consoleauth.CredentialTokenInput, importCurrent bool) (consoleauth.CredentialToken, error) {
var out consoleauth.CredentialToken
if namespace.Validate() != nil || credential.Validate() != nil || in.ExpectedRevision == 0 || (!importCurrent && in.Token != "") {
return out, fault.Invalid("invalid credential token request")
}
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
actor, err := s.admin(ctx, tx, session)
if err != nil {
return err
}
if s.vault == nil {
return fault.New("UNAVAILABLE", "Token 保管密钥尚未配置,请联系部署管理员")
}
var revision uint64
var revoked sql.NullTime
var digest core.Digest
var keyID sql.NullString
var ciphertext []byte
err = tx.QueryRowContext(ctx, `SELECT revision,revoked_at,secret_digest,secret_key_id,secret_ciphertext FROM orbit_credentials WHERE namespace_id=? AND id=? FOR UPDATE`, namespace, credential).Scan(&revision, &revoked, &digest, &keyID, &ciphertext)
if err == sql.ErrNoRows {
return fault.NotFound
}
if err != nil {
return err
}
if revision != in.ExpectedRevision || revoked.Valid {
return fault.New("STATE_CONFLICT", "凭据已变更或撤销,请刷新后重试")
}
raw := in.Token
action := "console.credential.token.view"
if !importCurrent {
if !keyID.Valid {
return fault.New("STATE_CONFLICT", "此历史凭据尚未保管 Token,请先导入当前 Token")
}
raw, err = s.vault.Open(namespace, credential, keyID.String, ciphertext)
if err != nil {
return fault.New("UNAVAILABLE", "Token 解密失败,请检查保管密钥文件")
}
}
parsed, err := auth.Parse(raw)
if err != nil || parsed.NamespaceID != namespace || parsed.CredentialID != credential || subtleCompareDigest(parsed.Digest, digest) != 1 {
return fault.Invalid("Token 与当前凭据不匹配;不能导入过渡期旧 Token")
}
if importCurrent {
action = "console.credential.token.import"
id, encrypted, err := s.vault.Seal(namespace, credential, raw)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET secret_key_id=?,secret_ciphertext=?,updated_at=UTC_TIMESTAMP(6) WHERE namespace_id=? AND id=?`, id, encrypted, namespace, credential); err != nil {
return err
}
}
operation, err := core.NewID()
if err != nil {
return err
}
if err := appendAuditActor(ctx, tx, namespace, actor, operation, action, credential, "administrator access to current integration configuration", nil, map[string]any{"revision": revision}); err != nil {
return err
}
out = consoleauth.CredentialToken{NamespaceID: namespace, CredentialID: credential, Revision: revision, Token: raw}
return nil
})
// 包括提交结果未知:只有确认审计提交成功,才允许向调用方交付秘密。
if err != nil {
return consoleauth.CredentialToken{}, err
}
return out, nil
}

View File

@@ -0,0 +1,207 @@
package mysql
import (
"bytes"
"encoding/base64"
"encoding/json"
"strings"
"sync"
"testing"
"time"
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
"git.yuntuo.hk/k2safe/orbit/internal/credentialvault"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
)
func TestIntegrationCredentialVault(t *testing.T) {
ctx, db := overlapDB(t)
ring, err := credentialvault.Read(strings.NewReader(`{"active":"test","keys":{"test":"` + base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{42}, 32)) + `"}}`))
if err != nil {
t.Fatal(err)
}
name := "vault-" + string(testID(t))
var initial BootstrapResult
if err := Bootstrap(ctx, db, BootstrapInput{Application: name, Namespace: "default", Environment: "test", Queue: "work", ConsoleUsername: name, ConsolePassword: "vault-test-password-12345"}, func(v BootstrapResult) error { initial = v; return nil }); err != nil {
t.Fatal(err)
}
login, err := NewConsoleAuthStore(db).Login(ctx, name, "vault-test-password-12345", "test", "127.0.0.1")
if err != nil {
t.Fatal(err)
}
admin, _ := auth.Parse(login.Token)
sdk, _ := auth.Parse(initial.Token)
store := NewConsoleApplicationStore(db, name, ring)
in := consoleauth.CredentialTokenInput{ExpectedRevision: 1}
access := func(input consoleauth.CredentialTokenInput, importCurrent bool) (consoleauth.CredentialToken, error) {
return store.AccessCredentialToken(ctx, admin, initial.NamespaceID, initial.CredentialID, input, importCurrent)
}
for _, unauthorized := range []auth.Token{sdk, {}} {
if _, err := store.AccessCredentialToken(ctx, unauthorized, initial.NamespaceID, initial.CredentialID, in, false); fault.Code(err) != "PERMISSION_DENIED" {
t.Fatal("SDK or missing session accepted")
}
}
if _, err := NewConsoleApplicationStore(db, "other", ring).AccessCredentialToken(ctx, admin, initial.NamespaceID, initial.CredentialID, in, false); fault.Code(err) != "PERMISSION_DENIED" {
t.Fatal("wrong administrator accepted")
}
if _, err := access(in, false); fault.Code(err) != "STATE_CONFLICT" {
t.Fatal("legacy token unexpectedly revealed")
}
in.Token = initial.Token
// 审计失败时导入事务回滚,查看也不能把秘密交付给调用方。
trigger := "vault_audit_" + string(testID(t))
if _, err := db.ExecContext(ctx, "CREATE TRIGGER "+trigger+" BEFORE INSERT ON orbit_audit_events FOR EACH ROW BEGIN IF NEW.namespace_id=UNHEX('"+string(initial.NamespaceID)+"') AND NEW.action LIKE 'console.credential.token.%' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='vault audit failure test'; END IF; END"); err != nil {
t.Fatal(err)
}
defer db.ExecContext(ctx, "DROP TRIGGER IF EXISTS "+trigger)
if got, err := access(in, true); err == nil || got.Token != "" {
t.Fatal("audit failure returned secret")
}
var imported bool
if err := db.QueryRowContext(ctx, `SELECT secret_ciphertext IS NOT NULL FROM orbit_credentials WHERE namespace_id=? AND id=?`, initial.NamespaceID, initial.CredentialID).Scan(&imported); err != nil || imported {
t.Fatal("failed import did not roll back")
}
if _, err := db.ExecContext(ctx, "DROP TRIGGER "+trigger); err != nil {
t.Fatal(err)
}
if _, err := access(in, true); err != nil {
t.Fatal(err)
}
if _, err := access(in, true); err != nil {
t.Fatal("repeat import failed", err)
}
in.Token = ""
if _, err := db.ExecContext(ctx, "CREATE TRIGGER "+trigger+" BEFORE INSERT ON orbit_audit_events FOR EACH ROW BEGIN IF NEW.namespace_id=UNHEX('"+string(initial.NamespaceID)+"') AND NEW.action='console.credential.token.view' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='vault audit failure test'; END IF; END"); err != nil {
t.Fatal(err)
}
if got, err := access(in, false); err == nil || got.Token != "" {
t.Fatal("read without audit returned secret")
}
if _, err := db.ExecContext(ctx, "DROP TRIGGER "+trigger); err != nil {
t.Fatal(err)
}
if got, err := access(in, false); err != nil || got.Token != initial.Token || got.Revision != 1 {
t.Fatal("import changed identity or cannot reveal")
}
var cipher []byte
if err := db.QueryRowContext(ctx, `SELECT secret_ciphertext FROM orbit_credentials WHERE namespace_id=? AND id=?`, initial.NamespaceID, initial.CredentialID).Scan(&cipher); err != nil || bytes.Contains(cipher, []byte(initial.Token)) {
t.Fatal("plaintext persisted")
}
detail, err := store.GetApplication(ctx, admin, initial.NamespaceID)
raw, _ := json.Marshal(detail)
if err != nil || !detail.Credentials[0].TokenStored || bytes.Contains(raw, []byte(initial.Token)) {
t.Fatal("metadata leaked or failed")
}
// 已启用密文保管的 CREATE、ROTATE、过渡期读取以及旧操作重放。
create, err := store.PrepareApplication(ctx, admin, consoleauth.ApplicationInput{Kind: "CREATE", Application: "vault-new-" + string(testID(t)), Namespace: "default", Environment: "test", Queue: "work", Reason: "vault integration"})
if err != nil {
t.Fatal(err)
}
if _, err := store.ApplyApplication(ctx, admin, create); err != nil {
t.Fatal(err)
}
if got, err := store.AccessCredentialToken(ctx, admin, create.NamespaceID, create.CredentialID, consoleauth.CredentialTokenInput{ExpectedRevision: 1}, false); err != nil || got.Token != create.Token {
t.Fatal("new app not encrypted")
}
rotate, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: 1, Action: "ROTATE", GraceFor: time.Hour, Reason: "vault rotate"})
if err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
wg.Add(2)
var readErr, rotateErr error
var read consoleauth.CredentialToken
go func() { defer wg.Done(); read, readErr = access(in, false) }()
go func() { defer wg.Done(); _, rotateErr = ApplyCredentialOperation(ctx, db, rotate, ring) }()
wg.Wait()
if rotateErr != nil || (readErr != nil && fault.Code(readErr) != "STATE_CONFLICT") || (readErr == nil && read.Token != initial.Token) {
t.Fatal("concurrent read/rotation inconsistent", rotateErr, readErr)
}
in.ExpectedRevision = 2
if got, err := access(in, false); err != nil || got.Token != rotate.Token {
t.Fatal("current token unavailable during grace")
}
in.Token = initial.Token
if _, err := access(in, true); fault.Code(err) != "INVALID_ARGUMENT" {
t.Fatal("old generation imported")
}
in.Token = ""
status, err := GetCredentialStatus(ctx, db, initial.NamespaceID, initial.CredentialID)
if err != nil || status.PreviousValidUntil == nil {
t.Fatal("grace lost")
}
for _, action := range []string{"SET_EXPIRY", "FINALIZE"} {
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: in.ExpectedRevision, Action: action, Reason: "preserve vault"})
if err != nil {
t.Fatal(err)
}
if _, err = ApplyCredentialOperation(ctx, db, op, ring); err != nil {
t.Fatal(err)
}
in.ExpectedRevision++
if got, err := access(in, false); err != nil || got.Token != rotate.Token {
t.Fatal("non-secret change invalidated encryption")
}
}
if _, err := ApplyCredentialOperation(ctx, db, rotate, ring); err != nil {
t.Fatal(err)
}
if got, err := access(in, false); err != nil || got.Token != rotate.Token {
t.Fatal("replay changed ciphertext")
}
// 撤销会清除密文;历史 CLI 不配置密钥时轮换也不能遗留过期密文。
op, err := PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: in.ExpectedRevision, Action: "ROTATE", Reason: "legacy CLI"})
if err != nil {
t.Fatal(err)
}
if _, err = ApplyCredentialOperation(ctx, db, op); err != nil {
t.Fatal(err)
}
in.ExpectedRevision++
if _, err := access(in, false); fault.Code(err) != "STATE_CONFLICT" {
t.Fatal("stale ciphertext remained after CLI rotation")
}
in.Token = op.Token
if _, err := access(in, true); err != nil {
t.Fatal(err)
}
in.Token = ""
op, err = PrepareCredentialOperation(ctx, db, CredentialChangeInput{NamespaceID: initial.NamespaceID, CredentialID: initial.CredentialID, ExpectedRevision: in.ExpectedRevision, Action: "REVOKE", Reason: "revoke vault"})
if err != nil {
t.Fatal(err)
}
if _, err = ApplyCredentialOperation(ctx, db, op, ring); err != nil {
t.Fatal(err)
}
in.ExpectedRevision++
if _, err := access(in, false); err == nil {
t.Fatal("revoked credential revealed")
}
var stored bool
if err := db.QueryRowContext(ctx, `SELECT secret_ciphertext IS NOT NULL FROM orbit_credentials WHERE namespace_id=? AND id=?`, initial.NamespaceID, initial.CredentialID).Scan(&stored); err != nil || stored {
t.Fatal("revoked ciphertext remains")
}
// 审计只包含版本与操作身份,不包含 Token 或密文。
rows, err := db.QueryContext(ctx, "SELECT CONCAT_WS(' ',action,`before`,`after`) FROM orbit_audit_events WHERE namespace_id=?", initial.NamespaceID)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
views := 0
for rows.Next() {
var text string
if err := rows.Scan(&text); err != nil {
t.Fatal(err)
}
if strings.Contains(text, initial.Token) || strings.Contains(text, rotate.Token) {
t.Fatal("secret leaked to audit")
}
if strings.Contains(text, "console.credential.token.view") {
views++
}
}
if rows.Err() != nil || views < 3 {
t.Fatal("view audit missing", rows.Err())
}
}

View File

@@ -10,6 +10,7 @@ import (
"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"
@@ -67,7 +68,7 @@ type CredentialChangeInput struct {
}
// CredentialOperation 是可安全重放的私有操作文件内容。
// 令牌只存在于 ROTATE 文件中;服务端只保存其摘要和操作回执。
// ROTATE 文件包含新令牌;服务端保存摘要、可选密文和不含秘密的操作回执。
type CredentialOperation struct {
// Version 操作文件格式版本,升级时不得静默改变语义。
Version int `json:"version"`
@@ -225,12 +226,16 @@ func ValidateCredentialOperation(op CredentialOperation) error {
// 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) (CredentialChangeReceipt, error) {
return applyCredentialOperation(ctx, db, op, nil)
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)) (CredentialChangeReceipt, error) {
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
@@ -320,9 +325,14 @@ func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOper
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=?,revision=revision+1,updated_at=?
WHERE namespace_id=? AND id=? AND revision=?`, parsed.Digest, op.ExpiresAt, previousDigest, out.PreviousValidUntil, now, op.NamespaceID, op.CredentialID, revision); err != nil {
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
@@ -341,7 +351,7 @@ func applyCredentialOperation(ctx context.Context, db *sql.DB, op CredentialOper
}
out.PreviousValidUntil = nil
case credentialRevokeAction:
if _, err := tx.ExecContext(ctx, `UPDATE orbit_credentials SET revoked_at=?,previous_secret_digest=NULL,previous_valid_until=NULL,revision=revision+1,updated_at=? WHERE namespace_id=? AND id=? AND revision=?`, now, now, op.NamespaceID, op.CredentialID, revision); err != nil {
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)

View File

@@ -26,7 +26,7 @@ var migrationFiles embed.FS
//go:embed baseline.sql
var freshInstallBaseline string
const freshInstallBaselineVersion uint64 = 32
const freshInstallBaselineVersion uint64 = 33
type Migration struct {
Version uint64

View File

@@ -0,0 +1,6 @@
-- 管理员可回看当前 Token;旧记录保留为空,导入只补密文,不轮换秘密。
ALTER TABLE orbit_credentials
ADD COLUMN secret_key_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL COMMENT '当前 Token 密文使用的主密钥标识;空表示尚未保管,主密钥不存数据库',
ADD COLUMN secret_ciphertext VARBINARY(1024) NULL COMMENT '当前 Token 的 AES-256-GCM nonce 与认证密文;空表示旧凭据仅存摘要',
ADD CONSTRAINT ck_credential_ciphertext CHECK ((secret_key_id IS NULL AND secret_ciphertext IS NULL) OR (secret_key_id IS NOT NULL AND secret_ciphertext IS NOT NULL AND LENGTH(secret_key_id)>0 AND LENGTH(secret_ciphertext)>=28)),
COMMENT='命名空间凭据、认证摘要、授权范围及可选的当前 Token 密文';