create-workframe 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/LICENSE +201 -201
  2. package/NOTICE +12 -12
  3. package/README.md +8 -92
  4. package/SECURITY.md +40 -40
  5. package/bin/workframe.js +329 -329
  6. package/docs/workspace-instructions/WORKFRAME_ONBOARDING.md +1 -1
  7. package/docs/workspace-instructions/WORKFRAME_ROUTING.md +8 -8
  8. package/package.json +3 -6
  9. package/profiles/architect/AGENTS.md +29 -29
  10. package/profiles/architect/SOUL.md +2 -2
  11. package/profiles/architect/skills/devops/kanban-worker/SKILL.md +27 -27
  12. package/profiles/designer/AGENTS.md +26 -26
  13. package/profiles/designer/skills/devops/kanban-worker/SKILL.md +27 -27
  14. package/profiles/dev/AGENTS.md +28 -28
  15. package/profiles/dev/skills/devops/kanban-worker/SKILL.md +27 -27
  16. package/profiles/docs/AGENTS.md +27 -27
  17. package/profiles/docs/skills/devops/kanban-worker/SKILL.md +27 -27
  18. package/profiles/research/AGENTS.md +26 -26
  19. package/profiles/research/skills/devops/kanban-worker/SKILL.md +27 -27
  20. package/profiles/visionary/AGENTS.md +25 -25
  21. package/profiles/visionary/skills/devops/kanban-worker/SKILL.md +27 -27
  22. package/profiles/workframe-agent/AGENTS.md +37 -37
  23. package/profiles/workframe-agent/skills/devops/botfather/SKILL.md +85 -85
  24. package/profiles/workframe-agent/skills/devops/kanban-handoff-pattern/SKILL.md +58 -58
  25. package/profiles/workframe-agent/skills/devops/workframe-cohort/SKILL.md +54 -54
  26. package/rules/workspace-README.md +5 -5
  27. package/scripts/apply-update-hermes.sh +17 -17
  28. package/scripts/apply-update-workframe.sh +77 -77
  29. package/scripts/bootstrap-workspace-link.sh +8 -8
  30. package/scripts/bundle-workframe-ui.mjs +3 -3
  31. package/scripts/compose-docker-host.sh +37 -37
  32. package/scripts/ensure-compose-host-paths.mjs +51 -51
  33. package/scripts/fix-zk-encryption-key.sh +35 -35
  34. package/scripts/lib/install-identity.mjs +212 -212
  35. package/scripts/restart-gateway-hermes.sh +12 -12
  36. package/scripts/set-compose-public-url.mjs +92 -92
  37. package/scripts/setup-stack-secrets.sh +50 -50
  38. package/scripts/sync-canonical-to-package.mjs +8 -7
  39. package/scripts/verify-public-deploy.sh +105 -105
  40. package/shared/WORKFRAME_AGENT_LIBRARY.md +17 -17
  41. package/shared/WORKFRAME_AGENT_OPERATIONS.md +15 -15
  42. package/shared/WORKFRAME_AGENT_PACKS.json +18 -18
  43. package/shared/WORKFRAME_AGENT_PACKS.yaml +8 -8
  44. package/shared/WORKFRAME_SKILL_CURATION.md +4 -4
  45. package/workframe-api/README.md +28 -28
  46. package/workframe-api/action_proxy.py +131 -131
  47. package/workframe-api/auth_rate_limit.py +49 -49
  48. package/workframe-api/credential_vault.py +445 -445
  49. package/workframe-api/data/avatar-catalog.json +41 -41
  50. package/workframe-api/email_sender.py +220 -220
  51. package/workframe-api/google_auth.py +90 -90
  52. package/workframe-api/install_api.py +359 -359
  53. package/workframe-api/internal_proxy_auth.py +150 -150
  54. package/workframe-api/llm_proxy.py +277 -277
  55. package/workframe-api/oidc_jwt.py +108 -108
  56. package/workframe-api/package.json +12 -13
  57. package/workframe-api/public/assets/index-DPXu_lGn.css +1 -1
  58. package/workframe-api/public/assets/index-DYnLrCZZ.js +8 -8
  59. package/workframe-api/requirements.txt +2 -2
  60. package/workframe-api/site_meta.py +271 -271
  61. package/workframe-api/stack_config.py +427 -427
  62. package/workframe-api/time-bind-chat.py +99 -99
  63. package/workframe-api/turn_credentials.py +226 -226
  64. package/workframe-api/updates.py +417 -417
  65. package/workframe-api/vault_kek.py +159 -159
  66. package/workframe-api/zk_auth.py +633 -633
  67. package/workframe-supervisor/Dockerfile +11 -11
  68. package/workframe-supervisor/server.py +787 -787
  69. package/workframe-ui/docker/nginx.conf +85 -85
  70. package/workframe-ui/public/assets/{arc-CBDYvkAF.js → arc-COAT3laO.js} +1 -1
  71. package/workframe-ui/public/assets/architecture-7EHR7CIX-DUyH3hWG.js +1 -0
  72. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-XnBRKeW0.js → architectureDiagram-3BPJPVTR-BFjWV24l.js} +1 -1
  73. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-VYHUfVhd.js → blockDiagram-GPEHLZMM-DSQLPfrj.js} +1 -1
  74. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BTjUcJpm.js → c4Diagram-AAUBKEIU-DKEHv1t2.js} +1 -1
  75. package/workframe-ui/public/assets/channel-g7r_RGaY.js +1 -0
  76. package/workframe-ui/public/assets/{chunk-2J33WTMH-w7uu7R-b.js → chunk-2J33WTMH-DHZg-DUi.js} +1 -1
  77. package/workframe-ui/public/assets/{chunk-3OPIFGDE-Cb9LtnDX.js → chunk-3OPIFGDE-BB-OYTfp.js} +1 -1
  78. package/workframe-ui/public/assets/{chunk-4BX2VUAB-DiQ-qCwH.js → chunk-4BX2VUAB-C93q0YIm.js} +1 -1
  79. package/workframe-ui/public/assets/{chunk-55IACEB6-C-mLFr7z.js → chunk-55IACEB6-MAYniqik.js} +1 -1
  80. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-DOesfiCI.js → chunk-5ZQYHXKU-ChgN6YJs.js} +1 -1
  81. package/workframe-ui/public/assets/{chunk-727SXJPM-BJ3oBZuz.js → chunk-727SXJPM-B_FYwdAv.js} +1 -1
  82. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-CCA6xpGs.js → chunk-AQP2D5EJ-1_Hw_h1A.js} +1 -1
  83. package/workframe-ui/public/assets/{chunk-BSJP7CBP-a0cMNFb2.js → chunk-BSJP7CBP-CFiDQ1Rv.js} +1 -1
  84. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-kuqN8EIY.js → chunk-CSCIHK7Q-DZ9UMTlB.js} +1 -1
  85. package/workframe-ui/public/assets/{chunk-FMBD7UC4-DyPgYHCg.js → chunk-FMBD7UC4-DlMlyFgw.js} +1 -1
  86. package/workframe-ui/public/assets/{chunk-KSCS5N6A-CdUuvR0V.js → chunk-KSCS5N6A-DHXtQ_Hf.js} +1 -1
  87. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-Dq9NoWmK.js → chunk-L5ZTLDWV-CuQzg-QG.js} +1 -1
  88. package/workframe-ui/public/assets/{chunk-LZXEDZCA-p74rddlO.js → chunk-LZXEDZCA-BHzjzCGg.js} +2 -2
  89. package/workframe-ui/public/assets/{chunk-ND2GUHAM-DBD2u1Gz.js → chunk-ND2GUHAM-DHXx05n2.js} +1 -1
  90. package/workframe-ui/public/assets/{chunk-NZK2D7GU-BeIeYFnd.js → chunk-NZK2D7GU-CV5pmDM_.js} +1 -1
  91. package/workframe-ui/public/assets/{chunk-O5CBEL6O-ClHc56ib.js → chunk-O5CBEL6O-6tkCHxsV.js} +1 -1
  92. package/workframe-ui/public/assets/chunk-QZHKN3VN-C5UQehWY.js +1 -0
  93. package/workframe-ui/public/assets/chunk-WU5MYG2G-DhWllrI8.js +1 -0
  94. package/workframe-ui/public/assets/{chunk-XPW4576I-EFr8R_1p.js → chunk-XPW4576I-BClwIiCp.js} +1 -1
  95. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BBM_8T8E.js +1 -0
  96. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BBM_8T8E.js +1 -0
  97. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-C7aPBODd.js → cose-bilkent-S5V4N54A-DOrGV6DQ.js} +1 -1
  98. package/workframe-ui/public/assets/{dagre-BM42HDAG-BdU1Rv-H.js → dagre-BM42HDAG-DXTPvJkX.js} +1 -1
  99. package/workframe-ui/public/assets/{diagram-2AECGRRQ-DWowSo85.js → diagram-2AECGRRQ-xX_v-pbf.js} +1 -1
  100. package/workframe-ui/public/assets/{diagram-5GNKFQAL-MnxBbceO.js → diagram-5GNKFQAL-Cd2pXbBe.js} +1 -1
  101. package/workframe-ui/public/assets/{diagram-KO2AKTUF-DQaLRXFf.js → diagram-KO2AKTUF-Df3XvUtk.js} +1 -1
  102. package/workframe-ui/public/assets/{diagram-LMA3HP47-CQaBud9k.js → diagram-LMA3HP47-CsijIPaD.js} +1 -1
  103. package/workframe-ui/public/assets/{diagram-OG6HWLK6-D8bAXbY9.js → diagram-OG6HWLK6-aq5fmfHd.js} +1 -1
  104. package/workframe-ui/public/assets/{dist-DGpTLHr_.js → dist-D1c0mkbB.js} +1 -1
  105. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-1E-xSvBK.js → erDiagram-TEJ5UH35-DnFysVRY.js} +1 -1
  106. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Ci8mdb44.js +1 -0
  107. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-CgOVD5hu.js → flowDiagram-I6XJVG4X-C6Ebi3su.js} +1 -1
  108. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-JFYAIauo.js → ganttDiagram-6RSMTGT7-BQXQtUpa.js} +1 -1
  109. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-B9REenIl.js → gitGraph-WXDBUCRP-Dt0zIs_M.js} +1 -1
  110. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-BQ7NcMSn.js → gitGraphDiagram-PVQCEYII-BF8gHzRn.js} +1 -1
  111. package/workframe-ui/public/assets/index-DpoUZAxh.css +1 -0
  112. package/workframe-ui/public/assets/{index-Dnw6vjqb.js → index-lRpzpNPT.js} +2 -2
  113. package/workframe-ui/public/assets/{info-J43DQDTF-CL6-eTjH.js → info-J43DQDTF-CSmszQJT.js} +1 -1
  114. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-LJTODW4W.js → infoDiagram-5YYISTIA-CVTKGW6p.js} +1 -1
  115. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-bchrQVuo.js → ishikawaDiagram-YF4QCWOH-Z8pT09Lv.js} +1 -1
  116. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-DkrvYuxP.js → journeyDiagram-JHISSGLW-r3wD68_T.js} +1 -1
  117. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-DFRbj0IG.js → kanban-definition-UN3LZRKU-Il8VglqN.js} +1 -1
  118. package/workframe-ui/public/assets/{line-Vd48P7-O.js → line-oyjpfz2A.js} +1 -1
  119. package/workframe-ui/public/assets/{linear-Ckizh2G7.js → linear-Cf7p5tVp.js} +1 -1
  120. package/workframe-ui/public/assets/{mermaid-parser.core-Bkimsnqj.js → mermaid-parser.core-YmbZ-AfY.js} +2 -2
  121. package/workframe-ui/public/assets/{mermaid.core-x0TvVuPo.js → mermaid.core-BFdCAqCo.js} +3 -3
  122. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-6ykAFPEz.js → mindmap-definition-RKZ34NQL-Cy2iCtEl.js} +1 -1
  123. package/workframe-ui/public/assets/{packet-YPE3B663-Dw3xgMDt.js → packet-YPE3B663-DwOBZL6K.js} +1 -1
  124. package/workframe-ui/public/assets/{pie-LRSECV5Y-DATysawG.js → pie-LRSECV5Y-04PPhnKK.js} +1 -1
  125. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-SJKD1S0S.js → pieDiagram-4H26LBE5-LxIpgHqi.js} +1 -1
  126. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-BrYDZX8q.js → quadrantDiagram-W4KKPZXB-0nBYfYm4.js} +1 -1
  127. package/workframe-ui/public/assets/{radar-GUYGQ44K-BmWYPCds.js → radar-GUYGQ44K-D2-vBqps.js} +1 -1
  128. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DwL9Mc8e.js → requirementDiagram-4Y6WPE33-DbuU0nlu.js} +1 -1
  129. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DYIFsL8h.js → sankeyDiagram-5OEKKPKP-B2hQ6B2x.js} +1 -1
  130. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-0-FPkFk8.js → sequenceDiagram-3UESZ5HK-BBrU30e1.js} +1 -1
  131. package/workframe-ui/public/assets/{src-B_od6b6h.js → src-BJEDmV70.js} +1 -1
  132. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BQCiBk6u.js → stateDiagram-AJRCARHV-7FGO4kkH.js} +1 -1
  133. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-DLTSizMg.js +1 -0
  134. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DS3tFcXj.js → timeline-definition-PNZ67QCA-ptDm4rCN.js} +1 -1
  135. package/workframe-ui/public/assets/{treeView-BLDUP644-DSyUCKLY.js → treeView-BLDUP644-CS6Z-0q8.js} +1 -1
  136. package/workframe-ui/public/assets/{treemap-LRROVOQU-CEZaNh5Y.js → treemap-LRROVOQU-DqV4Y2VA.js} +1 -1
  137. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CD-Vc9NF.js → vennDiagram-CIIHVFJN-C0UrZJYt.js} +1 -1
  138. package/workframe-ui/public/assets/{wardley-L42UT6IY-Drq5w1Mc.js → wardley-L42UT6IY-bNDN3_Sa.js} +1 -1
  139. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DouXDJoF.js → wardleyDiagram-YWT4CUSO-jWiJsefM.js} +1 -1
  140. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DDf_Lol5.js → xychartDiagram-2RQKCTM6-Dsh_fLCy.js} +1 -1
  141. package/workframe-ui/public/favicon.svg +7 -7
  142. package/workframe-ui/public/index.html +50 -50
  143. package/workframe-ui/public/workframe-config.json +3 -3
  144. package/scripts/security_audit.py +0 -156
  145. package/scripts/test-scaffold.mjs +0 -390
  146. package/workframe-api/tests/__init__.py +0 -0
  147. package/workframe-api/tests/db_setup.py +0 -13
  148. package/workframe-api/tests/test_admin_updates_gated.py +0 -30
  149. package/workframe-api/tests/test_agent_dm_bootstrap.py +0 -196
  150. package/workframe-api/tests/test_agent_profile_sync.py +0 -76
  151. package/workframe-api/tests/test_auth_email.py +0 -222
  152. package/workframe-api/tests/test_auth_hole_fix_selfcheck.py +0 -99
  153. package/workframe-api/tests/test_auth_rate_limit.py +0 -19
  154. package/workframe-api/tests/test_avatar_resolve.py +0 -77
  155. package/workframe-api/tests/test_child_soul_template.py +0 -71
  156. package/workframe-api/tests/test_credential_canary.py +0 -135
  157. package/workframe-api/tests/test_credential_isolation.py +0 -448
  158. package/workframe-api/tests/test_credential_resolution.py +0 -206
  159. package/workframe-api/tests/test_device_oauth.py +0 -108
  160. package/workframe-api/tests/test_doctor_repair.py +0 -103
  161. package/workframe-api/tests/test_ensure_profile_api.py +0 -77
  162. package/workframe-api/tests/test_gateway_compose_security.py +0 -136
  163. package/workframe-api/tests/test_install_secure_host.py +0 -39
  164. package/workframe-api/tests/test_internal_proxy_auth.py +0 -125
  165. package/workframe-api/tests/test_invite_runtime_bootstrap.py +0 -72
  166. package/workframe-api/tests/test_kanban_delegation.py +0 -185
  167. package/workframe-api/tests/test_llm_proxy.py +0 -155
  168. package/workframe-api/tests/test_login_access_policy.py +0 -183
  169. package/workframe-api/tests/test_mvp_model_bootstrap.py +0 -75
  170. package/workframe-api/tests/test_onboarding_bootstrap.py +0 -248
  171. package/workframe-api/tests/test_platform_auth.py +0 -47
  172. package/workframe-api/tests/test_profile_config_path.py +0 -56
  173. package/workframe-api/tests/test_profile_config_yaml_repair.py +0 -63
  174. package/workframe-api/tests/test_profile_create.py +0 -72
  175. package/workframe-api/tests/test_profile_identity_overlay.py +0 -61
  176. package/workframe-api/tests/test_profile_install_health.py +0 -45
  177. package/workframe-api/tests/test_profile_secret_policy.py +0 -57
  178. package/workframe-api/tests/test_profile_workspace_cwd.py +0 -34
  179. package/workframe-api/tests/test_provider_bootstrap.py +0 -75
  180. package/workframe-api/tests/test_provider_connect.py +0 -54
  181. package/workframe-api/tests/test_room_crud.py +0 -192
  182. package/workframe-api/tests/test_room_tenancy.py +0 -701
  183. package/workframe-api/tests/test_runtime_identity_backfill.py +0 -34
  184. package/workframe-api/tests/test_site_meta.py +0 -81
  185. package/workframe-api/tests/test_soul_stub.py +0 -42
  186. package/workframe-api/tests/test_space_member_sync.py +0 -99
  187. package/workframe-api/tests/test_stripe_stack_config.py +0 -37
  188. package/workframe-api/tests/test_supervisor_lifecycle.py +0 -52
  189. package/workframe-api/tests/test_turn_credential_vault.py +0 -125
  190. package/workframe-api/tests/test_updates.py +0 -176
  191. package/workframe-api/tests/test_user_cohort.py +0 -113
  192. package/workframe-api/tests/test_vault_envelope.py +0 -110
  193. package/workframe-api/tests/test_workspace_members.py +0 -183
  194. package/workframe-api/tests/test_workspace_messaging_sync.py +0 -125
  195. package/workframe-api/tests/test_workspace_provider_list.py +0 -57
  196. package/workframe-supervisor/tests/test_exec_guard.py +0 -42
  197. package/workframe-supervisor/tests/test_server_import.py +0 -21
  198. package/workframe-ui/public/assets/architecture-7EHR7CIX-CtbQKTuT.js +0 -1
  199. package/workframe-ui/public/assets/channel-Dy4Z4-jn.js +0 -1
  200. package/workframe-ui/public/assets/chunk-QZHKN3VN-CtBEchFK.js +0 -1
  201. package/workframe-ui/public/assets/chunk-WU5MYG2G-B9pBtriN.js +0 -1
  202. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BMAEA8jI.js +0 -1
  203. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BMAEA8jI.js +0 -1
  204. package/workframe-ui/public/assets/eventmodeling-FCH6USID-D75cstNT.js +0 -1
  205. package/workframe-ui/public/assets/index-DpAGxump.css +0 -1
  206. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-B89jAMFF.js +0 -1
