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.
- package/LICENSE +201 -201
- package/NOTICE +12 -12
- package/README.md +8 -92
- package/SECURITY.md +38 -40
- package/bin/workframe.js +329 -329
- package/docs/workspace-instructions/WORKFRAME_ONBOARDING.md +1 -1
- package/docs/workspace-instructions/WORKFRAME_ROUTING.md +8 -8
- package/package.json +3 -6
- package/profiles/architect/AGENTS.md +29 -29
- package/profiles/architect/SOUL.md +2 -2
- package/profiles/architect/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/designer/AGENTS.md +26 -26
- package/profiles/designer/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/dev/AGENTS.md +28 -28
- package/profiles/dev/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/docs/AGENTS.md +27 -27
- package/profiles/docs/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/research/AGENTS.md +26 -26
- package/profiles/research/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/visionary/AGENTS.md +25 -25
- package/profiles/visionary/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/workframe-agent/AGENTS.md +37 -37
- package/profiles/workframe-agent/skills/devops/botfather/SKILL.md +85 -85
- package/profiles/workframe-agent/skills/devops/kanban-handoff-pattern/SKILL.md +58 -58
- package/profiles/workframe-agent/skills/devops/workframe-cohort/SKILL.md +54 -54
- package/rules/workspace-README.md +5 -5
- package/scripts/bundle-workframe-ui.mjs +3 -3
- package/scripts/ensure-compose-host-paths.mjs +51 -51
- package/scripts/lib/install-identity.mjs +212 -212
- package/scripts/set-compose-public-url.mjs +92 -92
- package/scripts/sync-canonical-to-package.mjs +27 -9
- package/shared/WORKFRAME_AGENT_LIBRARY.md +17 -17
- package/shared/WORKFRAME_AGENT_OPERATIONS.md +15 -15
- package/shared/WORKFRAME_AGENT_PACKS.json +18 -18
- package/shared/WORKFRAME_AGENT_PACKS.yaml +8 -8
- package/shared/WORKFRAME_SKILL_CURATION.md +4 -4
- package/workframe-api/README.md +26 -28
- package/workframe-api/action_proxy.py +131 -131
- package/workframe-api/auth_rate_limit.py +49 -49
- package/workframe-api/credential_vault.py +445 -445
- package/workframe-api/data/avatar-catalog.json +41 -41
- package/workframe-api/email_sender.py +220 -220
- package/workframe-api/google_auth.py +90 -90
- package/workframe-api/install_api.py +359 -359
- package/workframe-api/internal_proxy_auth.py +150 -150
- package/workframe-api/llm_proxy.py +277 -277
- package/workframe-api/oidc_jwt.py +108 -108
- package/workframe-api/package.json +12 -13
- package/workframe-api/public/assets/index-DPXu_lGn.css +1 -1
- package/workframe-api/public/assets/index-DYnLrCZZ.js +8 -8
- package/workframe-api/requirements.txt +2 -2
- package/workframe-api/site_meta.py +271 -271
- package/workframe-api/stack_config.py +427 -427
- package/workframe-api/time-bind-chat.py +99 -99
- package/workframe-api/turn_credentials.py +226 -226
- package/workframe-api/updates.py +417 -417
- package/workframe-api/vault_kek.py +159 -159
- package/workframe-api/zk_auth.py +633 -633
- package/workframe-supervisor/Dockerfile +11 -11
- package/workframe-supervisor/server.py +787 -787
- package/workframe-ui/docker/nginx.conf +85 -85
- package/workframe-ui/public/assets/{arc-CBDYvkAF.js → arc-COAT3laO.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-DUyH3hWG.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-XnBRKeW0.js → architectureDiagram-3BPJPVTR-BFjWV24l.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-VYHUfVhd.js → blockDiagram-GPEHLZMM-DSQLPfrj.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BTjUcJpm.js → c4Diagram-AAUBKEIU-DKEHv1t2.js} +1 -1
- package/workframe-ui/public/assets/channel-g7r_RGaY.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-w7uu7R-b.js → chunk-2J33WTMH-DHZg-DUi.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-Cb9LtnDX.js → chunk-3OPIFGDE-BB-OYTfp.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-DiQ-qCwH.js → chunk-4BX2VUAB-C93q0YIm.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-C-mLFr7z.js → chunk-55IACEB6-MAYniqik.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-DOesfiCI.js → chunk-5ZQYHXKU-ChgN6YJs.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-BJ3oBZuz.js → chunk-727SXJPM-B_FYwdAv.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-CCA6xpGs.js → chunk-AQP2D5EJ-1_Hw_h1A.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-a0cMNFb2.js → chunk-BSJP7CBP-CFiDQ1Rv.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-kuqN8EIY.js → chunk-CSCIHK7Q-DZ9UMTlB.js} +1 -1
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-DyPgYHCg.js → chunk-FMBD7UC4-DlMlyFgw.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-CdUuvR0V.js → chunk-KSCS5N6A-DHXtQ_Hf.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-Dq9NoWmK.js → chunk-L5ZTLDWV-CuQzg-QG.js} +1 -1
- package/workframe-ui/public/assets/{chunk-LZXEDZCA-p74rddlO.js → chunk-LZXEDZCA-BHzjzCGg.js} +2 -2
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-DBD2u1Gz.js → chunk-ND2GUHAM-DHXx05n2.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-BeIeYFnd.js → chunk-NZK2D7GU-CV5pmDM_.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-ClHc56ib.js → chunk-O5CBEL6O-6tkCHxsV.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-C5UQehWY.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-DhWllrI8.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-EFr8R_1p.js → chunk-XPW4576I-BClwIiCp.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BBM_8T8E.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BBM_8T8E.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-C7aPBODd.js → cose-bilkent-S5V4N54A-DOrGV6DQ.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-BdU1Rv-H.js → dagre-BM42HDAG-DXTPvJkX.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-DWowSo85.js → diagram-2AECGRRQ-xX_v-pbf.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-MnxBbceO.js → diagram-5GNKFQAL-Cd2pXbBe.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-DQaLRXFf.js → diagram-KO2AKTUF-Df3XvUtk.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-CQaBud9k.js → diagram-LMA3HP47-CsijIPaD.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-D8bAXbY9.js → diagram-OG6HWLK6-aq5fmfHd.js} +1 -1
- package/workframe-ui/public/assets/{dist-DGpTLHr_.js → dist-D1c0mkbB.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-1E-xSvBK.js → erDiagram-TEJ5UH35-DnFysVRY.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-Ci8mdb44.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-CgOVD5hu.js → flowDiagram-I6XJVG4X-C6Ebi3su.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-JFYAIauo.js → ganttDiagram-6RSMTGT7-BQXQtUpa.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-B9REenIl.js → gitGraph-WXDBUCRP-Dt0zIs_M.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-BQ7NcMSn.js → gitGraphDiagram-PVQCEYII-BF8gHzRn.js} +1 -1
- package/workframe-ui/public/assets/index-DpoUZAxh.css +1 -0
- package/workframe-ui/public/assets/{index-Dnw6vjqb.js → index-lRpzpNPT.js} +2 -2
- package/workframe-ui/public/assets/{info-J43DQDTF-CL6-eTjH.js → info-J43DQDTF-CSmszQJT.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-LJTODW4W.js → infoDiagram-5YYISTIA-CVTKGW6p.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-bchrQVuo.js → ishikawaDiagram-YF4QCWOH-Z8pT09Lv.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-DkrvYuxP.js → journeyDiagram-JHISSGLW-r3wD68_T.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-DFRbj0IG.js → kanban-definition-UN3LZRKU-Il8VglqN.js} +1 -1
- package/workframe-ui/public/assets/{line-Vd48P7-O.js → line-oyjpfz2A.js} +1 -1
- package/workframe-ui/public/assets/{linear-Ckizh2G7.js → linear-Cf7p5tVp.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-Bkimsnqj.js → mermaid-parser.core-YmbZ-AfY.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-x0TvVuPo.js → mermaid.core-BFdCAqCo.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-6ykAFPEz.js → mindmap-definition-RKZ34NQL-Cy2iCtEl.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-Dw3xgMDt.js → packet-YPE3B663-DwOBZL6K.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-DATysawG.js → pie-LRSECV5Y-04PPhnKK.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-SJKD1S0S.js → pieDiagram-4H26LBE5-LxIpgHqi.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-BrYDZX8q.js → quadrantDiagram-W4KKPZXB-0nBYfYm4.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-BmWYPCds.js → radar-GUYGQ44K-D2-vBqps.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DwL9Mc8e.js → requirementDiagram-4Y6WPE33-DbuU0nlu.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DYIFsL8h.js → sankeyDiagram-5OEKKPKP-B2hQ6B2x.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-0-FPkFk8.js → sequenceDiagram-3UESZ5HK-BBrU30e1.js} +1 -1
- package/workframe-ui/public/assets/{src-B_od6b6h.js → src-BJEDmV70.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BQCiBk6u.js → stateDiagram-AJRCARHV-7FGO4kkH.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-DLTSizMg.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DS3tFcXj.js → timeline-definition-PNZ67QCA-ptDm4rCN.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-DSyUCKLY.js → treeView-BLDUP644-CS6Z-0q8.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-CEZaNh5Y.js → treemap-LRROVOQU-DqV4Y2VA.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CD-Vc9NF.js → vennDiagram-CIIHVFJN-C0UrZJYt.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-Drq5w1Mc.js → wardley-L42UT6IY-bNDN3_Sa.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DouXDJoF.js → wardleyDiagram-YWT4CUSO-jWiJsefM.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DDf_Lol5.js → xychartDiagram-2RQKCTM6-Dsh_fLCy.js} +1 -1
- package/workframe-ui/public/favicon.svg +7 -7
- package/workframe-ui/public/index.html +50 -50
- package/workframe-ui/public/workframe-config.json +3 -3
- package/scripts/security_audit.py +0 -156
- package/scripts/test-scaffold.mjs +0 -390
- package/workframe-api/tests/__init__.py +0 -0
- package/workframe-api/tests/db_setup.py +0 -13
- package/workframe-api/tests/test_admin_updates_gated.py +0 -30
- package/workframe-api/tests/test_agent_dm_bootstrap.py +0 -196
- package/workframe-api/tests/test_agent_profile_sync.py +0 -76
- package/workframe-api/tests/test_auth_email.py +0 -222
- package/workframe-api/tests/test_auth_hole_fix_selfcheck.py +0 -99
- package/workframe-api/tests/test_auth_rate_limit.py +0 -19
- package/workframe-api/tests/test_avatar_resolve.py +0 -77
- package/workframe-api/tests/test_child_soul_template.py +0 -71
- package/workframe-api/tests/test_credential_canary.py +0 -135
- package/workframe-api/tests/test_credential_isolation.py +0 -448
- package/workframe-api/tests/test_credential_resolution.py +0 -206
- package/workframe-api/tests/test_device_oauth.py +0 -108
- package/workframe-api/tests/test_doctor_repair.py +0 -103
- package/workframe-api/tests/test_ensure_profile_api.py +0 -77
- package/workframe-api/tests/test_gateway_compose_security.py +0 -136
- package/workframe-api/tests/test_install_secure_host.py +0 -39
- package/workframe-api/tests/test_internal_proxy_auth.py +0 -125
- package/workframe-api/tests/test_invite_runtime_bootstrap.py +0 -72
- package/workframe-api/tests/test_kanban_delegation.py +0 -185
- package/workframe-api/tests/test_llm_proxy.py +0 -155
- package/workframe-api/tests/test_login_access_policy.py +0 -183
- package/workframe-api/tests/test_mvp_model_bootstrap.py +0 -75
- package/workframe-api/tests/test_onboarding_bootstrap.py +0 -248
- package/workframe-api/tests/test_platform_auth.py +0 -47
- package/workframe-api/tests/test_profile_config_path.py +0 -56
- package/workframe-api/tests/test_profile_config_yaml_repair.py +0 -63
- package/workframe-api/tests/test_profile_create.py +0 -72
- package/workframe-api/tests/test_profile_identity_overlay.py +0 -61
- package/workframe-api/tests/test_profile_install_health.py +0 -45
- package/workframe-api/tests/test_profile_secret_policy.py +0 -57
- package/workframe-api/tests/test_profile_workspace_cwd.py +0 -34
- package/workframe-api/tests/test_provider_bootstrap.py +0 -75
- package/workframe-api/tests/test_provider_connect.py +0 -54
- package/workframe-api/tests/test_room_crud.py +0 -192
- package/workframe-api/tests/test_room_tenancy.py +0 -701
- package/workframe-api/tests/test_runtime_identity_backfill.py +0 -34
- package/workframe-api/tests/test_site_meta.py +0 -81
- package/workframe-api/tests/test_soul_stub.py +0 -42
- package/workframe-api/tests/test_space_member_sync.py +0 -99
- package/workframe-api/tests/test_stripe_stack_config.py +0 -37
- package/workframe-api/tests/test_supervisor_lifecycle.py +0 -52
- package/workframe-api/tests/test_turn_credential_vault.py +0 -125
- package/workframe-api/tests/test_updates.py +0 -176
- package/workframe-api/tests/test_user_cohort.py +0 -113
- package/workframe-api/tests/test_vault_envelope.py +0 -110
- package/workframe-api/tests/test_workspace_members.py +0 -183
- package/workframe-api/tests/test_workspace_messaging_sync.py +0 -125
- package/workframe-api/tests/test_workspace_provider_list.py +0 -57
- package/workframe-supervisor/tests/test_exec_guard.py +0 -42
- package/workframe-supervisor/tests/test_server_import.py +0 -21
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CtbQKTuT.js +0 -1
- package/workframe-ui/public/assets/channel-Dy4Z4-jn.js +0 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-CtBEchFK.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-B9pBtriN.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BMAEA8jI.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BMAEA8jI.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-D75cstNT.js +0 -1
- package/workframe-ui/public/assets/index-DpAGxump.css +0 -1
- 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")
|