create-workframe 0.1.0 → 0.1.2

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 (198) hide show
  1. package/LICENSE +201 -201
  2. package/NOTICE +12 -12
  3. package/README.md +8 -92
  4. package/SECURITY.md +38 -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/bundle-workframe-ui.mjs +3 -3
  28. package/scripts/ensure-compose-host-paths.mjs +51 -51
  29. package/scripts/lib/install-identity.mjs +212 -212
  30. package/scripts/set-compose-public-url.mjs +92 -92
  31. package/scripts/sync-canonical-to-package.mjs +27 -9
  32. package/shared/WORKFRAME_AGENT_LIBRARY.md +17 -17
  33. package/shared/WORKFRAME_AGENT_OPERATIONS.md +15 -15
  34. package/shared/WORKFRAME_AGENT_PACKS.json +18 -18
  35. package/shared/WORKFRAME_AGENT_PACKS.yaml +8 -8
  36. package/shared/WORKFRAME_SKILL_CURATION.md +4 -4
  37. package/workframe-api/README.md +26 -28
  38. package/workframe-api/action_proxy.py +131 -131
  39. package/workframe-api/auth_rate_limit.py +49 -49
  40. package/workframe-api/credential_vault.py +445 -445
  41. package/workframe-api/data/avatar-catalog.json +41 -41
  42. package/workframe-api/email_sender.py +220 -220
  43. package/workframe-api/google_auth.py +90 -90
  44. package/workframe-api/install_api.py +359 -359
  45. package/workframe-api/internal_proxy_auth.py +150 -150
  46. package/workframe-api/llm_proxy.py +277 -277
  47. package/workframe-api/oidc_jwt.py +108 -108
  48. package/workframe-api/package.json +12 -13
  49. package/workframe-api/public/assets/index-DPXu_lGn.css +1 -1
  50. package/workframe-api/public/assets/index-DYnLrCZZ.js +8 -8
  51. package/workframe-api/requirements.txt +2 -2
  52. package/workframe-api/site_meta.py +271 -271
  53. package/workframe-api/stack_config.py +427 -427
  54. package/workframe-api/time-bind-chat.py +99 -99
  55. package/workframe-api/turn_credentials.py +226 -226
  56. package/workframe-api/updates.py +417 -417
  57. package/workframe-api/vault_kek.py +159 -159
  58. package/workframe-api/zk_auth.py +633 -633
  59. package/workframe-supervisor/Dockerfile +11 -11
  60. package/workframe-supervisor/server.py +787 -787
  61. package/workframe-ui/docker/nginx.conf +85 -85
  62. package/workframe-ui/public/assets/{arc-CBDYvkAF.js → arc-COAT3laO.js} +1 -1
  63. package/workframe-ui/public/assets/architecture-7EHR7CIX-DUyH3hWG.js +1 -0
  64. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-XnBRKeW0.js → architectureDiagram-3BPJPVTR-BFjWV24l.js} +1 -1
  65. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-VYHUfVhd.js → blockDiagram-GPEHLZMM-DSQLPfrj.js} +1 -1
  66. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BTjUcJpm.js → c4Diagram-AAUBKEIU-DKEHv1t2.js} +1 -1
  67. package/workframe-ui/public/assets/channel-g7r_RGaY.js +1 -0
  68. package/workframe-ui/public/assets/{chunk-2J33WTMH-w7uu7R-b.js → chunk-2J33WTMH-DHZg-DUi.js} +1 -1
  69. package/workframe-ui/public/assets/{chunk-3OPIFGDE-Cb9LtnDX.js → chunk-3OPIFGDE-BB-OYTfp.js} +1 -1
  70. package/workframe-ui/public/assets/{chunk-4BX2VUAB-DiQ-qCwH.js → chunk-4BX2VUAB-C93q0YIm.js} +1 -1
  71. package/workframe-ui/public/assets/{chunk-55IACEB6-C-mLFr7z.js → chunk-55IACEB6-MAYniqik.js} +1 -1
  72. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-DOesfiCI.js → chunk-5ZQYHXKU-ChgN6YJs.js} +1 -1
  73. package/workframe-ui/public/assets/{chunk-727SXJPM-BJ3oBZuz.js → chunk-727SXJPM-B_FYwdAv.js} +1 -1
  74. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-CCA6xpGs.js → chunk-AQP2D5EJ-1_Hw_h1A.js} +1 -1
  75. package/workframe-ui/public/assets/{chunk-BSJP7CBP-a0cMNFb2.js → chunk-BSJP7CBP-CFiDQ1Rv.js} +1 -1
  76. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-kuqN8EIY.js → chunk-CSCIHK7Q-DZ9UMTlB.js} +1 -1
  77. package/workframe-ui/public/assets/{chunk-FMBD7UC4-DyPgYHCg.js → chunk-FMBD7UC4-DlMlyFgw.js} +1 -1
  78. package/workframe-ui/public/assets/{chunk-KSCS5N6A-CdUuvR0V.js → chunk-KSCS5N6A-DHXtQ_Hf.js} +1 -1
  79. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-Dq9NoWmK.js → chunk-L5ZTLDWV-CuQzg-QG.js} +1 -1
  80. package/workframe-ui/public/assets/{chunk-LZXEDZCA-p74rddlO.js → chunk-LZXEDZCA-BHzjzCGg.js} +2 -2
  81. package/workframe-ui/public/assets/{chunk-ND2GUHAM-DBD2u1Gz.js → chunk-ND2GUHAM-DHXx05n2.js} +1 -1
  82. package/workframe-ui/public/assets/{chunk-NZK2D7GU-BeIeYFnd.js → chunk-NZK2D7GU-CV5pmDM_.js} +1 -1
  83. package/workframe-ui/public/assets/{chunk-O5CBEL6O-ClHc56ib.js → chunk-O5CBEL6O-6tkCHxsV.js} +1 -1
  84. package/workframe-ui/public/assets/chunk-QZHKN3VN-C5UQehWY.js +1 -0
  85. package/workframe-ui/public/assets/chunk-WU5MYG2G-DhWllrI8.js +1 -0
  86. package/workframe-ui/public/assets/{chunk-XPW4576I-EFr8R_1p.js → chunk-XPW4576I-BClwIiCp.js} +1 -1
  87. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BBM_8T8E.js +1 -0
  88. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BBM_8T8E.js +1 -0
  89. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-C7aPBODd.js → cose-bilkent-S5V4N54A-DOrGV6DQ.js} +1 -1
  90. package/workframe-ui/public/assets/{dagre-BM42HDAG-BdU1Rv-H.js → dagre-BM42HDAG-DXTPvJkX.js} +1 -1
  91. package/workframe-ui/public/assets/{diagram-2AECGRRQ-DWowSo85.js → diagram-2AECGRRQ-xX_v-pbf.js} +1 -1
  92. package/workframe-ui/public/assets/{diagram-5GNKFQAL-MnxBbceO.js → diagram-5GNKFQAL-Cd2pXbBe.js} +1 -1
  93. package/workframe-ui/public/assets/{diagram-KO2AKTUF-DQaLRXFf.js → diagram-KO2AKTUF-Df3XvUtk.js} +1 -1
  94. package/workframe-ui/public/assets/{diagram-LMA3HP47-CQaBud9k.js → diagram-LMA3HP47-CsijIPaD.js} +1 -1
  95. package/workframe-ui/public/assets/{diagram-OG6HWLK6-D8bAXbY9.js → diagram-OG6HWLK6-aq5fmfHd.js} +1 -1
  96. package/workframe-ui/public/assets/{dist-DGpTLHr_.js → dist-D1c0mkbB.js} +1 -1
  97. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-1E-xSvBK.js → erDiagram-TEJ5UH35-DnFysVRY.js} +1 -1
  98. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Ci8mdb44.js +1 -0
  99. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-CgOVD5hu.js → flowDiagram-I6XJVG4X-C6Ebi3su.js} +1 -1
  100. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-JFYAIauo.js → ganttDiagram-6RSMTGT7-BQXQtUpa.js} +1 -1
  101. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-B9REenIl.js → gitGraph-WXDBUCRP-Dt0zIs_M.js} +1 -1
  102. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-BQ7NcMSn.js → gitGraphDiagram-PVQCEYII-BF8gHzRn.js} +1 -1
  103. package/workframe-ui/public/assets/index-DpoUZAxh.css +1 -0
  104. package/workframe-ui/public/assets/{index-Dnw6vjqb.js → index-lRpzpNPT.js} +2 -2
  105. package/workframe-ui/public/assets/{info-J43DQDTF-CL6-eTjH.js → info-J43DQDTF-CSmszQJT.js} +1 -1
  106. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-LJTODW4W.js → infoDiagram-5YYISTIA-CVTKGW6p.js} +1 -1
  107. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-bchrQVuo.js → ishikawaDiagram-YF4QCWOH-Z8pT09Lv.js} +1 -1
  108. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-DkrvYuxP.js → journeyDiagram-JHISSGLW-r3wD68_T.js} +1 -1
  109. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-DFRbj0IG.js → kanban-definition-UN3LZRKU-Il8VglqN.js} +1 -1
  110. package/workframe-ui/public/assets/{line-Vd48P7-O.js → line-oyjpfz2A.js} +1 -1
  111. package/workframe-ui/public/assets/{linear-Ckizh2G7.js → linear-Cf7p5tVp.js} +1 -1
  112. package/workframe-ui/public/assets/{mermaid-parser.core-Bkimsnqj.js → mermaid-parser.core-YmbZ-AfY.js} +2 -2
  113. package/workframe-ui/public/assets/{mermaid.core-x0TvVuPo.js → mermaid.core-BFdCAqCo.js} +3 -3
  114. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-6ykAFPEz.js → mindmap-definition-RKZ34NQL-Cy2iCtEl.js} +1 -1
  115. package/workframe-ui/public/assets/{packet-YPE3B663-Dw3xgMDt.js → packet-YPE3B663-DwOBZL6K.js} +1 -1
  116. package/workframe-ui/public/assets/{pie-LRSECV5Y-DATysawG.js → pie-LRSECV5Y-04PPhnKK.js} +1 -1
  117. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-SJKD1S0S.js → pieDiagram-4H26LBE5-LxIpgHqi.js} +1 -1
  118. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-BrYDZX8q.js → quadrantDiagram-W4KKPZXB-0nBYfYm4.js} +1 -1
  119. package/workframe-ui/public/assets/{radar-GUYGQ44K-BmWYPCds.js → radar-GUYGQ44K-D2-vBqps.js} +1 -1
  120. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DwL9Mc8e.js → requirementDiagram-4Y6WPE33-DbuU0nlu.js} +1 -1
  121. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DYIFsL8h.js → sankeyDiagram-5OEKKPKP-B2hQ6B2x.js} +1 -1
  122. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-0-FPkFk8.js → sequenceDiagram-3UESZ5HK-BBrU30e1.js} +1 -1
  123. package/workframe-ui/public/assets/{src-B_od6b6h.js → src-BJEDmV70.js} +1 -1
  124. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BQCiBk6u.js → stateDiagram-AJRCARHV-7FGO4kkH.js} +1 -1
  125. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-DLTSizMg.js +1 -0
  126. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DS3tFcXj.js → timeline-definition-PNZ67QCA-ptDm4rCN.js} +1 -1
  127. package/workframe-ui/public/assets/{treeView-BLDUP644-DSyUCKLY.js → treeView-BLDUP644-CS6Z-0q8.js} +1 -1
  128. package/workframe-ui/public/assets/{treemap-LRROVOQU-CEZaNh5Y.js → treemap-LRROVOQU-DqV4Y2VA.js} +1 -1
  129. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CD-Vc9NF.js → vennDiagram-CIIHVFJN-C0UrZJYt.js} +1 -1
  130. package/workframe-ui/public/assets/{wardley-L42UT6IY-Drq5w1Mc.js → wardley-L42UT6IY-bNDN3_Sa.js} +1 -1
  131. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DouXDJoF.js → wardleyDiagram-YWT4CUSO-jWiJsefM.js} +1 -1
  132. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DDf_Lol5.js → xychartDiagram-2RQKCTM6-Dsh_fLCy.js} +1 -1
  133. package/workframe-ui/public/favicon.svg +7 -7
  134. package/workframe-ui/public/index.html +50 -50
  135. package/workframe-ui/public/workframe-config.json +3 -3
  136. package/scripts/security_audit.py +0 -156
  137. package/scripts/test-scaffold.mjs +0 -390
  138. package/workframe-api/tests/__init__.py +0 -0
  139. package/workframe-api/tests/db_setup.py +0 -13
  140. package/workframe-api/tests/test_admin_updates_gated.py +0 -30
  141. package/workframe-api/tests/test_agent_dm_bootstrap.py +0 -196
  142. package/workframe-api/tests/test_agent_profile_sync.py +0 -76
  143. package/workframe-api/tests/test_auth_email.py +0 -222
  144. package/workframe-api/tests/test_auth_hole_fix_selfcheck.py +0 -99
  145. package/workframe-api/tests/test_auth_rate_limit.py +0 -19
  146. package/workframe-api/tests/test_avatar_resolve.py +0 -77
  147. package/workframe-api/tests/test_child_soul_template.py +0 -71
  148. package/workframe-api/tests/test_credential_canary.py +0 -135
  149. package/workframe-api/tests/test_credential_isolation.py +0 -448
  150. package/workframe-api/tests/test_credential_resolution.py +0 -206
  151. package/workframe-api/tests/test_device_oauth.py +0 -108
  152. package/workframe-api/tests/test_doctor_repair.py +0 -103
  153. package/workframe-api/tests/test_ensure_profile_api.py +0 -77
  154. package/workframe-api/tests/test_gateway_compose_security.py +0 -136
  155. package/workframe-api/tests/test_install_secure_host.py +0 -39
  156. package/workframe-api/tests/test_internal_proxy_auth.py +0 -125
  157. package/workframe-api/tests/test_invite_runtime_bootstrap.py +0 -72
  158. package/workframe-api/tests/test_kanban_delegation.py +0 -185
  159. package/workframe-api/tests/test_llm_proxy.py +0 -155
  160. package/workframe-api/tests/test_login_access_policy.py +0 -183
  161. package/workframe-api/tests/test_mvp_model_bootstrap.py +0 -75
  162. package/workframe-api/tests/test_onboarding_bootstrap.py +0 -248
  163. package/workframe-api/tests/test_platform_auth.py +0 -47
  164. package/workframe-api/tests/test_profile_config_path.py +0 -56
  165. package/workframe-api/tests/test_profile_config_yaml_repair.py +0 -63
  166. package/workframe-api/tests/test_profile_create.py +0 -72
  167. package/workframe-api/tests/test_profile_identity_overlay.py +0 -61
  168. package/workframe-api/tests/test_profile_install_health.py +0 -45
  169. package/workframe-api/tests/test_profile_secret_policy.py +0 -57
  170. package/workframe-api/tests/test_profile_workspace_cwd.py +0 -34
  171. package/workframe-api/tests/test_provider_bootstrap.py +0 -75
  172. package/workframe-api/tests/test_provider_connect.py +0 -54
  173. package/workframe-api/tests/test_room_crud.py +0 -192
  174. package/workframe-api/tests/test_room_tenancy.py +0 -701
  175. package/workframe-api/tests/test_runtime_identity_backfill.py +0 -34
  176. package/workframe-api/tests/test_site_meta.py +0 -81
  177. package/workframe-api/tests/test_soul_stub.py +0 -42
  178. package/workframe-api/tests/test_space_member_sync.py +0 -99
  179. package/workframe-api/tests/test_stripe_stack_config.py +0 -37
  180. package/workframe-api/tests/test_supervisor_lifecycle.py +0 -52
  181. package/workframe-api/tests/test_turn_credential_vault.py +0 -125
  182. package/workframe-api/tests/test_updates.py +0 -176
  183. package/workframe-api/tests/test_user_cohort.py +0 -113
  184. package/workframe-api/tests/test_vault_envelope.py +0 -110
  185. package/workframe-api/tests/test_workspace_members.py +0 -183
  186. package/workframe-api/tests/test_workspace_messaging_sync.py +0 -125
  187. package/workframe-api/tests/test_workspace_provider_list.py +0 -57
  188. package/workframe-supervisor/tests/test_exec_guard.py +0 -42
  189. package/workframe-supervisor/tests/test_server_import.py +0 -21
  190. package/workframe-ui/public/assets/architecture-7EHR7CIX-CtbQKTuT.js +0 -1
  191. package/workframe-ui/public/assets/channel-Dy4Z4-jn.js +0 -1
  192. package/workframe-ui/public/assets/chunk-QZHKN3VN-CtBEchFK.js +0 -1
  193. package/workframe-ui/public/assets/chunk-WU5MYG2G-B9pBtriN.js +0 -1
  194. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BMAEA8jI.js +0 -1
  195. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BMAEA8jI.js +0 -1
  196. package/workframe-ui/public/assets/eventmodeling-FCH6USID-D75cstNT.js +0 -1
  197. package/workframe-ui/public/assets/index-DpAGxump.css +0 -1
  198. 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")