@@ -1,445 +1,445 @@
1
- """API-only credential vault — envelope-encrypted secrets (KEK + per-secret DEK)."""
2
-
3
- from __future__ import annotations
4
-
5
- import base64
6
- import json
7
- import os
8
- import secrets
9
- import sqlite3
10
- import time
11
- from pathlib import Path
12
- from typing import Any
13
-
14
- import vault_kek
15
- import zk_auth
16
-
17
- DATA_DIR = Path(os.environ.get("WORKFRAME_API_DATA_DIR", "/app/data"))
18
- VAULT_DB = DATA_DIR / "credential_vault.db"
19
- LEGACY_V1 = 1
20
- ENVELOPE_V2 = 2
21
-
22
-
23
- def _connect() -> sqlite3.Connection:
24
- DATA_DIR.mkdir(parents=True, exist_ok=True)
25
- conn = sqlite3.connect(str(VAULT_DB), timeout=5.0)
26
- conn.execute("PRAGMA foreign_keys = ON")
27
- conn.row_factory = sqlite3.Row
28
- return conn
29
-
30
-
31
- def _meta_row(conn: sqlite3.Connection) -> sqlite3.Row | None:
32
- return conn.execute("SELECT * FROM vault_meta WHERE id = 1").fetchone()
33
-
34
-
35
- # ponytail: ran on every read_secret/store (and again via turn_credentials) — ~11ms/call on
36
- # bind-mounted sqlite. Guard by DB path: once per process, tests reassign VAULT_DB → re-run.
37
- _SCHEMA_READY: set[str] = set()
38
-
39
-
40
- def ensure_schema() -> None:
41
- key = str(VAULT_DB)
42
- if key in _SCHEMA_READY:
43
- return
44
- conn = _connect()
45
- try:
46
- conn.execute(
47
- """
48
- CREATE TABLE IF NOT EXISTS credential_secrets (
49
- binding_id TEXT PRIMARY KEY,
50
- encrypted_secret TEXT NOT NULL,
51
- env_var TEXT NOT NULL DEFAULT '',
52
- provider TEXT NOT NULL DEFAULT '',
53
- scope TEXT NOT NULL DEFAULT 'user',
54
- user_id TEXT DEFAULT NULL,
55
- workspace_id TEXT DEFAULT NULL,
56
- created_at TEXT NOT NULL,
57
- updated_at TEXT NOT NULL
58
- )
59
- """
60
- )
61
- conn.execute(
62
- "CREATE INDEX IF NOT EXISTS idx_credential_secrets_user "
63
- "ON credential_secrets(user_id, provider)"
64
- )
65
- conn.execute(
66
- """
67
- CREATE TABLE IF NOT EXISTS vault_meta (
68
- id INTEGER PRIMARY KEY CHECK (id = 1),
69
- initialized INTEGER NOT NULL DEFAULT 0,
70
- passphrase_enabled INTEGER NOT NULL DEFAULT 0,
71
- kdf_salt TEXT DEFAULT NULL,
72
- wrapped_kek TEXT DEFAULT NULL,
73
- created_at TEXT NOT NULL,
74
- updated_at TEXT NOT NULL
75
- )
76
- """
77
- )
78
- if not _meta_row(conn):
79
- now = str(int(time.time()))
80
- conn.execute(
81
- """
82
- INSERT INTO vault_meta (id, initialized, passphrase_enabled, created_at, updated_at)
83
- VALUES (1, 0, 0, ?, ?)
84
- """,
85
- (now, now),
86
- )
87
- conn.commit()
88
- finally:
89
- conn.close()
90
- _SCHEMA_READY.add(key)
91
-
92
-
93
- def vault_status() -> dict[str, Any]:
94
- ensure_schema()
95
- conn = _connect()
96
- try:
97
- meta = _meta_row(conn)
98
- count = int(conn.execute("SELECT COUNT(*) FROM credential_secrets").fetchone()[0])
99
- finally:
100
- conn.close()
101
- initialized = bool(meta and int(meta["initialized"] or 0))
102
- return {
103
- "sealed": not vault_kek.kek_in_memory(),
104
- "initialized": initialized,
105
- "passphrase_enabled": bool(meta and int(meta["passphrase_enabled"] or 0)),
106
- "secret_count": count,
107
- "kek_file_present": vault_kek.VAULT_KEK_FILE.is_file(),
108
- "env_kek_configured": bool(os.environ.get("WORKFRAME_VAULT_KEK", "").strip()),
109
- }
110
-
111
-
112
- def _require_unsealed() -> None:
113
- if not vault_kek.kek_in_memory():
114
- raise RuntimeError("vault_sealed")
115
-
116
-
117
- def _encrypt_dek_with_kek(dek: bytes) -> dict[str, str]:
118
- kek = vault_kek.get_kek()
119
- iv = os.urandom(12)
120
- from cryptography.hazmat.primitives.ciphers.aead import AESGCM
121
-
122
- ct = AESGCM(kek).encrypt(iv, dek, None)
123
- return {
124
- "iv": base64.b64encode(iv).decode("ascii"),
125
- "tag": base64.b64encode(ct[-16:]).decode("ascii"),
126
- "ciphertext": base64.b64encode(ct[:-16]).decode("ascii"),
127
- }
128
-
129
-
130
- def _decrypt_dek_with_kek(wrapped: dict[str, Any]) -> bytes:
131
- kek = vault_kek.get_kek()
132
- from cryptography.hazmat.primitives.ciphers.aead import AESGCM
133
-
134
- iv = base64.b64decode(str(wrapped["iv"]))
135
- tag = base64.b64decode(str(wrapped["tag"]))
136
- ct = base64.b64decode(str(wrapped["ciphertext"]))
137
- dek = AESGCM(kek).decrypt(iv, ct + tag, None)
138
- if len(dek) != 32:
139
- raise ValueError("invalid DEK length")
140
- return dek
141
-
142
-
143
- def _encrypt_v2(secret: str) -> str:
144
- dek = os.urandom(32)
145
- dek_b64 = base64.b64encode(dek).decode("ascii")
146
- payload = zk_auth.encrypt_string(str(secret or ""), dek_b64)
147
- envelope = {
148
- "v": ENVELOPE_V2,
149
- "alg": "envelope-aes-gcm",
150
- "wrapped_dek": _encrypt_dek_with_kek(dek),
151
- "payload": payload,
152
- }
153
- return json.dumps(envelope)
154
-
155
-
156
- def _decrypt_v2(blob: str) -> str:
157
- envelope = json.loads(blob)
158
- if int(envelope.get("v") or 0) != ENVELOPE_V2:
159
- raise ValueError("not v2 envelope")
160
- dek_b64 = base64.b64encode(_decrypt_dek_with_kek(envelope["wrapped_dek"])).decode("ascii")
161
- return zk_auth.decrypt_string(envelope["payload"], dek_b64)
162
-
163
-
164
- def _decrypt_legacy_v1(blob: str) -> str:
165
- payload = json.loads(blob) if isinstance(blob, str) else blob
166
- if not isinstance(payload, dict):
167
- return ""
168
- if int(payload.get("v") or 0) == ENVELOPE_V2:
169
- return _decrypt_v2(blob)
170
- return zk_auth.decrypt_string(payload, zk_auth.ZK_AUTH_ENCRYPTION_KEY)
171
-
172
-
173
- def _encrypt(secret: str) -> str:
174
- _require_unsealed()
175
- return _encrypt_v2(secret)
176
-
177
-
178
- def _decrypt(blob: str) -> str:
179
- if not str(blob or "").strip():
180
- return ""
181
- try:
182
- parsed = json.loads(blob)
183
- except json.JSONDecodeError:
184
- return ""
185
- version = int(parsed.get("v") or LEGACY_V1) if isinstance(parsed, dict) else LEGACY_V1
186
- if version == ENVELOPE_V2:
187
- if not vault_kek.kek_in_memory():
188
- return ""
189
- return _decrypt_v2(blob)
190
- return _decrypt_legacy_v1(blob)
191
-
192
-
193
- def _mark_initialized(passphrase_enabled: bool = False, salt_b64: str = "", wrapped: str = "") -> None:
194
- now = str(int(time.time()))
195
- conn = _connect()
196
- try:
197
- conn.execute(
198
- """
199
- UPDATE vault_meta
200
- SET initialized = 1,
201
- passphrase_enabled = ?,
202
- kdf_salt = ?,
203
- wrapped_kek = ?,
204
- updated_at = ?
205
- WHERE id = 1
206
- """,
207
- (1 if passphrase_enabled else 0, salt_b64 or None, wrapped or None, now),
208
- )
209
- conn.commit()
210
- finally:
211
- conn.close()
212
-
213
-
214
- def bootstrap_vault(*, allow_generate_file: bool = True) -> dict[str, Any]:
215
- """Load KEK from env or .vault_kek; optionally generate file on first boot."""
216
- ensure_schema()
217
- if vault_kek.kek_in_memory():
218
- return vault_status()
219
- if vault_kek.load_kek_from_env():
220
- _mark_initialized(passphrase_enabled=False)
221
- return vault_status()
222
- if vault_kek.load_kek_from_file():
223
- _mark_initialized(passphrase_enabled=False)
224
- return vault_status()
225
- if allow_generate_file and not vault_status()["initialized"]:
226
- vault_kek.generate_and_persist_kek()
227
- _mark_initialized(passphrase_enabled=False)
228
- return vault_status()
229
- return vault_status()
230
-
231
-
232
- def init_vault_passphrase(passphrase: str) -> dict[str, Any]:
233
- ensure_schema()
234
- status = vault_status()
235
- if status["passphrase_enabled"]:
236
- raise ValueError("vault_passphrase_already_set")
237
- if not vault_kek.kek_in_memory():
238
- if not vault_kek.load_kek_from_env() and not vault_kek.load_kek_from_file():
239
- vault_kek.generate_and_persist_kek()
240
- salt, wrapped = vault_kek.wrap_kek_for_passphrase(passphrase)
241
- _mark_initialized(passphrase_enabled=True, salt_b64=salt, wrapped=wrapped)
242
- _reencrypt_all_secrets()
243
- return vault_status()
244
-
245
-
246
- def unlock_vault(passphrase: str) -> dict[str, Any]:
247
- ensure_schema()
248
- conn = _connect()
249
- try:
250
- meta = _meta_row(conn)
251
- finally:
252
- conn.close()
253
- if not meta or not int(meta["passphrase_enabled"] or 0):
254
- raise ValueError("vault_passphrase_not_configured")
255
- kek = vault_kek.unwrap_kek_from_passphrase(
256
- passphrase,
257
- str(meta["kdf_salt"] or ""),
258
- str(meta["wrapped_kek"] or ""),
259
- )
260
- vault_kek.set_kek(kek)
261
- return vault_status()
262
-
263
-
264
- def seal_vault() -> dict[str, Any]:
265
- vault_kek.clear_kek()
266
- return vault_status()
267
-
268
-
269
- def wipe_all_secrets() -> int:
270
- """Emergency delete — ciphertext only; bindings remain but secrets are gone."""
271
- ensure_schema()
272
- conn = _connect()
273
- try:
274
- cur = conn.execute("DELETE FROM credential_secrets")
275
- conn.commit()
276
- return int(cur.rowcount)
277
- finally:
278
- conn.close()
279
-
280
-
281
- def _reencrypt_all_secrets() -> int:
282
- _require_unsealed()
283
- conn = _connect()
284
- migrated = 0
285
- try:
286
- rows = conn.execute(
287
- "SELECT binding_id, encrypted_secret FROM credential_secrets"
288
- ).fetchall()
289
- now = str(int(time.time()))
290
- for row in rows:
291
- plain = _decrypt(str(row["encrypted_secret"] or ""))
292
- if not plain:
293
- continue
294
- enc = _encrypt_v2(plain)
295
- conn.execute(
296
- "UPDATE credential_secrets SET encrypted_secret = ?, updated_at = ? WHERE binding_id = ?",
297
- (enc, now, str(row["binding_id"])),
298
- )
299
- migrated += 1
300
- conn.commit()
301
- finally:
302
- conn.close()
303
- return migrated
304
-
305
-
306
- def store_secret(
307
- binding_id: str,
308
- secret: str,
309
- *,
310
- env_var: str = "",
311
- provider: str = "",
312
- scope: str = "user",
313
- user_id: str = "",
314
- workspace_id: str = "",
315
- ) -> None:
316
- binding_id = str(binding_id or "").strip()
317
- if not binding_id:
318
- raise ValueError("binding_id required")
319
- if not str(secret or "").strip():
320
- raise ValueError("secret required")
321
- if not vault_kek.kek_in_memory():
322
- status = vault_status()
323
- if status["passphrase_enabled"]:
324
- raise RuntimeError("vault_sealed")
325
- bootstrap_vault(allow_generate_file=True)
326
- _require_unsealed()
327
- ensure_schema()
328
- now = str(int(time.time()))
329
- enc = _encrypt(secret)
330
- conn = _connect()
331
- try:
332
- conn.execute(
333
- """
334
- INSERT INTO credential_secrets (
335
- binding_id, encrypted_secret, env_var, provider, scope,
336
- user_id, workspace_id, created_at, updated_at
337
- ) VALUES (?,?,?,?,?,?,?,?,?)
338
- ON CONFLICT(binding_id) DO UPDATE SET
339
- encrypted_secret = excluded.encrypted_secret,
340
- env_var = excluded.env_var,
341
- provider = excluded.provider,
342
- scope = excluded.scope,
343
- user_id = excluded.user_id,
344
- workspace_id = excluded.workspace_id,
345
- updated_at = excluded.updated_at
346
- """,
347
- (
348
- binding_id,
349
- enc,
350
- str(env_var or ""),
351
- str(provider or ""),
352
- str(scope or "user"),
353
- str(user_id or "") or None,
354
- str(workspace_id or "") or None,
355
- now,
356
- now,
357
- ),
358
- )
359
- conn.commit()
360
- finally:
361
- conn.close()
362
-
363
-
364
- def read_secret(binding_id: str) -> str:
365
- binding_id = str(binding_id or "").strip()
366
- if not binding_id:
367
- return ""
368
- ensure_schema()
369
- if not vault_kek.kek_in_memory():
370
- status = vault_status()
371
- if status["passphrase_enabled"]:
372
- return ""
373
- bootstrap_vault(allow_generate_file=True)
374
- conn = _connect()
375
- try:
376
- row = conn.execute(
377
- "SELECT encrypted_secret FROM credential_secrets WHERE binding_id = ?",
378
- (binding_id,),
379
- ).fetchone()
380
- if not row:
381
- return ""
382
- blob = str(row["encrypted_secret"] or "")
383
- plain = _decrypt(blob)
384
- if not plain or not vault_kek.kek_in_memory():
385
- return plain
386
- try:
387
- parsed = json.loads(blob)
388
- if isinstance(parsed, dict) and int(parsed.get("v") or 0) != ENVELOPE_V2:
389
- enc = _encrypt_v2(plain)
390
- now = str(int(time.time()))
391
- conn.execute(
392
- "UPDATE credential_secrets SET encrypted_secret = ?, updated_at = ? WHERE binding_id = ?",
393
- (enc, now, binding_id),
394
- )
395
- conn.commit()
396
- except json.JSONDecodeError:
397
- pass
398
- return plain
399
- finally:
400
- conn.close()
401
-
402
-
403
- def delete_secret(binding_id: str) -> None:
404
- binding_id = str(binding_id or "").strip()
405
- if not binding_id:
406
- return
407
- ensure_schema()
408
- conn = _connect()
409
- try:
410
- conn.execute("DELETE FROM credential_secrets WHERE binding_id = ?", (binding_id,))
411
- conn.commit()
412
- finally:
413
- conn.close()
414
-
415
-
416
- def vault_ref(binding_id: str) -> str:
417
- return f"vault:{binding_id}"
418
-
419
-
420
- def unseal_for_tests() -> None:
421
- """Deterministic KEK for unit tests."""
422
- vault_kek.unseal_for_tests()
423
-
424
-
425
- def parse_vault_ref(credential_ref: str) -> str:
426
- ref = str(credential_ref or "").strip()
427
- if ref.startswith("vault:"):
428
- return ref[6:].strip()
429
- return ""
430
-
431
-
432
- if __name__ == "__main__":
433
- vault_kek.unseal_for_tests()
434
- ensure_schema()
435
- bid = "__selfcheck__"
436
- store_secret(bid, "sk-test", env_var="OPENROUTER_API_KEY", provider="openrouter")
437
- assert read_secret(bid) == "sk-test"
438
- blob = _connect().execute(
439
- "SELECT encrypted_secret FROM credential_secrets WHERE binding_id = ?",
440
- (bid,),
441
- ).fetchone()["encrypted_secret"]
442
- assert '"v": 2' in blob or '"v":2' in blob.replace(" ", "")
443
- delete_secret(bid)
444
- assert read_secret(bid) == ""
445
- print("credential_vault ok")
1
+ """API-only credential vault — envelope-encrypted secrets (KEK + per-secret DEK)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import os
8
+ import secrets
9
+ import sqlite3
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import vault_kek
15
+ import zk_auth
16
+
17
+ DATA_DIR = Path(os.environ.get("WORKFRAME_API_DATA_DIR", "/app/data"))
18
+ VAULT_DB = DATA_DIR / "credential_vault.db"
19
+ LEGACY_V1 = 1
20
+ ENVELOPE_V2 = 2
21
+
22
+
23
+ def _connect() -> sqlite3.Connection:
24
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
25
+ conn = sqlite3.connect(str(VAULT_DB), timeout=5.0)
26
+ conn.execute("PRAGMA foreign_keys = ON")
27
+ conn.row_factory = sqlite3.Row
28
+ return conn
29
+
30
+
31
+ def _meta_row(conn: sqlite3.Connection) -> sqlite3.Row | None:
32
+ return conn.execute("SELECT * FROM vault_meta WHERE id = 1").fetchone()
33
+
34
+
35
+ # ponytail: ran on every read_secret/store (and again via turn_credentials) — ~11ms/call on
36
+ # bind-mounted sqlite. Guard by DB path: once per process, tests reassign VAULT_DB → re-run.
37
+ _SCHEMA_READY: set[str] = set()
38
+
39
+
40
+ def ensure_schema() -> None:
41
+ key = str(VAULT_DB)
42
+ if key in _SCHEMA_READY:
43
+ return
44
+ conn = _connect()
45
+ try:
46
+ conn.execute(
47
+ """
48
+ CREATE TABLE IF NOT EXISTS credential_secrets (
49
+ binding_id TEXT PRIMARY KEY,
50
+ encrypted_secret TEXT NOT NULL,
51
+ env_var TEXT NOT NULL DEFAULT '',
52
+ provider TEXT NOT NULL DEFAULT '',
53
+ scope TEXT NOT NULL DEFAULT 'user',
54
+ user_id TEXT DEFAULT NULL,
55
+ workspace_id TEXT DEFAULT NULL,
56
+ created_at TEXT NOT NULL,
57
+ updated_at TEXT NOT NULL
58
+ )
59
+ """
60
+ )
61
+ conn.execute(
62
+ "CREATE INDEX IF NOT EXISTS idx_credential_secrets_user "
63
+ "ON credential_secrets(user_id, provider)"
64
+ )
65
+ conn.execute(
66
+ """
67
+ CREATE TABLE IF NOT EXISTS vault_meta (
68
+ id INTEGER PRIMARY KEY CHECK (id = 1),
69
+ initialized INTEGER NOT NULL DEFAULT 0,
70
+ passphrase_enabled INTEGER NOT NULL DEFAULT 0,
71
+ kdf_salt TEXT DEFAULT NULL,
72
+ wrapped_kek TEXT DEFAULT NULL,
73
+ created_at TEXT NOT NULL,
74
+ updated_at TEXT NOT NULL
75
+ )
76
+ """
77
+ )
78
+ if not _meta_row(conn):
79
+ now = str(int(time.time()))
80
+ conn.execute(
81
+ """
82
+ INSERT INTO vault_meta (id, initialized, passphrase_enabled, created_at, updated_at)
83
+ VALUES (1, 0, 0, ?, ?)
84
+ """,
85
+ (now, now),
86
+ )
87
+ conn.commit()
88
+ finally:
89
+ conn.close()
90
+ _SCHEMA_READY.add(key)
91
+
92
+
93
+ def vault_status() -> dict[str, Any]:
94
+ ensure_schema()
95
+ conn = _connect()
96
+ try:
97
+ meta = _meta_row(conn)
98
+ count = int(conn.execute("SELECT COUNT(*) FROM credential_secrets").fetchone()[0])
99
+ finally:
100
+ conn.close()
101
+ initialized = bool(meta and int(meta["initialized"] or 0))
102
+ return {
103
+ "sealed": not vault_kek.kek_in_memory(),
104
+ "initialized": initialized,
105
+ "passphrase_enabled": bool(meta and int(meta["passphrase_enabled"] or 0)),
106
+ "secret_count": count,
107
+ "kek_file_present": vault_kek.VAULT_KEK_FILE.is_file(),
108
+ "env_kek_configured": bool(os.environ.get("WORKFRAME_VAULT_KEK", "").strip()),
109
+ }
110
+
111
+
112
+ def _require_unsealed() -> None:
113
+ if not vault_kek.kek_in_memory():
114
+ raise RuntimeError("vault_sealed")
115
+
116
+
117
+ def _encrypt_dek_with_kek(dek: bytes) -> dict[str, str]:
118
+ kek = vault_kek.get_kek()
119
+ iv = os.urandom(12)
120
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
121
+
122
+ ct = AESGCM(kek).encrypt(iv, dek, None)
123
+ return {
124
+ "iv": base64.b64encode(iv).decode("ascii"),
125
+ "tag": base64.b64encode(ct[-16:]).decode("ascii"),
126
+ "ciphertext": base64.b64encode(ct[:-16]).decode("ascii"),
127
+ }
128
+
129
+
130
+ def _decrypt_dek_with_kek(wrapped: dict[str, Any]) -> bytes:
131
+ kek = vault_kek.get_kek()
132
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
133
+
134
+ iv = base64.b64decode(str(wrapped["iv"]))
135
+ tag = base64.b64decode(str(wrapped["tag"]))
136
+ ct = base64.b64decode(str(wrapped["ciphertext"]))
137
+ dek = AESGCM(kek).decrypt(iv, ct + tag, None)
138
+ if len(dek) != 32:
139
+ raise ValueError("invalid DEK length")
140
+ return dek
141
+
142
+
143
+ def _encrypt_v2(secret: str) -> str:
144
+ dek = os.urandom(32)
145
+ dek_b64 = base64.b64encode(dek).decode("ascii")
146
+ payload = zk_auth.encrypt_string(str(secret or ""), dek_b64)
147
+ envelope = {
148
+ "v": ENVELOPE_V2,
149
+ "alg": "envelope-aes-gcm",
150
+ "wrapped_dek": _encrypt_dek_with_kek(dek),
151
+ "payload": payload,
152
+ }
153
+ return json.dumps(envelope)
154
+
155
+
156
+ def _decrypt_v2(blob: str) -> str:
157
+ envelope = json.loads(blob)
158
+ if int(envelope.get("v") or 0) != ENVELOPE_V2:
159
+ raise ValueError("not v2 envelope")
160
+ dek_b64 = base64.b64encode(_decrypt_dek_with_kek(envelope["wrapped_dek"])).decode("ascii")
161
+ return zk_auth.decrypt_string(envelope["payload"], dek_b64)
162
+
163
+
164
+ def _decrypt_legacy_v1(blob: str) -> str:
165
+ payload = json.loads(blob) if isinstance(blob, str) else blob
166
+ if not isinstance(payload, dict):
167
+ return ""
168
+ if int(payload.get("v") or 0) == ENVELOPE_V2:
169
+ return _decrypt_v2(blob)
170
+ return zk_auth.decrypt_string(payload, zk_auth.ZK_AUTH_ENCRYPTION_KEY)
171
+
172
+
173
+ def _encrypt(secret: str) -> str:
174
+ _require_unsealed()
175
+ return _encrypt_v2(secret)
176
+
177
+
178
+ def _decrypt(blob: str) -> str:
179
+ if not str(blob or "").strip():
180
+ return ""
181
+ try:
182
+ parsed = json.loads(blob)
183
+ except json.JSONDecodeError:
184
+ return ""
185
+ version = int(parsed.get("v") or LEGACY_V1) if isinstance(parsed, dict) else LEGACY_V1
186
+ if version == ENVELOPE_V2:
187
+ if not vault_kek.kek_in_memory():
188
+ return ""
189
+ return _decrypt_v2(blob)
190
+ return _decrypt_legacy_v1(blob)
191
+
192
+
193
+ def _mark_initialized(passphrase_enabled: bool = False, salt_b64: str = "", wrapped: str = "") -> None:
194
+ now = str(int(time.time()))
195
+ conn = _connect()
196
+ try:
197
+ conn.execute(
198
+ """
199
+ UPDATE vault_meta
200
+ SET initialized = 1,
201
+ passphrase_enabled = ?,
202
+ kdf_salt = ?,
203
+ wrapped_kek = ?,
204
+ updated_at = ?
205
+ WHERE id = 1
206
+ """,
207
+ (1 if passphrase_enabled else 0, salt_b64 or None, wrapped or None, now),
208
+ )
209
+ conn.commit()
210
+ finally:
211
+ conn.close()
212
+
213
+
214
+ def bootstrap_vault(*, allow_generate_file: bool = True) -> dict[str, Any]:
215
+ """Load KEK from env or .vault_kek; optionally generate file on first boot."""
216
+ ensure_schema()
217
+ if vault_kek.kek_in_memory():
218
+ return vault_status()
219
+ if vault_kek.load_kek_from_env():
220
+ _mark_initialized(passphrase_enabled=False)
221
+ return vault_status()
222
+ if vault_kek.load_kek_from_file():
223
+ _mark_initialized(passphrase_enabled=False)
224
+ return vault_status()
225
+ if allow_generate_file and not vault_status()["initialized"]:
226
+ vault_kek.generate_and_persist_kek()
227
+ _mark_initialized(passphrase_enabled=False)
228
+ return vault_status()
229
+ return vault_status()
230
+
231
+
232
+ def init_vault_passphrase(passphrase: str) -> dict[str, Any]:
233
+ ensure_schema()
234
+ status = vault_status()
235
+ if status["passphrase_enabled"]:
236
+ raise ValueError("vault_passphrase_already_set")
237
+ if not vault_kek.kek_in_memory():
238
+ if not vault_kek.load_kek_from_env() and not vault_kek.load_kek_from_file():
239
+ vault_kek.generate_and_persist_kek()
240
+ salt, wrapped = vault_kek.wrap_kek_for_passphrase(passphrase)
241
+ _mark_initialized(passphrase_enabled=True, salt_b64=salt, wrapped=wrapped)
242
+ _reencrypt_all_secrets()
243
+ return vault_status()
244
+
245
+
246
+ def unlock_vault(passphrase: str) -> dict[str, Any]:
247
+ ensure_schema()
248
+ conn = _connect()
249
+ try:
250
+ meta = _meta_row(conn)
251
+ finally:
252
+ conn.close()
253
+ if not meta or not int(meta["passphrase_enabled"] or 0):
254
+ raise ValueError("vault_passphrase_not_configured")
255
+ kek = vault_kek.unwrap_kek_from_passphrase(
256
+ passphrase,
257
+ str(meta["kdf_salt"] or ""),
258
+ str(meta["wrapped_kek"] or ""),
259
+ )
260
+ vault_kek.set_kek(kek)
261
+ return vault_status()
262
+
263
+
264
+ def seal_vault() -> dict[str, Any]:
265
+ vault_kek.clear_kek()
266
+ return vault_status()
267
+
268
+
269
+ def wipe_all_secrets() -> int:
270
+ """Emergency delete — ciphertext only; bindings remain but secrets are gone."""
271
+ ensure_schema()
272
+ conn = _connect()
273
+ try:
274
+ cur = conn.execute("DELETE FROM credential_secrets")
275
+ conn.commit()
276
+ return int(cur.rowcount)
277
+ finally:
278
+ conn.close()
279
+
280
+
281
+ def _reencrypt_all_secrets() -> int:
282
+ _require_unsealed()
283
+ conn = _connect()
284
+ migrated = 0
285
+ try:
286
+ rows = conn.execute(
287
+ "SELECT binding_id, encrypted_secret FROM credential_secrets"
288
+ ).fetchall()
289
+ now = str(int(time.time()))
290
+ for row in rows:
291
+ plain = _decrypt(str(row["encrypted_secret"] or ""))
292
+ if not plain:
293
+ continue
294
+ enc = _encrypt_v2(plain)
295
+ conn.execute(
296
+ "UPDATE credential_secrets SET encrypted_secret = ?, updated_at = ? WHERE binding_id = ?",
297
+ (enc, now, str(row["binding_id"])),
298
+ )
299
+ migrated += 1
300
+ conn.commit()
301
+ finally:
302
+ conn.close()
303
+ return migrated
304
+
305
+
306
+ def store_secret(
307
+ binding_id: str,
308
+ secret: str,
309
+ *,
310
+ env_var: str = "",
311
+ provider: str = "",
312
+ scope: str = "user",
313
+ user_id: str = "",
314
+ workspace_id: str = "",
315
+ ) -> None:
316
+ binding_id = str(binding_id or "").strip()
317
+ if not binding_id:
318
+ raise ValueError("binding_id required")
319
+ if not str(secret or "").strip():
320
+ raise ValueError("secret required")
321
+ if not vault_kek.kek_in_memory():
322
+ status = vault_status()
323
+ if status["passphrase_enabled"]:
324
+ raise RuntimeError("vault_sealed")
325
+ bootstrap_vault(allow_generate_file=True)
326
+ _require_unsealed()
327
+ ensure_schema()
328
+ now = str(int(time.time()))
329
+ enc = _encrypt(secret)
330
+ conn = _connect()
331
+ try:
332
+ conn.execute(
333
+ """
334
+ INSERT INTO credential_secrets (
335
+ binding_id, encrypted_secret, env_var, provider, scope,
336
+ user_id, workspace_id, created_at, updated_at
337
+ ) VALUES (?,?,?,?,?,?,?,?,?)
338
+ ON CONFLICT(binding_id) DO UPDATE SET
339
+ encrypted_secret = excluded.encrypted_secret,
340
+ env_var = excluded.env_var,
341
+ provider = excluded.provider,
342
+ scope = excluded.scope,
343
+ user_id = excluded.user_id,
344
+ workspace_id = excluded.workspace_id,
345
+ updated_at = excluded.updated_at
346
+ """,
347
+ (
348
+ binding_id,
349
+ enc,
350
+ str(env_var or ""),
351
+ str(provider or ""),
352
+ str(scope or "user"),
353
+ str(user_id or "") or None,
354
+ str(workspace_id or "") or None,
355
+ now,
356
+ now,
357
+ ),
358
+ )
359
+ conn.commit()
360
+ finally:
361
+ conn.close()
362
+
363
+
364
+ def read_secret(binding_id: str) -> str:
365
+ binding_id = str(binding_id or "").strip()
366
+ if not binding_id:
367
+ return ""
368
+ ensure_schema()
369
+ if not vault_kek.kek_in_memory():
370
+ status = vault_status()
371
+ if status["passphrase_enabled"]:
372
+ return ""
373
+ bootstrap_vault(allow_generate_file=True)
374
+ conn = _connect()
375
+ try:
376
+ row = conn.execute(
377
+ "SELECT encrypted_secret FROM credential_secrets WHERE binding_id = ?",
378
+ (binding_id,),
379
+ ).fetchone()
380
+ if not row:
381
+ return ""
382
+ blob = str(row["encrypted_secret"] or "")
383
+ plain = _decrypt(blob)
384
+ if not plain or not vault_kek.kek_in_memory():
385
+ return plain
386
+ try:
387
+ parsed = json.loads(blob)
388
+ if isinstance(parsed, dict) and int(parsed.get("v") or 0) != ENVELOPE_V2:
389
+ enc = _encrypt_v2(plain)
390
+ now = str(int(time.time()))
391
+ conn.execute(
392
+ "UPDATE credential_secrets SET encrypted_secret = ?, updated_at = ? WHERE binding_id = ?",
393
+ (enc, now, binding_id),
394
+ )
395
+ conn.commit()
396
+ except json.JSONDecodeError:
397
+ pass
398
+ return plain
399
+ finally:
400
+ conn.close()
401
+
402
+
403
+ def delete_secret(binding_id: str) -> None:
404
+ binding_id = str(binding_id or "").strip()
405
+ if not binding_id:
406
+ return
407
+ ensure_schema()
408
+ conn = _connect()
409
+ try:
410
+ conn.execute("DELETE FROM credential_secrets WHERE binding_id = ?", (binding_id,))
411
+ conn.commit()
412
+ finally:
413
+ conn.close()
414
+
415
+
416
+ def vault_ref(binding_id: str) -> str:
417
+ return f"vault:{binding_id}"
418
+
419
+
420
+ def unseal_for_tests() -> None:
421
+ """Deterministic KEK for unit tests."""
422
+ vault_kek.unseal_for_tests()
423
+
424
+
425
+ def parse_vault_ref(credential_ref: str) -> str:
426
+ ref = str(credential_ref or "").strip()
427
+ if ref.startswith("vault:"):
428
+ return ref[6:].strip()
429
+ return ""
430
+
431
+
432
+ if __name__ == "__main__":
433
+ vault_kek.unseal_for_tests()
434
+ ensure_schema()
435
+ bid = "__selfcheck__"
436
+ store_secret(bid, "sk-test", env_var="OPENROUTER_API_KEY", provider="openrouter")
437
+ assert read_secret(bid) == "sk-test"
438
+ blob = _connect().execute(
439
+ "SELECT encrypted_secret FROM credential_secrets WHERE binding_id = ?",
440
+ (bid,),
441
+ ).fetchone()["encrypted_secret"]
442
+ assert '"v": 2' in blob or '"v":2' in blob.replace(" ", "")
443
+ delete_secret(bid)
444
+ assert read_secret(bid) == ""
445
+ print("credential_vault ok")