store.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. import sodium from 'libsodium-wrappers-sumo'
  2. import { randomUUID, createHash } from 'node:crypto'
  3. import { mkdirSync, lstatSync, openSync, writeFileSync, fsyncSync, closeSync, renameSync, rmSync } from 'node:fs'
  4. import { join, resolve } from 'node:path'
  5. import { z } from 'zod'
  6. import { HttpError } from '../errors.js'
  7. import { entryInput, updateInput, storedEntry, parse, masterPassword, type VaultEntry } from './schema.js'
  8. import type { CredentialSummary } from '../../shared/credentialVault.js'
  9. import { readPrivateUtf8 } from './privateFiles.js'
  10. const MAX_BYTES = 2 * 1024 * 1024
  11. const bytes = z.string().max(MAX_BYTES).regex(/^[A-Za-z0-9+/]*={0,2}$/)
  12. const envelopeSchema = z.object({
  13. format: z.literal('yaoyao-credential-vault'), version: z.literal(1),
  14. owner: z.string().min(1).max(256), id: z.string().uuid(), revision: z.number().int().positive(),
  15. kdf: z.object({ algorithm: z.literal('argon2id13'), salt: bytes, ops: z.literal(3), memory: z.literal(67108864) }).strict(),
  16. wrapping: z.object({ nonce: bytes, ciphertext: bytes }).strict(),
  17. payload: z.object({ nonce: bytes, ciphertext: bytes }).strict(),
  18. }).strict()
  19. type Envelope = z.infer<typeof envelopeSchema>
  20. const documentSchema = z.object({ entries: z.array(storedEntry).max(256) }).strict()
  21. const encode = (value: Uint8Array) => sodium.to_base64(value, sodium.base64_variants.ORIGINAL)
  22. const decode = (value: string, length?: number) => {
  23. const data = sodium.from_base64(value, sodium.base64_variants.ORIGINAL)
  24. if (length !== undefined && data.length !== length) throw new Error('invalid envelope')
  25. return data
  26. }
  27. const wipe = (value?: Uint8Array) => { if (value) sodium.memzero(value) }
  28. function aad(e: Envelope, part: 'key' | 'entries') {
  29. return JSON.stringify(['yaoyao-credential-vault', e.version, e.owner, e.id, part,
  30. ...(part === 'key' ? [e.kdf] : [e.revision])])
  31. }
  32. function encrypt(value: Uint8Array, key: Uint8Array, associated: string) {
  33. const nonce = sodium.randombytes_buf(24)
  34. return { nonce: encode(nonce), ciphertext: encode(sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(value, associated, null, nonce, key)) }
  35. }
  36. function decrypt(value: Envelope['payload'], key: Uint8Array, associated: string) {
  37. return sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(null, decode(value.ciphertext), associated, decode(value.nonce, 24), key)
  38. }
  39. function derive(password: string, e: Envelope) {
  40. const data = sodium.from_string(masterPassword(password))
  41. try { return sodium.crypto_pwhash(32, data, decode(e.kdf.salt, 16), e.kdf.ops, e.kdf.memory, sodium.crypto_pwhash_ALG_ARGON2ID13) }
  42. finally { wipe(data) }
  43. }
  44. const summary = ({ secret: _secret, passphrase: _passphrase, ...entry }: VaultEntry): CredentialSummary => entry
  45. /** This belongs in an independently isolated broker. No file/OS-account key is created.
  46. * JS strings/GC and WASM are not a secure enclave; same-UID process access must be
  47. * prevented by deployment. Restart discards the DEK and always starts locked. */
  48. export class CredentialVaultStore {
  49. private unlocked = new Map<string, { envelope: Envelope; key: Uint8Array; entries: VaultEntry[]; until: number }>()
  50. private failures = new Map<string, { count: number; next: number }>()
  51. readonly root: string
  52. onLock: (owner: string) => void = () => {}
  53. constructor(root: string, private now = Date.now, private beforeRename?: () => void) {
  54. this.root = resolve(root)
  55. mkdirSync(this.root, { recursive: true, mode: 0o700 })
  56. const stat = lstatSync(this.root)
  57. if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) || stat.uid !== process.getuid?.()) throw new Error('密码库目录必须为当前服务身份的私有目录')
  58. }
  59. static async create(root: string, now = Date.now, beforeRename?: () => void) {
  60. await sodium.ready
  61. return new CredentialVaultStore(root, now, beforeRename)
  62. }
  63. private path(owner: string) {
  64. parse(z.string().min(1).max(256), owner)
  65. return join(this.root, createHash('sha256').update(owner).digest('hex') + '.vault')
  66. }
  67. private load(owner: string): Envelope | undefined {
  68. const path = this.path(owner)
  69. try {
  70. const e = parse(envelopeSchema, JSON.parse(readPrivateUtf8(path, MAX_BYTES)))
  71. if (e.owner !== owner) throw new Error()
  72. return e
  73. } catch (error) {
  74. if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
  75. throw new HttpError(409, '密码库文件无效或权限不安全', 'vault_corrupt')
  76. }
  77. }
  78. private save(e: Envelope) {
  79. const value = JSON.stringify(e)
  80. if (Buffer.byteLength(value) > MAX_BYTES) throw new HttpError(413, '密码库已达到大小上限', 'vault_size_limit')
  81. const path = this.path(e.owner), temp = path + '.' + randomUUID() + '.tmp'
  82. try {
  83. const fd = openSync(temp, 'wx', 0o600)
  84. try { writeFileSync(fd, value); fsyncSync(fd) } finally { closeSync(fd) }
  85. this.beforeRename?.()
  86. renameSync(temp, path)
  87. const directory = openSync(this.root, 'r')
  88. try { fsyncSync(directory) } finally { closeSync(directory) }
  89. } finally { rmSync(temp, { force: true }) }
  90. }
  91. private active(owner: string) {
  92. const session = this.unlocked.get(owner)
  93. if (session && session.until <= this.now()) this.lock(owner)
  94. const current = this.unlocked.get(owner)
  95. if (!current) throw new HttpError(423, '密码库已锁定,请在密码管理界面手动解锁', 'vault_locked')
  96. return current
  97. }
  98. state(owner: string) {
  99. const s = this.unlocked.get(owner)
  100. if (s && s.until <= this.now()) this.lock(owner)
  101. const current = this.unlocked.get(owner)
  102. return { initialized: !!this.load(owner), unlocked: !!current, ...(current ? { unlockExpiresAt: current.until } : {}) }
  103. }
  104. private decodeDocument(e: Envelope, key: Uint8Array) {
  105. const data = decrypt(e.payload, key, aad(e, 'entries'))
  106. try { return parse(documentSchema, JSON.parse(sodium.to_string(data))).entries }
  107. finally { wipe(data) }
  108. }
  109. private encodeDocument(e: Envelope, key: Uint8Array, entries: VaultEntry[]) {
  110. const data = sodium.from_string(JSON.stringify({ entries }))
  111. try { return { ...e, payload: encrypt(data, key, aad(e, 'entries')) } }
  112. finally { wipe(data) }
  113. }
  114. initialize(owner: string, password: string) {
  115. if (this.load(owner)) throw new HttpError(409, '密码库已存在', 'vault_exists')
  116. const key = sodium.randombytes_buf(32)
  117. const e: Envelope = { format: 'yaoyao-credential-vault', version: 1, owner, id: randomUUID(), revision: 1,
  118. kdf: { algorithm: 'argon2id13', salt: encode(sodium.randombytes_buf(16)), ops: 3, memory: 67108864 },
  119. wrapping: { nonce: '', ciphertext: '' }, payload: { nonce: '', ciphertext: '' } }
  120. let kek: Uint8Array | undefined
  121. try {
  122. kek = derive(password, e); e.wrapping = encrypt(key, kek, aad(e, 'key'))
  123. this.save(this.encodeDocument(e, key, []))
  124. } finally { wipe(kek); wipe(key) }
  125. return this.state(owner) // Creation does not silently unlock.
  126. }
  127. unlock(owner: string, password: string, seconds = 300) {
  128. parse(z.number().int().min(30).max(900), seconds)
  129. if ((this.failures.get(owner)?.next ?? 0) > this.now()) throw new HttpError(429, '解锁尝试过多,请稍后重试', 'vault_unlock_throttled')
  130. const e = this.load(owner)
  131. if (!e) throw new HttpError(404, '请先创建密码库', 'vault_missing')
  132. this.lock(owner)
  133. let kek: Uint8Array | undefined, key: Uint8Array | undefined
  134. try {
  135. kek = derive(password, e); key = decrypt(e.wrapping, kek, aad(e, 'key'))
  136. if (key.length !== 32) throw new Error()
  137. const entries = this.decodeDocument(e, key)
  138. this.unlocked.set(owner, { envelope: e, key, entries, until: this.now() + seconds * 1000 })
  139. key = undefined; this.failures.delete(owner)
  140. } catch {
  141. const count = (this.failures.get(owner)?.count ?? 0) + 1
  142. this.failures.set(owner, { count, next: this.now() + Math.min(60000, 1000 * 2 ** Math.min(count - 1, 6)) })
  143. throw new HttpError(401, '主密码错误或备份已损坏', 'vault_unlock_failed')
  144. } finally { wipe(kek); wipe(key) }
  145. return this.state(owner)
  146. }
  147. lock(owner: string) {
  148. const session = this.unlocked.get(owner)
  149. this.unlocked.delete(owner); wipe(session?.key)
  150. if (session) { session.entries.length = 0; this.onLock(owner) }
  151. }
  152. lockAll() { for (const owner of [...this.unlocked.keys()]) this.lock(owner) }
  153. list(owner: string) { return this.active(owner).entries.map(summary) }
  154. private commit(owner: string, entries: VaultEntry[]) {
  155. const s = this.active(owner)
  156. const next = this.encodeDocument({ ...s.envelope, revision: s.envelope.revision + 1 }, s.key, entries)
  157. try { this.save(next); s.envelope = next; s.entries = entries }
  158. catch (error) { this.lock(owner); throw error } // A rename may have happened before a failed directory fsync.
  159. }
  160. add(owner: string, value: unknown) {
  161. const s = this.active(owner), input = parse(entryInput, value)
  162. if (s.entries.length >= 256) throw new HttpError(413, '凭据数量已达到上限', 'vault_entry_limit')
  163. const entry = { ...input, id: randomUUID(), revision: 1, updatedAt: this.now() }
  164. this.commit(owner, [...s.entries, entry]); return summary(entry)
  165. }
  166. update(owner: string, id: string, value: unknown) {
  167. const s = this.active(owner), input = parse(updateInput, value), old = s.entries.find(v => v.id === id)
  168. if (!old) throw new HttpError(404, '凭据不存在', 'vault_entry_missing')
  169. if (old.revision !== input.revision) throw new HttpError(409, '凭据已变化,请刷新', 'vault_revision_conflict')
  170. const next = { ...old, ...input, secret: input.secret ?? old.secret, revision: old.revision + 1, updatedAt: this.now() }
  171. this.commit(owner, s.entries.map(v => v.id === id ? next : v)); this.onLock(owner)
  172. return summary(next)
  173. }
  174. remove(owner: string, id: string) {
  175. const s = this.active(owner)
  176. if (!s.entries.some(v => v.id === id)) throw new HttpError(404, '凭据不存在', 'vault_entry_missing')
  177. this.commit(owner, s.entries.filter(v => v.id !== id)); this.onLock(owner)
  178. }
  179. /** Only the isolated executor callback may receive this. No read-secret RPC exists. */
  180. withSecret<T>(owner: string, id: string, use: (entry: Readonly<VaultEntry>) => T): T {
  181. const s = this.active(owner), entry = s.entries.find(v => v.id === id)
  182. if (!entry) throw new HttpError(404, '凭据不存在', 'vault_entry_missing')
  183. return use(entry)
  184. }
  185. backup(owner: string) {
  186. this.active(owner)
  187. return JSON.stringify(this.load(owner))
  188. }
  189. restore(owner: string, archive: string, password: string) {
  190. if (this.load(owner)) throw new HttpError(409, '恢复需要尚未初始化的密码库,不能覆盖现有数据', 'vault_exists')
  191. if (Buffer.byteLength(archive) > MAX_BYTES) throw new HttpError(413, '备份过大', 'vault_size_limit')
  192. const e = parse(envelopeSchema, JSON.parse(archive))
  193. if (e.owner !== owner) throw new HttpError(403, '备份不属于当前账号', 'vault_backup_owner')
  194. let kek: Uint8Array | undefined, key: Uint8Array | undefined
  195. try { kek = derive(password, e); key = decrypt(e.wrapping, kek, aad(e, 'key')); this.decodeDocument(e, key); this.save(e) }
  196. catch { throw new HttpError(400, '备份无法验证,原密码库未改变', 'vault_backup_invalid') }
  197. finally { wipe(kek); wipe(key) }
  198. return this.state(owner)
  199. }
  200. rotate(owner: string, password: string) {
  201. const s = this.active(owner), key = sodium.randombytes_buf(32)
  202. const next = { ...s.envelope, revision: s.envelope.revision + 1,
  203. kdf: { ...s.envelope.kdf, salt: encode(sodium.randombytes_buf(16)) } }
  204. let kek: Uint8Array | undefined
  205. try {
  206. kek = derive(password, next); next.wrapping = encrypt(key, kek, aad(next, 'key'))
  207. this.save(this.encodeDocument(next, key, s.entries))
  208. this.lock(owner)
  209. } catch (error) { this.lock(owner); throw error }
  210. finally { wipe(kek); wipe(key) }
  211. }
  212. }