Jelajahi Sumber

新增手动解锁的独立加密密码库存储与恢复回归

Samien 1 bulan lalu
induk
melakukan
9092831048

+ 21 - 0
docs/credential-vault.md

@@ -0,0 +1,21 @@
+# Bot 内置密码库(首期)
+
+部署目标是 Hermes 执行节点,用户通过专用界面手动输入主密码。默认锁定,重启不自动解锁。密码库不是 Hermes 的环境变量或工具配置。
+
+## 密码学与存储
+
+固定依赖 libsodium-wrappers-sumo 0.8.4(官方 npm registry,禁用安装脚本),使用其 Argon2id13 和 XChaCha20-Poly1305。每个账号独立随机 256-bit DEK;主密码经随机 128-bit salt、3 次迭代、64 MiB 内存派生 KEK,包装 DEK。每次包装、写入都使用独立随机 192-bit nonce。AAD 绑定格式版本、账号、vault ID,以及 KDF 参数或文档修订;元数据与秘密一起加密,没有同目录静态解密密钥。
+
+格式 v1 的文件原子替换并 fsync;写入中断应留下旧或新的完整可解密版本。主密码轮换同时更换 salt 和 DEK。加密备份保留 owner 身份,只恢复到未初始化且账号身份相同的库;不能覆盖现有库。迁移必须保留账号 ID。备份可用其导出时的主密码解密,改主密码不会使旧备份失效;遗失主密码无法恢复。没有离线文件回滚检测或硬件密钥保护。
+
+解锁 DEK 只存在 broker 内存,固定到期、锁定、关闭时清除可控字节缓冲。JS 字符串、GC、WASM、交换区和进程转储不提供安全擦除保证,必须配合操作系统隔离;不能据此称“密码永不进入内存”。UI/API 不提供读取密码或私钥明文的接口;编辑秘密是单向替换。
+
+## 执行边界
+
+同 OS 用户的 Hermes 任意 shell 可以读取同用户文件或访问进程/IPC,因此单纯子进程、0600 权限、secretref 或加密 DB 不构成隔离。真实部署前需人工批准独立 OS 身份/可信沙箱、私有 IPC、控制面访问权限以及浏览器进程/资料目录边界。Web 管理面也不能与拥有任意本机 shell 的 Hermes 共用可信身份。
+
+首期正式浏览器与 SSH executor **未启用**。即使库已解锁也不能退回通用 fill、host_shell、环境变量或临时密钥文件;没有“模拟登录成功”的生产实现。网站登录最终需要独占阶段、精确 HTTPS origin/frame/form 目标校验,冻结模型 DOM/截图/请求/Cookie 访问;MFA/验证码由用户处理。SSH 需固定 host key,密钥认证优先,禁 agent forwarding 与任意 ProxyCommand;文件传输采用 SFTP,明文 FTP 不支持。
+
+开发仅使用临时目录中的 dummy 凭据和本机测试夹具。真实秘密录入、真实登录、OS 用户/权限/守护进程安装及生产切换都不属于本轮本地实现操作。
+
+官方说明:[libsodium.js](https://github.com/jedisct1/libsodium.js)、[Argon2id](https://doc.libsodium.org/password_hashing/default_phf)、[XChaCha20-Poly1305](https://doc.libsodium.org/secret-key_cryptography/aead/chacha20-poly1305/xchacha20-poly1305_construction)。

+ 18 - 2
package-lock.json

@@ -1,11 +1,11 @@
 {
 {
-  "name": "yaoyao",
+  "name": "@lsamien/yaoyao",
   "version": "0.4.77",
   "version": "0.4.77",
   "lockfileVersion": 3,
   "lockfileVersion": 3,
   "requires": true,
   "requires": true,
   "packages": {
   "packages": {
     "": {
     "": {
-      "name": "yaoyao",
+      "name": "@lsamien/yaoyao",
       "version": "0.4.77",
       "version": "0.4.77",
       "license": "Apache-2.0",
       "license": "Apache-2.0",
       "dependencies": {
       "dependencies": {
@@ -34,6 +34,7 @@
         "koa": "^2.15.3",
         "koa": "^2.15.3",
         "koa-send": "^5.0.1",
         "koa-send": "^5.0.1",
         "koa-static": "^5.0.0",
         "koa-static": "^5.0.0",
+        "libsodium-wrappers-sumo": "0.8.4",
         "markdown-it": "^14.1.0",
         "markdown-it": "^14.1.0",
         "mime-types": "^3.0.1",
         "mime-types": "^3.0.1",
         "naive-ui": "^2.44.1",
         "naive-ui": "^2.44.1",
@@ -6905,6 +6906,21 @@
       "dev": true,
       "dev": true,
       "license": "MIT"
       "license": "MIT"
     },
     },
+    "node_modules/libsodium-sumo": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.8.4.tgz",
+      "integrity": "sha512-TMtHShQfVVsaxDygyapvUC3o7YsPgXa/hRWeIgzyFz6w5k/1hirGptCxp1U7XwW3rCskaTTYKgV10v86UiGgNw==",
+      "license": "ISC"
+    },
+    "node_modules/libsodium-wrappers-sumo": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.8.4.tgz",
+      "integrity": "sha512-ql7hcgulKZ3ekfa2DGAogcCKsWU0diA/0nArz1CFzh93WQdb46/Kj18ka/Hifq6uA3Ush34Pc6vU/6HXeRwUkg==",
+      "license": "ISC",
+      "dependencies": {
+        "libsodium-sumo": "^0.8.0"
+      }
+    },
     "node_modules/lie": {
     "node_modules/lie": {
       "version": "3.3.0",
       "version": "3.3.0",
       "resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz",
       "resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz",

+ 1 - 0
package.json

@@ -69,6 +69,7 @@
     "koa": "^2.15.3",
     "koa": "^2.15.3",
     "koa-send": "^5.0.1",
     "koa-send": "^5.0.1",
     "koa-static": "^5.0.0",
     "koa-static": "^5.0.0",
+    "libsodium-wrappers-sumo": "0.8.4",
     "markdown-it": "^14.1.0",
     "markdown-it": "^14.1.0",
     "mime-types": "^3.0.1",
     "mime-types": "^3.0.1",
     "naive-ui": "^2.44.1",
     "naive-ui": "^2.44.1",

+ 25 - 0
src/server/credentialVault/schema.ts

@@ -0,0 +1,25 @@
+import { z } from 'zod'
+import { HttpError } from '../errors.js'
+export const website = z.object({ kind: z.literal('website'), origin: z.string().max(2000).transform((value, ctx) => {
+  try {
+    const u = new URL(value)
+    if (u.protocol !== 'https:' || u.username || u.password || u.search || u.hash || (u.pathname !== '/' && u.pathname !== '')) throw new Error()
+    return u.origin
+  } catch { ctx.addIssue({ code: 'custom', message: '网站需要准确的 HTTPS origin' }); return z.NEVER }
+}) }).strict()
+export const ssh = z.object({ kind: z.literal('ssh'), host: z.string().min(1).max(253).regex(/^[A-Za-z0-9.:[\]_-]+$/).transform(v => v.toLowerCase()),
+  port: z.number().int().min(1).max(65535), hostKey: z.string().regex(/^SHA256:[A-Za-z0-9+/]{43}=?$/) }).strict()
+export const target = z.discriminatedUnion('kind', [website, ssh])
+export const entryInput = z.object({ name: z.string().trim().min(1).max(120), username: z.string().min(1).max(256),
+  target, secret: z.string().min(1).max(32768), passphrase: z.string().max(4096).optional() }).strict()
+export const updateInput = entryInput.extend({ secret: entryInput.shape.secret.optional(), revision: z.number().int().positive() })
+export const storedEntry = entryInput.extend({ id: z.string().uuid(), revision: z.number().int().positive(), updatedAt: z.number().int().nonnegative() })
+export type VaultEntry = z.infer<typeof storedEntry>
+export function parse<T>(schema: z.ZodType<T>, value: unknown): T {
+  const result = schema.safeParse(value)
+  if (!result.success) throw new HttpError(400, '密码库请求格式无效', 'vault_invalid_request')
+  return result.data
+}
+export function masterPassword(value: unknown): string {
+  return parse(z.string().min(12).max(1024), value)
+}

+ 213 - 0
src/server/credentialVault/store.ts

@@ -0,0 +1,213 @@
+import sodium from 'libsodium-wrappers-sumo'
+import { randomUUID, createHash } from 'node:crypto'
+import { mkdirSync, lstatSync, readFileSync, openSync, writeFileSync, fsyncSync, closeSync, renameSync, rmSync } from 'node:fs'
+import { join, resolve } from 'node:path'
+import { z } from 'zod'
+import { HttpError } from '../errors.js'
+import { entryInput, updateInput, storedEntry, parse, masterPassword, type VaultEntry } from './schema.js'
+import type { CredentialSummary } from '../../shared/credentialVault.js'
+
+const MAX_BYTES = 2 * 1024 * 1024
+const bytes = z.string().max(MAX_BYTES).regex(/^[A-Za-z0-9+/]*={0,2}$/)
+const envelopeSchema = z.object({
+  format: z.literal('yaoyao-credential-vault'), version: z.literal(1),
+  owner: z.string().min(1).max(256), id: z.string().uuid(), revision: z.number().int().positive(),
+  kdf: z.object({ algorithm: z.literal('argon2id13'), salt: bytes, ops: z.literal(3), memory: z.literal(67108864) }).strict(),
+  wrapping: z.object({ nonce: bytes, ciphertext: bytes }).strict(),
+  payload: z.object({ nonce: bytes, ciphertext: bytes }).strict(),
+}).strict()
+type Envelope = z.infer<typeof envelopeSchema>
+const documentSchema = z.object({ entries: z.array(storedEntry).max(256) }).strict()
+const encode = (value: Uint8Array) => sodium.to_base64(value, sodium.base64_variants.ORIGINAL)
+const decode = (value: string, length?: number) => {
+  const data = sodium.from_base64(value, sodium.base64_variants.ORIGINAL)
+  if (length !== undefined && data.length !== length) throw new Error('invalid envelope')
+  return data
+}
+const wipe = (value?: Uint8Array) => { if (value) sodium.memzero(value) }
+function aad(e: Envelope, part: 'key' | 'entries') {
+  return JSON.stringify(['yaoyao-credential-vault', e.version, e.owner, e.id, part,
+    ...(part === 'key' ? [e.kdf] : [e.revision])])
+}
+function encrypt(value: Uint8Array, key: Uint8Array, associated: string) {
+  const nonce = sodium.randombytes_buf(24)
+  return { nonce: encode(nonce), ciphertext: encode(sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(value, associated, null, nonce, key)) }
+}
+function decrypt(value: Envelope['payload'], key: Uint8Array, associated: string) {
+  return sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(null, decode(value.ciphertext), associated, decode(value.nonce, 24), key)
+}
+function derive(password: string, e: Envelope) {
+  const data = sodium.from_string(masterPassword(password))
+  try { return sodium.crypto_pwhash(32, data, decode(e.kdf.salt, 16), e.kdf.ops, e.kdf.memory, sodium.crypto_pwhash_ALG_ARGON2ID13) }
+  finally { wipe(data) }
+}
+const summary = ({ secret: _secret, passphrase: _passphrase, ...entry }: VaultEntry): CredentialSummary => entry
+
+/** This belongs in an independently isolated broker. No file/OS-account key is created.
+ * JS strings/GC and WASM are not a secure enclave; same-UID process access must be
+ * prevented by deployment. Restart discards the DEK and always starts locked. */
+export class CredentialVaultStore {
+  private unlocked = new Map<string, { envelope: Envelope; key: Uint8Array; entries: VaultEntry[]; until: number }>()
+  private failures = new Map<string, { count: number; next: number }>()
+  readonly root: string
+  onLock: (owner: string) => void = () => {}
+  constructor(root: string, private now = Date.now, private beforeRename?: () => void) {
+    this.root = resolve(root)
+    mkdirSync(this.root, { recursive: true, mode: 0o700 })
+    const stat = lstatSync(this.root)
+    if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077)) throw new Error('密码库目录必须为私有目录')
+  }
+  static async create(root: string, now = Date.now, beforeRename?: () => void) {
+    await sodium.ready
+    return new CredentialVaultStore(root, now, beforeRename)
+  }
+  private path(owner: string) {
+    parse(z.string().min(1).max(256), owner)
+    return join(this.root, createHash('sha256').update(owner).digest('hex') + '.vault')
+  }
+  private load(owner: string): Envelope | undefined {
+    const path = this.path(owner)
+    try {
+      const stat = lstatSync(path)
+      if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_BYTES || (stat.mode & 0o077)) throw new Error()
+      const e = parse(envelopeSchema, JSON.parse(readFileSync(path, 'utf8')))
+      if (e.owner !== owner) throw new Error()
+      return e
+    } catch (error) {
+      if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
+      throw new HttpError(409, '密码库文件无效或权限不安全', 'vault_corrupt')
+    }
+  }
+  private save(e: Envelope) {
+    const value = JSON.stringify(e)
+    if (Buffer.byteLength(value) > MAX_BYTES) throw new HttpError(413, '密码库已达到大小上限', 'vault_size_limit')
+    const path = this.path(e.owner), temp = path + '.' + randomUUID() + '.tmp'
+    try {
+      const fd = openSync(temp, 'wx', 0o600)
+      try { writeFileSync(fd, value); fsyncSync(fd) } finally { closeSync(fd) }
+      this.beforeRename?.()
+      renameSync(temp, path)
+      const directory = openSync(this.root, 'r')
+      try { fsyncSync(directory) } finally { closeSync(directory) }
+    } finally { rmSync(temp, { force: true }) }
+  }
+  private active(owner: string) {
+    const session = this.unlocked.get(owner)
+    if (session && session.until <= this.now()) this.lock(owner)
+    const current = this.unlocked.get(owner)
+    if (!current) throw new HttpError(423, '密码库已锁定,请在密码管理界面手动解锁', 'vault_locked')
+    return current
+  }
+  state(owner: string) {
+    const s = this.unlocked.get(owner)
+    if (s && s.until <= this.now()) this.lock(owner)
+    const current = this.unlocked.get(owner)
+    return { initialized: !!this.load(owner), unlocked: !!current, ...(current ? { unlockExpiresAt: current.until } : {}) }
+  }
+  private decodeDocument(e: Envelope, key: Uint8Array) {
+    const data = decrypt(e.payload, key, aad(e, 'entries'))
+    try { return parse(documentSchema, JSON.parse(sodium.to_string(data))).entries }
+    finally { wipe(data) }
+  }
+  private encodeDocument(e: Envelope, key: Uint8Array, entries: VaultEntry[]) {
+    const data = sodium.from_string(JSON.stringify({ entries }))
+    try { return { ...e, payload: encrypt(data, key, aad(e, 'entries')) } }
+    finally { wipe(data) }
+  }
+  initialize(owner: string, password: string) {
+    if (this.load(owner)) throw new HttpError(409, '密码库已存在', 'vault_exists')
+    const key = sodium.randombytes_buf(32)
+    const e: Envelope = { format: 'yaoyao-credential-vault', version: 1, owner, id: randomUUID(), revision: 1,
+      kdf: { algorithm: 'argon2id13', salt: encode(sodium.randombytes_buf(16)), ops: 3, memory: 67108864 },
+      wrapping: { nonce: '', ciphertext: '' }, payload: { nonce: '', ciphertext: '' } }
+    let kek: Uint8Array | undefined
+    try {
+      kek = derive(password, e); e.wrapping = encrypt(key, kek, aad(e, 'key'))
+      this.save(this.encodeDocument(e, key, []))
+    } finally { wipe(kek); wipe(key) }
+    return this.state(owner) // Creation does not silently unlock.
+  }
+  unlock(owner: string, password: string, seconds = 300) {
+    parse(z.number().int().min(30).max(900), seconds)
+    if ((this.failures.get(owner)?.next ?? 0) > this.now()) throw new HttpError(429, '解锁尝试过多,请稍后重试', 'vault_unlock_throttled')
+    const e = this.load(owner)
+    if (!e) throw new HttpError(404, '请先创建密码库', 'vault_missing')
+    this.lock(owner)
+    let kek: Uint8Array | undefined, key: Uint8Array | undefined
+    try {
+      kek = derive(password, e); key = decrypt(e.wrapping, kek, aad(e, 'key'))
+      if (key.length !== 32) throw new Error()
+      const entries = this.decodeDocument(e, key)
+      this.unlocked.set(owner, { envelope: e, key, entries, until: this.now() + seconds * 1000 })
+      key = undefined; this.failures.delete(owner)
+    } catch {
+      const count = (this.failures.get(owner)?.count ?? 0) + 1
+      this.failures.set(owner, { count, next: this.now() + Math.min(60000, 1000 * 2 ** Math.min(count - 1, 6)) })
+      throw new HttpError(401, '主密码错误或备份已损坏', 'vault_unlock_failed')
+    } finally { wipe(kek); wipe(key) }
+    return this.state(owner)
+  }
+  lock(owner: string) {
+    const session = this.unlocked.get(owner)
+    this.unlocked.delete(owner); wipe(session?.key)
+    if (session) { session.entries.length = 0; this.onLock(owner) }
+  }
+  lockAll() { for (const owner of [...this.unlocked.keys()]) this.lock(owner) }
+  list(owner: string) { return this.active(owner).entries.map(summary) }
+  private commit(owner: string, entries: VaultEntry[]) {
+    const s = this.active(owner)
+    const next = this.encodeDocument({ ...s.envelope, revision: s.envelope.revision + 1 }, s.key, entries)
+    this.save(next); s.envelope = next; s.entries = entries
+  }
+  add(owner: string, value: unknown) {
+    const s = this.active(owner), input = parse(entryInput, value)
+    if (s.entries.length >= 256) throw new HttpError(413, '凭据数量已达到上限', 'vault_entry_limit')
+    const entry = { ...input, id: randomUUID(), revision: 1, updatedAt: this.now() }
+    this.commit(owner, [...s.entries, entry]); return summary(entry)
+  }
+  update(owner: string, id: string, value: unknown) {
+    const s = this.active(owner), input = parse(updateInput, value), old = s.entries.find(v => v.id === id)
+    if (!old) throw new HttpError(404, '凭据不存在', 'vault_entry_missing')
+    if (old.revision !== input.revision) throw new HttpError(409, '凭据已变化,请刷新', 'vault_revision_conflict')
+    const next = { ...old, ...input, secret: input.secret ?? old.secret, revision: old.revision + 1, updatedAt: this.now() }
+    this.commit(owner, s.entries.map(v => v.id === id ? next : v)); this.onLock(owner)
+    return summary(next)
+  }
+  remove(owner: string, id: string) {
+    const s = this.active(owner)
+    if (!s.entries.some(v => v.id === id)) throw new HttpError(404, '凭据不存在', 'vault_entry_missing')
+    this.commit(owner, s.entries.filter(v => v.id !== id)); this.onLock(owner)
+  }
+  /** Only the isolated executor callback may receive this. No read-secret RPC exists. */
+  withSecret<T>(owner: string, id: string, use: (entry: Readonly<VaultEntry>) => T): T {
+    const s = this.active(owner), entry = s.entries.find(v => v.id === id)
+    if (!entry) throw new HttpError(404, '凭据不存在', 'vault_entry_missing')
+    return use(entry)
+  }
+  backup(owner: string) {
+    this.active(owner)
+    return JSON.stringify(this.load(owner))
+  }
+  restore(owner: string, archive: string, password: string) {
+    if (this.load(owner)) throw new HttpError(409, '恢复需要尚未初始化的密码库,不能覆盖现有数据', 'vault_exists')
+    if (Buffer.byteLength(archive) > MAX_BYTES) throw new HttpError(413, '备份过大', 'vault_size_limit')
+    const e = parse(envelopeSchema, JSON.parse(archive))
+    if (e.owner !== owner) throw new HttpError(403, '备份不属于当前账号', 'vault_backup_owner')
+    let kek: Uint8Array | undefined, key: Uint8Array | undefined
+    try { kek = derive(password, e); key = decrypt(e.wrapping, kek, aad(e, 'key')); this.decodeDocument(e, key); this.save(e) }
+    catch { throw new HttpError(400, '备份无法验证,原密码库未改变', 'vault_backup_invalid') }
+    finally { wipe(kek); wipe(key) }
+    return this.state(owner)
+  }
+  rotate(owner: string, password: string) {
+    const s = this.active(owner), key = sodium.randombytes_buf(32)
+    const next = { ...s.envelope, revision: s.envelope.revision + 1,
+      kdf: { ...s.envelope.kdf, salt: encode(sodium.randombytes_buf(16)) } }
+    let kek: Uint8Array | undefined
+    try {
+      kek = derive(password, next); next.wrapping = encrypt(key, kek, aad(next, 'key'))
+      this.save(this.encodeDocument(next, key, s.entries))
+      this.lock(owner)
+    } finally { wipe(kek); wipe(key) }
+  }
+}

+ 20 - 0
src/shared/credentialVault.ts

@@ -0,0 +1,20 @@
+/** Public metadata only. Passwords, private keys and unlock material never belong here. */
+export const CREDENTIAL_VAULT_PROTOCOL = 1
+export type CredentialTarget =
+  | { kind: 'website'; origin: string }
+  | { kind: 'ssh'; host: string; port: number; hostKey: string }
+export type CredentialOperation = 'website.login' | 'ssh.exec' | 'sftp.read' | 'sftp.write'
+export interface CredentialSummary {
+  id: string; name: string; username: string; target: CredentialTarget
+  revision: number; updatedAt: number
+}
+export interface CredentialLeaseSummary {
+  id: string; credentialRef: string; agentId: string; workId: string
+  runnerId: string; operation: CredentialOperation; expiresAt: number
+}
+export interface CredentialVaultStatus {
+  protocol: 1; online: boolean; initialized: boolean; unlocked: boolean
+  unlockExpiresAt?: number
+  execution: 'disabled'; reason: 'executor_not_enabled' | 'vault_offline'
+  entries: CredentialSummary[]; leases: CredentialLeaseSummary[]
+}

+ 70 - 0
tests/server/credentialVaultStore.test.ts

@@ -0,0 +1,70 @@
+// @vitest-environment node
+import { afterEach, expect, it } from 'vitest'
+import { mkdtempSync, rmSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { CredentialVaultStore } from '../../src/server/credentialVault/store'
+const homes: string[] = [], stores: CredentialVaultStore[] = []
+const owner = 'fixture-owner', password = 'dummy-master-password-only'
+const secret = 'DUMMY-DO-NOT-USE-website-password'
+const input = { name: 'fixture site', username: 'fixture-user', target: { kind: 'website' as const, origin: 'https://example.test' }, secret }
+async function fixture() {
+  const home = mkdtempSync(join(tmpdir(), 'yaoyao-vault-store-')); homes.push(home)
+  const store = await CredentialVaultStore.create(home); stores.push(store); return { store, home }
+}
+afterEach(() => { stores.splice(0).forEach(s => s.lockAll()); homes.splice(0).forEach(p => rmSync(p, { recursive: true, force: true })) })
+it('starts locked, encrypts entries and exposes metadata only; restart requires manual unlock', async () => {
+  const { store, home } = await fixture()
+  expect(store.initialize(owner, password)).toMatchObject({ initialized: true, unlocked: false })
+  expect(() => store.add(owner, input)).toThrowError(expect.objectContaining({ code: 'vault_locked' }))
+  store.unlock(owner, password); const entry = store.add(owner, input)
+  expect(entry).not.toHaveProperty('secret')
+  const file = readdirSync(home)[0]!, bytes = readFileSync(join(home, file), 'utf8')
+  expect(bytes).not.toContain(secret); expect(bytes).not.toContain(input.username); expect(bytes).not.toContain(password)
+  expect(readdirSync(home)).toHaveLength(1) // No co-located key file.
+  const restarted = await CredentialVaultStore.create(home); stores.push(restarted)
+  expect(restarted.state(owner).unlocked).toBe(false)
+  expect(() => restarted.unlock(owner, 'wrong-dummy-password')).toThrowError(expect.objectContaining({ code: 'vault_unlock_failed' }))
+  expect(() => restarted.unlock(owner, password)).toThrowError(expect.objectContaining({ code: 'vault_unlock_throttled' }))
+  expect(store.withSecret(owner, entry.id, e => e.secret)).toBe(secret)
+})
+it('binds encrypted payload to owner, vault ID, revision and KDF metadata', async () => {
+  const { store, home } = await fixture(); store.initialize(owner, password)
+  const path = join(home, readdirSync(home)[0]!), original = JSON.parse(readFileSync(path, 'utf8'))
+  for (const mutate of [(e: any) => e.id = 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa', (e: any) => e.revision++, (e: any) => e.kdf.salt = Buffer.alloc(16).toString('base64')]) {
+    const e = structuredClone(original); mutate(e); writeFileSync(path, JSON.stringify(e), { mode: 0o600 })
+    const probe = await CredentialVaultStore.create(home); stores.push(probe)
+    expect(() => probe.unlock(owner, password)).toThrowError(expect.objectContaining({ code: 'vault_unlock_failed' }))
+  }
+})
+it('uses fresh nonces, enforces entry revisions and atomically rotates the DEK and wrapping password', async () => {
+  const { store, home } = await fixture(); store.initialize(owner, password); store.unlock(owner, password)
+  const first = store.add(owner, input), path = join(home, readdirSync(home)[0]!), before = JSON.parse(readFileSync(path, 'utf8'))
+  const next = store.update(owner, first.id, { ...input, secret: undefined, revision: 1 })
+  expect(next.revision).toBe(2)
+  expect(() => store.update(owner, first.id, { ...input, revision: 1 })).toThrowError(expect.objectContaining({ code: 'vault_revision_conflict' }))
+  expect(JSON.parse(readFileSync(path, 'utf8')).payload.nonce).not.toBe(before.payload.nonce)
+  store.rotate(owner, 'dummy-replacement-password')
+  expect(store.state(owner).unlocked).toBe(false)
+  store.unlock(owner, 'dummy-replacement-password')
+  expect(store.withSecret(owner, first.id, e => e.secret)).toBe(secret)
+  store.remove(owner, first.id); expect(store.list(owner)).toEqual([])
+})
+it('verifies encrypted backups, rejects owner changes and restores without retaining unlock', async () => {
+  const { store } = await fixture(); store.initialize(owner, password); store.unlock(owner, password)
+  const entry = store.add(owner, input), archive = store.backup(owner), { store: destination } = await fixture()
+  expect(archive).not.toContain(secret)
+  expect(() => destination.restore('another-owner', archive, password)).toThrowError(expect.objectContaining({ code: 'vault_backup_owner' }))
+  destination.restore(owner, archive, password); expect(destination.state(owner).unlocked).toBe(false)
+  destination.unlock(owner, password); expect(destination.withSecret(owner, entry.id, e => e.secret)).toBe(secret)
+})
+it('locks on a fixed deadline and preserves the old decryptable file if rotation is interrupted before rename', async () => {
+  const { store, home } = await fixture(); store.initialize(owner, password)
+  let now = 1000, fail = false
+  const guarded = await CredentialVaultStore.create(home, () => now, () => { if (fail) throw new Error('dummy write interruption') }); stores.push(guarded)
+  guarded.unlock(owner, password, 30); guarded.add(owner, input); const original = guarded.backup(owner)
+  fail = true; expect(() => guarded.rotate(owner, 'dummy-new-master-password')).toThrow('dummy write interruption')
+  expect(guarded.backup(owner)).toBe(original); expect(readdirSync(home)).toHaveLength(1)
+  now += 30000; expect(guarded.state(owner).unlocked).toBe(false)
+  fail = false; guarded.unlock(owner, password); expect(guarded.list(owner)).toHaveLength(1)
+})