@bongos/core 1.19.888 → 1.19.889
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/.bongos-core.json +34 -19
- package/docs/module-api-changelog.md +2 -0
- package/docs/recipes/gds-db-backup.md +30 -2
- package/migrations/core_249_session_token_hash.sql +103 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/db-backup-offsite.sh +105 -9
- package/scripts/gds/run-unit-tests.js +8 -0
- package/src/bongos/db-kernel.js +42 -6
- package/src/bongos/db.js +9 -2
- package/src/module-api.js +1 -1
- package/tests/auth_session_expiry_db.mjs +26 -21
- package/tests/government_live_proof.mjs +8 -2
- package/tests/own_session_revoke.mjs +13 -1
- package/tests/session_token_hash.mjs +139 -0
- package/tests/session_token_hash_db.mjs +193 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.889",
|
|
6
|
+
"core_contract": "1.19.889",
|
|
7
|
+
"source_commit": "b7871cd73a1ce5f73988d99ef63101a6a2d23050",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-19T21:23:27.620Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 497,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2269,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2792,
|
|
20
|
+
"tree_sha256": "b7b8bc9e53808070c5d46a211481ceef0dea2c901bc79e2b0dc0871e60c362b1",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2607,7 +2607,7 @@
|
|
|
2607
2607
|
{
|
|
2608
2608
|
"path": "docs/module-api-changelog.md",
|
|
2609
2609
|
"mode": "0000644",
|
|
2610
|
-
"sha256": "
|
|
2610
|
+
"sha256": "01a90a40951ae88d4c36c5fabbaf3debc0e02bcaa9fa8a7b70e247aa5d61d236"
|
|
2611
2611
|
},
|
|
2612
2612
|
{
|
|
2613
2613
|
"path": "docs/modules-contract.md",
|
|
@@ -2742,7 +2742,7 @@
|
|
|
2742
2742
|
{
|
|
2743
2743
|
"path": "docs/recipes/gds-db-backup.md",
|
|
2744
2744
|
"mode": "0000644",
|
|
2745
|
-
"sha256": "
|
|
2745
|
+
"sha256": "d3e628bc627cb29874e977bf2e93c6557dc72a210506c22515a983f4f513d627"
|
|
2746
2746
|
},
|
|
2747
2747
|
{
|
|
2748
2748
|
"path": "docs/recipes/instance-deploy.md",
|
|
@@ -3699,6 +3699,11 @@
|
|
|
3699
3699
|
"mode": "0000644",
|
|
3700
3700
|
"sha256": "4f35f87050a532f26b7c9cd0a9bc6f298ab5c0e15eb1c36ddcdd4d261201291d"
|
|
3701
3701
|
},
|
|
3702
|
+
{
|
|
3703
|
+
"path": "migrations/core_249_session_token_hash.sql",
|
|
3704
|
+
"mode": "0000644",
|
|
3705
|
+
"sha256": "ae3e405c7e59fbc13e23ae2a4ba945176f2369a150c997d9b8134e49991f273b"
|
|
3706
|
+
},
|
|
3702
3707
|
{
|
|
3703
3708
|
"path": "modules/agents/CLAUDE.md",
|
|
3704
3709
|
"mode": "0000644",
|
|
@@ -7697,12 +7702,12 @@
|
|
|
7697
7702
|
{
|
|
7698
7703
|
"path": "package-lock.json",
|
|
7699
7704
|
"mode": "0000644",
|
|
7700
|
-
"sha256": "
|
|
7705
|
+
"sha256": "d1348f68cd79ad102a381e3d7f90b784d9df605e859269ede84ad859cc831deb"
|
|
7701
7706
|
},
|
|
7702
7707
|
{
|
|
7703
7708
|
"path": "package.json",
|
|
7704
7709
|
"mode": "0000644",
|
|
7705
|
-
"sha256": "
|
|
7710
|
+
"sha256": "36e895c05e2aed3f97ce34ae3b3f73b0492fff7185077519068bc69dda6a8fd6"
|
|
7706
7711
|
},
|
|
7707
7712
|
{
|
|
7708
7713
|
"path": "public-docs/index.html",
|
|
@@ -8132,7 +8137,7 @@
|
|
|
8132
8137
|
{
|
|
8133
8138
|
"path": "scripts/gds/db-backup-offsite.sh",
|
|
8134
8139
|
"mode": "0000644",
|
|
8135
|
-
"sha256": "
|
|
8140
|
+
"sha256": "97f52f6aff95e1c81e069c1d4228eecb514af6068c810441465b5f5b7ee0f2ce"
|
|
8136
8141
|
},
|
|
8137
8142
|
{
|
|
8138
8143
|
"path": "scripts/gds/demote.js",
|
|
@@ -8807,7 +8812,7 @@
|
|
|
8807
8812
|
{
|
|
8808
8813
|
"path": "scripts/gds/run-unit-tests.js",
|
|
8809
8814
|
"mode": "0000644",
|
|
8810
|
-
"sha256": "
|
|
8815
|
+
"sha256": "a40a90ba6255820130aa6a94ddc314b643177e4a153750dc6290550a75e1a4e8"
|
|
8811
8816
|
},
|
|
8812
8817
|
{
|
|
8813
8818
|
"path": "scripts/gds/runner-drift.js",
|
|
@@ -9447,12 +9452,12 @@
|
|
|
9447
9452
|
{
|
|
9448
9453
|
"path": "src/bongos/db-kernel.js",
|
|
9449
9454
|
"mode": "0000644",
|
|
9450
|
-
"sha256": "
|
|
9455
|
+
"sha256": "dd16328ac569aa8c6af09b2700957634e2c6d368d291b015a74a8dc452dc340a"
|
|
9451
9456
|
},
|
|
9452
9457
|
{
|
|
9453
9458
|
"path": "src/bongos/db.js",
|
|
9454
9459
|
"mode": "0000644",
|
|
9455
|
-
"sha256": "
|
|
9460
|
+
"sha256": "def84475a0470dafb894e7ca3f96f9db27cea78964f90ac3e81ed748fe9efa48"
|
|
9456
9461
|
},
|
|
9457
9462
|
{
|
|
9458
9463
|
"path": "src/bongos/github-repos.js",
|
|
@@ -9707,7 +9712,7 @@
|
|
|
9707
9712
|
{
|
|
9708
9713
|
"path": "src/module-api.js",
|
|
9709
9714
|
"mode": "0000644",
|
|
9710
|
-
"sha256": "
|
|
9715
|
+
"sha256": "541b09991f4858b3255f250ef7e52e86b0d561ec69d058e74bc218fe1a104932"
|
|
9711
9716
|
},
|
|
9712
9717
|
{
|
|
9713
9718
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10092,7 +10097,7 @@
|
|
|
10092
10097
|
{
|
|
10093
10098
|
"path": "tests/auth_session_expiry_db.mjs",
|
|
10094
10099
|
"mode": "0000644",
|
|
10095
|
-
"sha256": "
|
|
10100
|
+
"sha256": "89730864ff5aa36271582c7e2717bf434bc84cbc20b615026005a1ef662ff31c"
|
|
10096
10101
|
},
|
|
10097
10102
|
{
|
|
10098
10103
|
"path": "tests/auth_session_ttl.mjs",
|
|
@@ -11357,7 +11362,7 @@
|
|
|
11357
11362
|
{
|
|
11358
11363
|
"path": "tests/government_live_proof.mjs",
|
|
11359
11364
|
"mode": "0000644",
|
|
11360
|
-
"sha256": "
|
|
11365
|
+
"sha256": "ca29425b30e726e6a7e939b008f0e5ca71877fa88fdcbfe1bf6356482f47c1e8"
|
|
11361
11366
|
},
|
|
11362
11367
|
{
|
|
11363
11368
|
"path": "tests/government_live_proof_shape.mjs",
|
|
@@ -12372,7 +12377,7 @@
|
|
|
12372
12377
|
{
|
|
12373
12378
|
"path": "tests/own_session_revoke.mjs",
|
|
12374
12379
|
"mode": "0000644",
|
|
12375
|
-
"sha256": "
|
|
12380
|
+
"sha256": "ff24d3753a52a64f426e0602bc2411aad13c1426c2bc0774b7618a1d20cebfad"
|
|
12376
12381
|
},
|
|
12377
12382
|
{
|
|
12378
12383
|
"path": "tests/package_core.mjs",
|
|
@@ -13234,6 +13239,16 @@
|
|
|
13234
13239
|
"mode": "0000644",
|
|
13235
13240
|
"sha256": "a630ac72981f230e8aa6609152765c13e8928ac329cb0ff3fe615371f31208d2"
|
|
13236
13241
|
},
|
|
13242
|
+
{
|
|
13243
|
+
"path": "tests/session_token_hash.mjs",
|
|
13244
|
+
"mode": "0000644",
|
|
13245
|
+
"sha256": "ff4cea59acb15ce383d9346793206865f0aababc5e2f4144c957bb93d45f62b4"
|
|
13246
|
+
},
|
|
13247
|
+
{
|
|
13248
|
+
"path": "tests/session_token_hash_db.mjs",
|
|
13249
|
+
"mode": "0000644",
|
|
13250
|
+
"sha256": "4a757c27f11cde314649e46ab24e51600d24c2f503f1aa7f055cdb4fa60ea6ce"
|
|
13251
|
+
},
|
|
13237
13252
|
{
|
|
13238
13253
|
"path": "tests/session_type.mjs",
|
|
13239
13254
|
"mode": "0000644",
|
|
@@ -2250,5 +2250,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2250
2250
|
landed since 1.19.886 with no explicit bump. run 35468511941. (task 1002620)
|
|
2251
2251
|
1.19.888 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2252
2252
|
landed since 1.19.887 with no explicit bump. run 35468948342. (task 1002620)
|
|
2253
|
+
1.19.889 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2254
|
+
landed since 1.19.888 with no explicit bump. run 35470239834. (task 1002620)
|
|
2253
2255
|
---------------------------------------------------------------------------
|
|
2254
2256
|
```
|
|
@@ -46,8 +46,33 @@ gds-db-offsite.timer ──→ gds-db-offsite.service ──→ db-backup-offsit
|
|
|
46
46
|
- **It does not take its own `pg_dump`.** It uploads the newest `/var/backups/gds/<db>-*.sql.gz` that the local nightly already produced. Runs at 05:00 UTC — one hour after the 04:00 local dump — so the copy is same-day-fresh.
|
|
47
47
|
- **Bucket layout:** objects land under the `gds-offsite/` key prefix in the Space.
|
|
48
48
|
- **Retention:** 4 weekly copies (`GDS_OFFSITE_KEEP=4` — ~1 month), pruned oldest-first under that prefix. Sub-1 MB each.
|
|
49
|
-
- **Credentials** live at `/etc/example/spaces.env` (chmod 600, owned by `lars`), never in the repo (ADR 0022). Required vars: `SPACES_KEY`, `SPACES_SECRET`, `SPACES_BUCKET`, `SPACES_REGION`, `SPACES_ENDPOINT`. systemd injects them via `EnvironmentFile`; a manual run sources the same file.
|
|
50
|
-
- **Dependency:** `python3` + the `boto3` module on the droplet. The script fails loudly if
|
|
49
|
+
- **Credentials** live at `/etc/example/spaces.env` (chmod 600, owned by `lars`), never in the repo (ADR 0022). Required vars: `SPACES_KEY`, `SPACES_SECRET`, `SPACES_BUCKET`, `SPACES_REGION`, `SPACES_ENDPOINT`, `GDS_OFFSITE_AGE_RECIPIENT`. systemd injects them via `EnvironmentFile`; a manual run sources the same file.
|
|
50
|
+
- **Dependency:** `age`, plus `python3` + the `boto3` module on the droplet. The script fails loudly if any is missing.
|
|
51
|
+
- **The uploaded object is encrypted** (`<db>-<date>.sql.gz.age`) and the retention count applies to those. Any legacy plaintext `.sql.gz` object still under the prefix is **deleted on the next run**, outside the retention count — see below.
|
|
52
|
+
|
|
53
|
+
#### Offsite dumps are encrypted (task [#1003372](https://cloudbongos.com/builders#/task/1003372), audit A1)
|
|
54
|
+
|
|
55
|
+
Until this task the weekly job uploaded the raw `pg_dump`. A dump is not merely "a backup": it is every secret the platform holds, in one file, on storage the platform does not own — OAuth credentials, the GitHub App private key, and (before `core_249`) every builder's live session bearer in plaintext. A breach of the Space, a mis-set bucket ACL or a leaked read-only S3 key was a total compromise with no trace in any log we keep.
|
|
56
|
+
|
|
57
|
+
The dump is now encrypted with [`age`](https://github.com/FiloSottile/age) **to a public key**, so the droplet can produce backups it cannot itself read. That asymmetry is the point: an attacker who owns the droplet already has the live database, but they do not also get the archive of every previous state, and they cannot decrypt what is already in the Space. **Keep the private key off the server.**
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# ONE-TIME, on a machine that is NOT the server:
|
|
61
|
+
age-keygen -o bongos-backup.key # store safe + OFFLINE — this is the only way back
|
|
62
|
+
# it prints: Public key: age1xxxxxxxx...
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Add the **public** key to `/etc/example/spaces.env` as `GDS_OFFSITE_AGE_RECIPIENT=age1...`.
|
|
66
|
+
|
|
67
|
+
> **Without a recipient the job refuses to run**, and does not fall back to a plaintext upload — that fallback is the vulnerability, and a silent one, since a backup that "worked" looks identical either way. A failing timer is visible in `systemctl status gds-db-offsite.timer`; an unencrypted dump in a bucket is visible to nobody until it matters. **If offsite backups stop after this deploy, this is why** — set the recipient.
|
|
68
|
+
>
|
|
69
|
+
> **The first successful run also PURGES every unencrypted `.sql.gz` already in the Space**, unconditionally and outside the retention count. Encrypting from here on does nothing about the copies already up there, and retaining four of them "for safety" would retain the finding. Local nightly dumps on the droplet are untouched, so this removes copies, never the last copy.
|
|
70
|
+
|
|
71
|
+
To restore an offsite object:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
age -d -i bongos-backup.key <db>-<date>.sql.gz.age | gunzip | psql <db>
|
|
75
|
+
```
|
|
51
76
|
|
|
52
77
|
> **History:** an earlier task ([#7](https://example.com/builders#/task/7)) shipped a `scripts/backup.sh` + `/etc/cron.d/otb-backup` daily-to-Spaces cron that was forgotten and never worked (empty credentials, no Space, no log). [#415](https://example.com/builders#/task/415) retired it and replaced it with the systemd unit above. If you find any trace of `otb-backup.cron`, `scripts/backup.sh`, or `~/.config/otb/spaces.env` on a host, it's dead — remove it.
|
|
53
78
|
>
|
|
@@ -65,6 +90,9 @@ SPACES_SECRET=...
|
|
|
65
90
|
SPACES_BUCKET=otb-gds-backups
|
|
66
91
|
SPACES_REGION=nyc3
|
|
67
92
|
SPACES_ENDPOINT=https://nyc3.digitaloceanspaces.com
|
|
93
|
+
# The age PUBLIC key from `age-keygen`, generated OFF this box (see above).
|
|
94
|
+
# Without it the job refuses to run rather than upload plaintext.
|
|
95
|
+
GDS_OFFSITE_AGE_RECIPIENT=age1...
|
|
68
96
|
ENV
|
|
69
97
|
sudo chmod 600 /etc/example/spaces.env
|
|
70
98
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
-- core_249 — builder_sessions stores a HASH of the bearer, never the bearer
|
|
2
|
+
-- (task 1003372, audit ref A1 from the 2026-08-29 security audit, goal 1000084).
|
|
3
|
+
--
|
|
4
|
+
-- forward-unsafe: drops builder_sessions.token and makes token_hash NOT NULL.
|
|
5
|
+
-- Both are the point of the migration, not incidental to it. The whole finding is
|
|
6
|
+
-- that the plaintext bearer is READABLE AT REST, so a version of this that kept the
|
|
7
|
+
-- column would not fix anything; and the NOT NULL is what stops a future writer
|
|
8
|
+
-- inserting a session no lookup can ever match. Neither is reversible, and neither
|
|
9
|
+
-- should be: rolling back to code that reads `token` finds it gone, but a variant
|
|
10
|
+
-- that merely emptied the column would leave that code finding NULL and signing
|
|
11
|
+
-- everyone out just the same — so the unsafety is in the rollback direction only,
|
|
12
|
+
-- and is identical either way. Forward, nobody is signed out: the backfill below
|
|
13
|
+
-- derives every existing row's hash from the plaintext before the column goes.
|
|
14
|
+
--
|
|
15
|
+
-- WHAT WAS WRONG. `token` held the live bearer in plaintext, NOT NULL UNIQUE and
|
|
16
|
+
-- INDEXED. Every row was a directly replayable credential for the builder it
|
|
17
|
+
-- belonged to — Archons included — so ANY read of this table was a complete
|
|
18
|
+
-- takeover of every account holding a session. That is the exact credential the
|
|
19
|
+
-- whole ADR 0016 trust boundary rests on: the server re-resolves authority per
|
|
20
|
+
-- request from a token, so a token IS the authority. `token_hash` had zero
|
|
21
|
+
-- occurrences repo-wide before this migration.
|
|
22
|
+
--
|
|
23
|
+
-- A read of the table is not hypothetical. The offsite dump was uploaded
|
|
24
|
+
-- unencrypted (same task, `scripts/gds/db-backup-offsite.sh`), and a co-tenant
|
|
25
|
+
-- Postgres role reached this table across tenants until task 1003369 split the
|
|
26
|
+
-- unix accounts. Hashing at rest is what makes those reads worthless rather than
|
|
27
|
+
-- catastrophic.
|
|
28
|
+
--
|
|
29
|
+
-- WHY A BACKFILL AND NOT A FORCED RE-AUTH. The hash is computable from the
|
|
30
|
+
-- plaintext we already hold, so every live session survives the change: the row
|
|
31
|
+
-- keeps working and its holder notices nothing. The alternative — ship the code
|
|
32
|
+
-- and let sessions re-mint — would sign out every builder and every dev box on
|
|
33
|
+
-- deploy day to reach a state this statement reaches for free. Migration 035 and
|
|
34
|
+
-- core_243 both reasoned this way in their own direction: do not put a policy
|
|
35
|
+
-- change on the backs of the people holding live sessions when it lands.
|
|
36
|
+
--
|
|
37
|
+
-- WHY sha256 WITH NO EXTENSION. Postgres 16 (docs/architecture.md) carries
|
|
38
|
+
-- `sha256(bytea)` as a built-in, so the backfill needs no pgcrypto — one less
|
|
39
|
+
-- extension to have installed on a box before a migration will run. It is the
|
|
40
|
+
-- same digest `db-kernel.js` computes in Node, so a row backfilled here and a row
|
|
41
|
+
-- minted by the code resolve identically.
|
|
42
|
+
--
|
|
43
|
+
-- WHY A FAST HASH IS THE RIGHT ONE. A session token is 256 bits of CSPRNG, not a
|
|
44
|
+
-- password: there is no dictionary to run and nothing to slow an attacker down,
|
|
45
|
+
-- so bcrypt/argon would buy nothing and cost a KDF on every authenticated
|
|
46
|
+
-- request. The same reasoning is written out at
|
|
47
|
+
-- `modules/platform-identity/platform-identity.js` (`sha256hex`), which hashes
|
|
48
|
+
-- its client secrets and authorization codes for exactly this reason.
|
|
49
|
+
--
|
|
50
|
+
-- WHY THE PLAINTEXT COLUMN IS DROPPED, NOT NULLED. Nulling it would leave a dead
|
|
51
|
+
-- column that still has to be kept empty by every future writer, and it buys no
|
|
52
|
+
-- rollback safety: rolling back to code that reads `token` finds it NULL and
|
|
53
|
+
-- signs everyone out either way. Dropping it is the honest end state, and the
|
|
54
|
+
-- unique index moves to `token_hash` so the uniqueness guarantee is unbroken.
|
|
55
|
+
--
|
|
56
|
+
-- WHAT THIS DOES NOT CHANGE. Expiry is untouched. ADR 0294 (CLI sessions do not
|
|
57
|
+
-- expire) and ADR 0206 (no absolute ceiling) both stand exactly as written; this
|
|
58
|
+
-- migration does not read, write or reason about `expires_at`. The audit also
|
|
59
|
+
-- asked for refresh-on-use to stop and an absolute lifetime to be added — both are
|
|
60
|
+
-- decisions those two ADRs have already taken the other way, with the owner as
|
|
61
|
+
-- decider, AFTER this audit was filed. Changing them is not this task's to do.
|
|
62
|
+
--
|
|
63
|
+
-- IDEMPOTENT. The whole body is guarded on the plaintext column still existing,
|
|
64
|
+
-- so a second run is a no-op rather than an error about a column that is gone.
|
|
65
|
+
|
|
66
|
+
BEGIN;
|
|
67
|
+
|
|
68
|
+
DO $$
|
|
69
|
+
BEGIN
|
|
70
|
+
IF EXISTS (
|
|
71
|
+
SELECT 1 FROM information_schema.columns
|
|
72
|
+
WHERE table_name = 'builder_sessions' AND column_name = 'token'
|
|
73
|
+
) THEN
|
|
74
|
+
|
|
75
|
+
ALTER TABLE builder_sessions ADD COLUMN IF NOT EXISTS token_hash text;
|
|
76
|
+
|
|
77
|
+
-- Every live row keeps working: the hash is derived from the bearer already
|
|
78
|
+
-- stored, so no holder is signed out by this deploy.
|
|
79
|
+
UPDATE builder_sessions
|
|
80
|
+
SET token_hash = encode(sha256(token::bytea), 'hex')
|
|
81
|
+
WHERE token_hash IS NULL;
|
|
82
|
+
|
|
83
|
+
-- The invariant the lookup depends on. Enforced only after the backfill has
|
|
84
|
+
-- given every existing row a value.
|
|
85
|
+
ALTER TABLE builder_sessions ALTER COLUMN token_hash SET NOT NULL;
|
|
86
|
+
|
|
87
|
+
-- Carries over `token`'s UNIQUE: two sessions may not share a bearer, and the
|
|
88
|
+
-- hash is what the lookup now keys on, so it needs the same index the
|
|
89
|
+
-- plaintext had for the same reason.
|
|
90
|
+
CREATE UNIQUE INDEX IF NOT EXISTS ux_builder_sessions_token_hash
|
|
91
|
+
ON builder_sessions (token_hash);
|
|
92
|
+
|
|
93
|
+
DROP INDEX IF EXISTS idx_builder_sessions_token;
|
|
94
|
+
ALTER TABLE builder_sessions DROP COLUMN token;
|
|
95
|
+
|
|
96
|
+
END IF;
|
|
97
|
+
END
|
|
98
|
+
$$;
|
|
99
|
+
|
|
100
|
+
COMMENT ON COLUMN builder_sessions.token_hash IS
|
|
101
|
+
'SHA-256 hex of the bearer token. The plaintext is NEVER stored (core_249, audit A1): it exists only in the response that mints it and in the client that holds it. lookupSession hashes the presented bearer and keys on this column, so a read of this table yields nothing replayable.';
|
|
102
|
+
|
|
103
|
+
COMMIT;
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.889",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.889",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.889",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -17,6 +17,36 @@
|
|
|
17
17
|
# ADR 0022. The systemd unit injects them via EnvironmentFile; a manual run
|
|
18
18
|
# sources the same file. Required vars:
|
|
19
19
|
# SPACES_KEY SPACES_SECRET SPACES_BUCKET SPACES_REGION SPACES_ENDPOINT
|
|
20
|
+
# GDS_OFFSITE_AGE_RECIPIENT
|
|
21
|
+
#
|
|
22
|
+
# ENCRYPTION IS MANDATORY (task 1003372, audit ref A1). Until that task this
|
|
23
|
+
# script uploaded the raw pg_dump to the Space. A database dump is not "a backup"
|
|
24
|
+
# from a security standpoint — it is every secret the platform holds, in one
|
|
25
|
+
# file, on someone else's disk: OAuth credentials, the GitHub App private key,
|
|
26
|
+
# and (before core_249) every builder's live session bearer in plaintext. A
|
|
27
|
+
# breach of the Space, a mis-set bucket ACL, or a leaked read-only S3 key was a
|
|
28
|
+
# total compromise of the instance with no trace in any log the platform keeps.
|
|
29
|
+
#
|
|
30
|
+
# The dump is encrypted to a PUBLIC key (`age`), so this box can produce backups
|
|
31
|
+
# it cannot itself read. That asymmetry is the point: an attacker who owns the
|
|
32
|
+
# droplet gets the live database anyway, but they do not also get the archive of
|
|
33
|
+
# every previous state, and they cannot decrypt what is already in the Space.
|
|
34
|
+
# Keep the private key OFF this machine.
|
|
35
|
+
#
|
|
36
|
+
# One-time setup, on a machine that is NOT the server:
|
|
37
|
+
# age-keygen -o bongos-backup.key # store this somewhere safe and offline
|
|
38
|
+
# # it prints: Public key: age1xxxxxxxx...
|
|
39
|
+
# Then on the server, add to $ENV_FILE:
|
|
40
|
+
# GDS_OFFSITE_AGE_RECIPIENT=age1xxxxxxxx...
|
|
41
|
+
#
|
|
42
|
+
# To restore:
|
|
43
|
+
# age -d -i bongos-backup.key <dump>.sql.gz.age | gunzip | psql <db>
|
|
44
|
+
#
|
|
45
|
+
# WITHOUT A RECIPIENT THIS SCRIPT REFUSES TO RUN. It does not fall back to a
|
|
46
|
+
# plaintext upload: that fallback is the vulnerability, and a silent one, since
|
|
47
|
+
# a backup that "worked" looks identical either way. A failing timer is visible
|
|
48
|
+
# in `systemctl status gds-db-offsite.timer`; an unencrypted dump in a bucket is
|
|
49
|
+
# visible to nobody until it matters.
|
|
20
50
|
#
|
|
21
51
|
# Manual one-shot:
|
|
22
52
|
# <instance-root>/scripts/gds/db-backup-offsite.sh
|
|
@@ -72,13 +102,17 @@ ENV_FILE="${GDS_SPACES_ENV:-${CONFIG_DIR_NAME:+/etc/$CONFIG_DIR_NAME/spaces.env}
|
|
|
72
102
|
# extract only the five expected KEY=VALUE lines. `read -r k v` with IFS='='
|
|
73
103
|
# splits on the FIRST '=' only, so base64 secrets containing '/', '+' or '='
|
|
74
104
|
# survive intact.
|
|
75
|
-
|
|
105
|
+
# Read the file when EITHER the credentials or the encryption recipient is still
|
|
106
|
+
# unset. Keying this on SPACES_KEY alone would skip the file on a box where
|
|
107
|
+
# systemd injects the S3 vars but the recipient was added to the file later —
|
|
108
|
+
# and the script would then refuse to run while the key it needs sat right there.
|
|
109
|
+
if { [ -z "${SPACES_KEY:-}" ] || [ -z "${GDS_OFFSITE_AGE_RECIPIENT:-}" ]; } && [ -f "$ENV_FILE" ]; then
|
|
76
110
|
while IFS='=' read -r _k _v; do
|
|
77
111
|
case "$_k" in
|
|
78
|
-
SPACES_KEY|SPACES_SECRET|SPACES_BUCKET|SPACES_REGION|SPACES_ENDPOINT)
|
|
112
|
+
SPACES_KEY|SPACES_SECRET|SPACES_BUCKET|SPACES_REGION|SPACES_ENDPOINT|GDS_OFFSITE_AGE_RECIPIENT)
|
|
79
113
|
export "$_k=$_v" ;;
|
|
80
114
|
esac
|
|
81
|
-
done < <(grep -E '^(SPACES_KEY|SPACES_SECRET|SPACES_BUCKET|SPACES_REGION|SPACES_ENDPOINT)=' "$ENV_FILE" || true)
|
|
115
|
+
done < <(grep -E '^(SPACES_KEY|SPACES_SECRET|SPACES_BUCKET|SPACES_REGION|SPACES_ENDPOINT|GDS_OFFSITE_AGE_RECIPIENT)=' "$ENV_FILE" || true)
|
|
82
116
|
fi
|
|
83
117
|
|
|
84
118
|
missing=()
|
|
@@ -91,8 +125,30 @@ if [ "${#missing[@]}" -gt 0 ]; then
|
|
|
91
125
|
exit 2
|
|
92
126
|
fi
|
|
93
127
|
|
|
128
|
+
# The encryption recipient is as required as the credentials — a dump this box
|
|
129
|
+
# can upload but nobody can decrypt is useless, and a dump it uploads in the
|
|
130
|
+
# clear is the finding this refusal exists to close (task 1003372, audit A1).
|
|
131
|
+
if [ -z "${GDS_OFFSITE_AGE_RECIPIENT:-}" ]; then
|
|
132
|
+
echo "ERROR: GDS_OFFSITE_AGE_RECIPIENT is not set — refusing to ship an unencrypted database dump offsite." >&2
|
|
133
|
+
echo " A dump is every secret this platform holds, in one file, on storage the platform does not own." >&2
|
|
134
|
+
echo "" >&2
|
|
135
|
+
echo " Fix, on a machine that is NOT this server:" >&2
|
|
136
|
+
echo " age-keygen -o bongos-backup.key # keep this file safe and OFFLINE — it is the only way back" >&2
|
|
137
|
+
echo " then add the age1... public key it prints to $ENV_FILE:" >&2
|
|
138
|
+
echo " GDS_OFFSITE_AGE_RECIPIENT=age1..." >&2
|
|
139
|
+
echo "" >&2
|
|
140
|
+
echo " This is deliberately not optional: falling back to a plaintext upload would look identical in the logs." >&2
|
|
141
|
+
exit 2
|
|
142
|
+
fi
|
|
143
|
+
|
|
144
|
+
|
|
94
145
|
# ---- preconditions ----------------------------------------------------------
|
|
95
146
|
|
|
147
|
+
if ! command -v age >/dev/null 2>&1; then
|
|
148
|
+
echo "ERROR: 'age' not on PATH — it is what encrypts the dump before it leaves this box." >&2
|
|
149
|
+
echo " Install with: apt install age (or fetch the static binary from https://github.com/FiloSottile/age/releases)" >&2
|
|
150
|
+
exit 2
|
|
151
|
+
fi
|
|
96
152
|
if ! command -v python3 >/dev/null 2>&1; then
|
|
97
153
|
echo "ERROR: python3 not on PATH (needed for the boto3 S3 client)." >&2
|
|
98
154
|
exit 2
|
|
@@ -113,14 +169,38 @@ if [ -z "$LATEST" ]; then
|
|
|
113
169
|
fi
|
|
114
170
|
|
|
115
171
|
BASENAME=$(basename "$LATEST")
|
|
116
|
-
KEY="${PREFIX}${BASENAME}"
|
|
117
172
|
BYTES=$(stat -c '%s' "$LATEST")
|
|
118
173
|
|
|
119
|
-
|
|
174
|
+
# ---- encrypt before it leaves the box ---------------------------------------
|
|
175
|
+
#
|
|
176
|
+
# To a temp file under the backup dir (same filesystem, so no cross-device copy
|
|
177
|
+
# of plaintext through /tmp) with a 0600 umask, and removed on every exit path
|
|
178
|
+
# including failure — the cleartext dump already lives beside it, so the
|
|
179
|
+
# ciphertext is not sensitive in itself, but leaving files behind on a failing
|
|
180
|
+
# weekly timer fills a disk nobody is watching.
|
|
181
|
+
umask 077
|
|
182
|
+
ENC="$(mktemp "${BACKUP_DIR}/.${BASENAME}.age.XXXXXX")"
|
|
183
|
+
trap 'rm -f "$ENC"' EXIT
|
|
184
|
+
|
|
185
|
+
if ! age -r "$GDS_OFFSITE_AGE_RECIPIENT" -o "$ENC" "$LATEST"; then
|
|
186
|
+
echo "ERROR: age refused to encrypt — is GDS_OFFSITE_AGE_RECIPIENT a valid age1... public key?" >&2
|
|
187
|
+
exit 2
|
|
188
|
+
fi
|
|
189
|
+
|
|
190
|
+
# A zero-byte or missing ciphertext would upload happily and restore to nothing.
|
|
191
|
+
# Checked here rather than trusted, because the failure is silent for a month.
|
|
192
|
+
ENC_BYTES=$(stat -c '%s' "$ENC")
|
|
193
|
+
if [ "$ENC_BYTES" -lt 1 ]; then
|
|
194
|
+
echo "ERROR: encryption produced an empty file — refusing to upload it as a backup." >&2
|
|
195
|
+
exit 2
|
|
196
|
+
fi
|
|
197
|
+
|
|
198
|
+
KEY="${PREFIX}${BASENAME}.age"
|
|
199
|
+
echo "[$(date -u +%FT%TZ)] gds-db-offsite: archiving ${LATEST} (${BYTES} bytes → ${ENC_BYTES} encrypted) → s3://${SPACES_BUCKET}/${KEY}"
|
|
120
200
|
|
|
121
201
|
# ---- upload + prune (single boto3 session) ----------------------------------
|
|
122
202
|
|
|
123
|
-
GDS_OFFSITE_LOCAL="$
|
|
203
|
+
GDS_OFFSITE_LOCAL="$ENC" \
|
|
124
204
|
GDS_OFFSITE_KEY="$KEY" \
|
|
125
205
|
GDS_OFFSITE_KEEP="$KEEP" \
|
|
126
206
|
GDS_OFFSITE_PREFIX="$PREFIX" \
|
|
@@ -151,10 +231,25 @@ s3 = boto3.client(
|
|
|
151
231
|
s3.upload_file(local, bucket, objkey)
|
|
152
232
|
print(f"[gds-db-offsite] uploaded {objkey}")
|
|
153
233
|
|
|
154
|
-
# Prune: keep the newest `keep` objects under our prefix, delete the rest.
|
|
155
234
|
resp = s3.list_objects_v2(Bucket=bucket, Prefix=prefix)
|
|
235
|
+
contents = resp.get("Contents", [])
|
|
236
|
+
|
|
237
|
+
# PURGE THE PLAINTEXT ALREADY UP THERE (task 1003372, audit A1). Every `.sql.gz`
|
|
238
|
+
# under this prefix was uploaded unencrypted by an earlier version of this
|
|
239
|
+
# script, and each one is a live copy of every secret the platform held on the
|
|
240
|
+
# day it was taken — including, before core_249, directly replayable session
|
|
241
|
+
# bearers. Encrypting from now on does nothing about them. They are deleted
|
|
242
|
+
# OUTSIDE the retention count, and unconditionally: retaining four of them
|
|
243
|
+
# "for safety" would retain the finding. The local nightly dumps on the droplet
|
|
244
|
+
# are untouched, so this removes copies, never the last copy.
|
|
245
|
+
legacy = [o for o in contents if o["Key"].endswith(".sql.gz")]
|
|
246
|
+
for obj in legacy:
|
|
247
|
+
s3.delete_object(Bucket=bucket, Key=obj["Key"])
|
|
248
|
+
print(f"[gds-db-offsite] PURGED unencrypted legacy dump {obj['Key']}")
|
|
249
|
+
|
|
250
|
+
# Prune: keep the newest `keep` ENCRYPTED objects under our prefix.
|
|
156
251
|
objects = sorted(
|
|
157
|
-
[o for o in
|
|
252
|
+
[o for o in contents if o["Key"].endswith(".sql.gz.age")],
|
|
158
253
|
key=lambda o: o["LastModified"],
|
|
159
254
|
)
|
|
160
255
|
to_delete = objects[:-keep] if len(objects) > keep else []
|
|
@@ -163,7 +258,8 @@ for obj in to_delete:
|
|
|
163
258
|
print(f"[gds-db-offsite] pruned {obj['Key']}")
|
|
164
259
|
|
|
165
260
|
kept = min(len(objects), keep)
|
|
166
|
-
print(f"[gds-db-offsite] ok · {kept} offsite dump(s) retained · pruned {len(to_delete)}"
|
|
261
|
+
print(f"[gds-db-offsite] ok · {kept} offsite dump(s) retained · pruned {len(to_delete)}"
|
|
262
|
+
+ (f" · purged {len(legacy)} unencrypted legacy dump(s)" if legacy else ""))
|
|
167
263
|
PYEOF
|
|
168
264
|
|
|
169
265
|
echo "[$(date -u +%FT%TZ)] gds-db-offsite: done"
|
|
@@ -171,6 +171,14 @@ const INTEGRATION = new Set([
|
|
|
171
171
|
// real engine can show the CASE guard leaving a NULL alone on an actual use,
|
|
172
172
|
// which is exactly where the removed timeout would come back.
|
|
173
173
|
'auth_session_expiry_db',
|
|
174
|
+
// task 1003372 (audit A1, core_249): that hashing the bearer at rest does not
|
|
175
|
+
// sign anybody out. The DB-free sibling (session_token_hash) stands in front of
|
|
176
|
+
// the pool and proves the plaintext never reaches a query — the negative that
|
|
177
|
+
// matters — but it cannot show that Postgres accepts the migration, nor that
|
|
178
|
+
// the digest the backfill computes in SQL equals the one the kernel computes in
|
|
179
|
+
// Node. That mismatch would leave every new session working and every migrated
|
|
180
|
+
// one dead, which is the failure a source-reading test walks straight past.
|
|
181
|
+
'session_token_hash_db',
|
|
174
182
|
'scouting_rollup_fanout', 'session_records',
|
|
175
183
|
// task 1003217: the cost-row RAISE against real SQL. Every claim in it IS the
|
|
176
184
|
// SQL — an ON CONFLICT DO UPDATE guarded to move a row only upward, and a
|
package/src/bongos/db-kernel.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
'use strict';
|
|
25
25
|
|
|
26
|
+
const crypto = require('node:crypto');
|
|
26
27
|
const { pool } = require('./pool');
|
|
27
28
|
const seams = require('../module-seams');
|
|
28
29
|
|
|
@@ -277,8 +278,37 @@ async function accessStateByLogin(login) {
|
|
|
277
278
|
// code agree on one number.
|
|
278
279
|
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
279
280
|
|
|
281
|
+
// sessionTokenHash — the ONE place a bearer becomes the value this table stores
|
|
282
|
+
// (task 1003372, audit A1, migration core_249). SHA-256 hex, matching the
|
|
283
|
+
// migration's `encode(sha256(token::bytea), 'hex')` byte for byte, so a row
|
|
284
|
+
// backfilled in SQL and a row minted here resolve identically.
|
|
285
|
+
//
|
|
286
|
+
// WHY A FAST HASH. A session token is 256 bits of CSPRNG, not a password: there
|
|
287
|
+
// is no dictionary to run, so a KDF would buy nothing and cost real latency on
|
|
288
|
+
// every authenticated request. Same reasoning, written out at length, in
|
|
289
|
+
// modules/platform-identity/platform-identity.js (`sha256hex`).
|
|
290
|
+
//
|
|
291
|
+
// WHY NO timingSafeEqual HERE, THOUGH THE TASK NAMED IT. The pattern the task
|
|
292
|
+
// points at compares a presented secret against one it already fetched by
|
|
293
|
+
// client_id, so the comparison happens in JS and must be constant-time. A session
|
|
294
|
+
// has no second identifier — the bearer IS the lookup key — so the comparison is
|
|
295
|
+
// the indexed `WHERE token_hash = $1` below, and there is no JS compare to make
|
|
296
|
+
// constant-time. Adding a timingSafeEqual after that lookup would compare the
|
|
297
|
+
// hash to itself: vacuously true, and worse than nothing, because it would read
|
|
298
|
+
// as a control that is doing something. The property the task wanted — a guessed
|
|
299
|
+
// token leaks no information about how close it was — holds here because the
|
|
300
|
+
// index is probed with a full digest, never a prefix.
|
|
301
|
+
function sessionTokenHash(token) {
|
|
302
|
+
return crypto.createHash('sha256').update(String(token), 'utf8').digest('hex');
|
|
303
|
+
}
|
|
304
|
+
|
|
280
305
|
// createSession — mint a builder_sessions row.
|
|
281
306
|
//
|
|
307
|
+
// THE PLAINTEXT BEARER IS NEVER STORED (task 1003372). `token` is hashed on the
|
|
308
|
+
// way in and the column that held it is gone (core_249). The raw value is
|
|
309
|
+
// returned to the caller — it is their input, handed back so the mint response
|
|
310
|
+
// can carry it to the client — and after that it exists only in the client.
|
|
311
|
+
//
|
|
282
312
|
// A null/undefined expiresAt means NO EXPIRY (expires_at NULL), which
|
|
283
313
|
// lookupSession honours as never-expires. That is the CLI's contract (ADR 0294,
|
|
284
314
|
// restoring 003_pms.sql's original one): a `bongos` sign-in lasts until it is
|
|
@@ -290,12 +320,15 @@ const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
|
290
320
|
async function createSession({ builderId, token, source, userAgent, expiresAt }) {
|
|
291
321
|
const effectiveExpiresAt = expiresAt ?? null;
|
|
292
322
|
const { rows } = await pool.query(
|
|
293
|
-
`INSERT INTO builder_sessions (builder_id,
|
|
323
|
+
`INSERT INTO builder_sessions (builder_id, token_hash, source, user_agent, expires_at)
|
|
294
324
|
VALUES ($1, $2, $3, $4, $5)
|
|
295
|
-
RETURNING id, builder_id,
|
|
296
|
-
[builderId, token, source, userAgent ?? null, effectiveExpiresAt]
|
|
325
|
+
RETURNING id, builder_id, source, created_at, expires_at`,
|
|
326
|
+
[builderId, sessionTokenHash(token), source, userAgent ?? null, effectiveExpiresAt]
|
|
297
327
|
);
|
|
298
|
-
|
|
328
|
+
// `token` is re-attached from the ARGUMENT, never read back from the row — the
|
|
329
|
+
// row no longer holds it. Callers that pass a token and read one back are
|
|
330
|
+
// unchanged; what changed is that the database never saw the plaintext.
|
|
331
|
+
return { ...rows[0], token };
|
|
299
332
|
}
|
|
300
333
|
|
|
301
334
|
// lookupSession — resolve a live (unrevoked, unexpired) session token to its
|
|
@@ -346,8 +379,8 @@ async function lookupSession(token) {
|
|
|
346
379
|
b.rank, b.status, b.system_role, b.anthropic_user_id${sessionPermsSelect()}
|
|
347
380
|
FROM builder_sessions s
|
|
348
381
|
JOIN builders b ON b.id = s.builder_id
|
|
349
|
-
WHERE s.
|
|
350
|
-
[token]
|
|
382
|
+
WHERE s.token_hash = $1`,
|
|
383
|
+
[sessionTokenHash(token)]
|
|
351
384
|
);
|
|
352
385
|
const row = rows[0];
|
|
353
386
|
if (!row) return null;
|
|
@@ -376,6 +409,9 @@ async function lookupSession(token) {
|
|
|
376
409
|
module.exports = {
|
|
377
410
|
withTx,
|
|
378
411
|
sessionPermsSelect,
|
|
412
|
+
// Exported for db.js's revokeSession (which is handed a bearer, not an id) and
|
|
413
|
+
// for the tests that assert the plaintext never reaches a query parameter.
|
|
414
|
+
sessionTokenHash,
|
|
379
415
|
getBuilderById,
|
|
380
416
|
getFoundingBuilder,
|
|
381
417
|
listActiveBuilders,
|
package/src/bongos/db.js
CHANGED
|
@@ -44,6 +44,9 @@ const {
|
|
|
44
44
|
// internal uses (setBuilderRank's RANKS.indexOf, etc.) stay byte-identical.
|
|
45
45
|
RANKS, SESSION_TTL_MS, upsertBuilder, seatFirstAdminIfUnclaimed,
|
|
46
46
|
getBuilderByGithubId, hasInvitedAccessRequest, createSession, lookupSession,
|
|
47
|
+
// The bearer → stored-value transform (task 1003372, core_249). revokeSession
|
|
48
|
+
// below is the one helper here still handed a raw bearer rather than an id.
|
|
49
|
+
sessionTokenHash,
|
|
47
50
|
} = require('./db-kernel');
|
|
48
51
|
|
|
49
52
|
// gradePassCount removed with builderRollingAvgGrade (its only core user) — that
|
|
@@ -728,10 +731,14 @@ async function hasCliSession(builderId) {
|
|
|
728
731
|
// lookupSession — carved to ./db-kernel (ADR 0102). Re-exported above.
|
|
729
732
|
|
|
730
733
|
|
|
734
|
+
// Takes the raw bearer (its callers are sign-out paths holding a cookie or an
|
|
735
|
+
// Authorization header, not a session id) and matches on the stored hash —
|
|
736
|
+
// core_249 dropped the plaintext column, so the bearer itself is no longer
|
|
737
|
+
// something this table can be searched by.
|
|
731
738
|
async function revokeSession(token) {
|
|
732
739
|
await pool.query(
|
|
733
|
-
`UPDATE builder_sessions SET revoked_at = now() WHERE
|
|
734
|
-
[token]
|
|
740
|
+
`UPDATE builder_sessions SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL`,
|
|
741
|
+
[sessionTokenHash(token)]
|
|
735
742
|
);
|
|
736
743
|
}
|
|
737
744
|
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.889'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -67,7 +67,7 @@ await pool.query(`
|
|
|
67
67
|
display_name text, avatar_url text, total_credits int DEFAULT 0, rank text DEFAULT 'archon',
|
|
68
68
|
status text DEFAULT 'active', system_role text, anthropic_user_id text);
|
|
69
69
|
CREATE TABLE builder_sessions (id bigserial PRIMARY KEY, builder_id bigint REFERENCES builders(id),
|
|
70
|
-
|
|
70
|
+
token_hash text UNIQUE, source text, user_agent text, created_at timestamptz DEFAULT now(),
|
|
71
71
|
last_used_at timestamptz DEFAULT now(), expires_at timestamptz, revoked_at timestamptz);
|
|
72
72
|
INSERT INTO builders (github_login) VALUES ('probe');
|
|
73
73
|
`);
|
|
@@ -86,6 +86,11 @@ const t = async (name, fn) => {
|
|
|
86
86
|
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
87
87
|
};
|
|
88
88
|
const row = async (sql, p) => (await client.query(sql, p)).rows[0];
|
|
89
|
+
// core_249: the bearer is no longer a column, so a probe that wants "the row for
|
|
90
|
+
// this token" has to ask by the stored digest. Uses the kernel's OWN helper, so a
|
|
91
|
+
// change to the hash cannot leave this file quietly matching nothing — a mismatch
|
|
92
|
+
// would make every assertion below read a missing row and fail loudly.
|
|
93
|
+
const h = (token) => kernel.sessionTokenHash(token);
|
|
89
94
|
// refresh-on-use is fire-and-forget: let the UPDATE land before reading it back.
|
|
90
95
|
const settle = () => new Promise((r) => setTimeout(r, 300));
|
|
91
96
|
|
|
@@ -110,67 +115,67 @@ await t('lookupSession resolves a CLI bearer to its builder', async () => {
|
|
|
110
115
|
await t('after a real use, a CLI session STILL has no expiry (the CASE guard)', async () => {
|
|
111
116
|
await kernel.lookupSession('cli-tok');
|
|
112
117
|
await settle();
|
|
113
|
-
assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE
|
|
118
|
+
assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('cli-tok')}'`)).expires_at, null);
|
|
114
119
|
});
|
|
115
120
|
|
|
116
121
|
await t('refresh-on-use still bumps last_used_at on that same row', async () => {
|
|
117
|
-
const before = (await row(`SELECT last_used_at FROM builder_sessions WHERE
|
|
118
|
-
await client.query(`UPDATE builder_sessions SET last_used_at = now() - interval '1 hour' WHERE
|
|
122
|
+
const before = (await row(`SELECT last_used_at FROM builder_sessions WHERE token_hash='${h('cli-tok')}'`)).last_used_at;
|
|
123
|
+
await client.query(`UPDATE builder_sessions SET last_used_at = now() - interval '1 hour' WHERE token_hash='${h('cli-tok')}'`);
|
|
119
124
|
await kernel.lookupSession('cli-tok');
|
|
120
125
|
await settle();
|
|
121
|
-
const after = (await row(`SELECT last_used_at FROM builder_sessions WHERE
|
|
126
|
+
const after = (await row(`SELECT last_used_at FROM builder_sessions WHERE token_hash='${h('cli-tok')}'`)).last_used_at;
|
|
122
127
|
assert.ok(after > new Date(Date.now() - 60_000), `last_used_at did not move: ${after} (was ${before})`);
|
|
123
128
|
});
|
|
124
129
|
|
|
125
130
|
await t('a web session still rolls its expiry forward (refresh-on-use intact)', async () => {
|
|
126
131
|
await kernel.lookupSession('web-tok');
|
|
127
132
|
await settle();
|
|
128
|
-
const got = (await row(`SELECT expires_at FROM builder_sessions WHERE
|
|
133
|
+
const got = (await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('web-tok')}'`)).expires_at;
|
|
129
134
|
assert.ok(got !== null && got < thirtyDays, `expected a rolled-forward date below 30d, got ${got}`);
|
|
130
135
|
});
|
|
131
136
|
|
|
132
137
|
await t('an expired session is still REFUSED (expiry honoured where present)', async () => {
|
|
133
|
-
await client.query(`INSERT INTO builder_sessions (builder_id,
|
|
134
|
-
VALUES (1,'dead-tok','web', now() - interval '1 hour')`);
|
|
138
|
+
await client.query(`INSERT INTO builder_sessions (builder_id, token_hash, source, expires_at)
|
|
139
|
+
VALUES (1,'${h('dead-tok')}','web', now() - interval '1 hour')`);
|
|
135
140
|
assert.equal(await kernel.lookupSession('dead-tok'), null);
|
|
136
141
|
});
|
|
137
142
|
|
|
138
143
|
await t('a revoked CLI session is still REFUSED', async () => {
|
|
139
|
-
await client.query(`INSERT INTO builder_sessions (builder_id,
|
|
140
|
-
VALUES (1,'revoked-tok','cli', now())`);
|
|
144
|
+
await client.query(`INSERT INTO builder_sessions (builder_id, token_hash, source, revoked_at)
|
|
145
|
+
VALUES (1,'${h('revoked-tok')}','cli', now())`);
|
|
141
146
|
assert.equal(await kernel.lookupSession('revoked-tok'), null);
|
|
142
147
|
});
|
|
143
148
|
|
|
144
149
|
// The migration, against rows shaped like the ones live instances actually hold.
|
|
145
150
|
const migrationSql = readFileSync(MIGRATION, 'utf8');
|
|
146
|
-
await client.query(`INSERT INTO builder_sessions (builder_id,
|
|
147
|
-
(1,'old-cli','cli', now() + interval '24 hours'),
|
|
148
|
-
(1,'old-web','web', now() + interval '30 days'),
|
|
149
|
-
(1,'old-revoked','cli', now() + interval '24 hours')`);
|
|
150
|
-
await client.query(`UPDATE builder_sessions SET revoked_at = now() WHERE
|
|
151
|
+
await client.query(`INSERT INTO builder_sessions (builder_id, token_hash, source, expires_at) VALUES
|
|
152
|
+
(1,'${h('old-cli')}','cli', now() + interval '24 hours'),
|
|
153
|
+
(1,'${h('old-web')}','web', now() + interval '30 days'),
|
|
154
|
+
(1,'${h('old-revoked')}','cli', now() + interval '24 hours')`);
|
|
155
|
+
await client.query(`UPDATE builder_sessions SET revoked_at = now() WHERE token_hash='${h('old-revoked')}'`);
|
|
151
156
|
await client.query(migrationSql);
|
|
152
157
|
|
|
153
158
|
await t('core_243 clears a pre-existing CLI expiry', async () => {
|
|
154
|
-
assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE
|
|
159
|
+
assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('old-cli')}'`)).expires_at, null);
|
|
155
160
|
});
|
|
156
161
|
|
|
157
162
|
await t('core_243 leaves a WEB row dated — the source filter is its safety property', async () => {
|
|
158
|
-
assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE
|
|
163
|
+
assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('old-web')}'`)).expires_at, null);
|
|
159
164
|
});
|
|
160
165
|
|
|
161
166
|
await t('core_243 leaves a revoked row alone (it is the audit trail)', async () => {
|
|
162
|
-
assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE
|
|
167
|
+
assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('old-revoked')}'`)).expires_at, null);
|
|
163
168
|
});
|
|
164
169
|
|
|
165
170
|
await t('core_243 is idempotent — a re-run changes nothing', async () => {
|
|
166
171
|
await client.query(migrationSql);
|
|
167
|
-
assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE
|
|
168
|
-
assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE
|
|
172
|
+
assert.equal((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('old-cli')}'`)).expires_at, null);
|
|
173
|
+
assert.notEqual((await row(`SELECT expires_at FROM builder_sessions WHERE token_hash='${h('old-web')}'`)).expires_at, null);
|
|
169
174
|
});
|
|
170
175
|
|
|
171
176
|
// The whole point of the task, stated as a test.
|
|
172
177
|
await t('a CLI session idle for 21 DAYS still authenticates', async () => {
|
|
173
|
-
await client.query(`UPDATE builder_sessions SET last_used_at = now() - interval '21 days' WHERE
|
|
178
|
+
await client.query(`UPDATE builder_sessions SET last_used_at = now() - interval '21 days' WHERE token_hash='${h('cli-tok')}'`);
|
|
174
179
|
assert.notEqual(await kernel.lookupSession('cli-tok'), null);
|
|
175
180
|
});
|
|
176
181
|
|
|
@@ -42,6 +42,9 @@ if (!process.env.DATABASE_URL) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
const { Pool } = require('pg');
|
|
45
|
+
// The kernel's own bearer → stored-digest transform (core_249), so this fixture
|
|
46
|
+
// cannot drift from how production mints a session.
|
|
47
|
+
const { sessionTokenHash } = require('../src/bongos/db-kernel.js');
|
|
45
48
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
46
49
|
|
|
47
50
|
// ── THE READINESS GUARD, AND WHY ITS SHAPE MATTERS (task 1003091) ────────────
|
|
@@ -137,9 +140,12 @@ async function seed() {
|
|
|
137
140
|
[ARCHON, THETES]
|
|
138
141
|
);
|
|
139
142
|
await q(
|
|
140
|
-
|
|
143
|
+
// core_249: the bearer is stored as its SHA-256, so a fixture session has to
|
|
144
|
+
// be seeded the same way the kernel mints one — otherwise the HTTP calls
|
|
145
|
+
// below authenticate as nobody.
|
|
146
|
+
`INSERT INTO builder_sessions (builder_id, token_hash, source)
|
|
141
147
|
VALUES ($1,$2,'web'), ($3,$4,'web')`,
|
|
142
|
-
[ARCHON, ARCHON_TOKEN, THETES, THETES_TOKEN]
|
|
148
|
+
[ARCHON, sessionTokenHash(ARCHON_TOKEN), THETES, sessionTokenHash(THETES_TOKEN)]
|
|
143
149
|
);
|
|
144
150
|
const [idea] = await q(
|
|
145
151
|
`INSERT INTO idea_inbox (title, body_md, captured_by)
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
// Run: node tests/own_session_revoke.mjs
|
|
31
31
|
|
|
32
32
|
import { strict as assert } from 'node:assert';
|
|
33
|
+
import crypto from 'node:crypto';
|
|
33
34
|
import { createRequire } from 'node:module';
|
|
34
35
|
import http from 'node:http';
|
|
35
36
|
import { readFileSync } from 'node:fs';
|
|
@@ -37,6 +38,13 @@ import { fileURLToPath } from 'node:url';
|
|
|
37
38
|
import { dirname, join } from 'node:path';
|
|
38
39
|
|
|
39
40
|
const require = createRequire(import.meta.url);
|
|
41
|
+
// core_249: the bearer is stored and matched as its SHA-256. Computed here with
|
|
42
|
+
// node:crypto rather than imported from db-kernel — requiring the kernel at this
|
|
43
|
+
// point would bind the REAL pool before the fake below is installed in the
|
|
44
|
+
// require cache, and every case would then try to reach Postgres. The two cannot
|
|
45
|
+
// drift unnoticed: tests/session_token_hash.mjs pins the kernel's helper against
|
|
46
|
+
// exactly this expression.
|
|
47
|
+
const sessionTokenHash = (t) => crypto.createHash('sha256').update(String(t), 'utf8').digest('hex');
|
|
40
48
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
41
49
|
|
|
42
50
|
let passed = 0;
|
|
@@ -124,7 +132,11 @@ function predicateFor(sql, params) {
|
|
|
124
132
|
// would tear it in half and leave a dangling `OR` behind.
|
|
125
133
|
take(/\(?expires_at IS NULL OR expires_at > now\(\)\)?/,
|
|
126
134
|
() => (r) => r.expires_at === null || r.expires_at > new Date());
|
|
127
|
-
|
|
135
|
+
// core_249: the bearer is matched as its SHA-256, never as itself. The fixture
|
|
136
|
+
// rows below carry `token` as the plaintext a client would present, so the
|
|
137
|
+
// predicate hashes the row rather than the parameter — which also means a
|
|
138
|
+
// regression to `token = $1` stops matching here instead of quietly passing.
|
|
139
|
+
take(/\b(?:s\.)?token_hash = \$(\d+)/, (v) => (r) => sessionTokenHash(r.token) === v);
|
|
128
140
|
take(/(?<!_)\b(?:s\.)?id = \$(\d+)/, (v) => (r) => String(r.id) === String(v));
|
|
129
141
|
take(/\bbuilder_id = \$(\d+)/, (v) => (r) => String(r.builder_id) === String(v));
|
|
130
142
|
take(/\brevoked_at IS NULL/, () => (r) => r.revoked_at === null);
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// tests/session_token_hash.mjs — core_249 / task 1003372 (audit A1). DB-FREE.
|
|
2
|
+
//
|
|
3
|
+
// The property this file exists to hold is NEGATIVE: the plaintext bearer must
|
|
4
|
+
// never reach the database. A negative is exactly what a green test suite is
|
|
5
|
+
// worst at — the code can stop hashing and every functional assertion still
|
|
6
|
+
// passes, because a system that stores and compares plaintext works perfectly.
|
|
7
|
+
// So this file does not check that sessions resolve (the DB sibling does that);
|
|
8
|
+
// it checks what the kernel HANDS THE POOL, by standing in front of it and
|
|
9
|
+
// reading every query and parameter that goes past.
|
|
10
|
+
//
|
|
11
|
+
// The real-DB sibling `session_token_hash_db.mjs` proves the halves this cannot:
|
|
12
|
+
// that Postgres accepts the migration, that the SQL digest equals the Node one,
|
|
13
|
+
// and that a bearer minted before the migration still authenticates after it.
|
|
14
|
+
// It self-skips without a database, and a skipped case is not a passing one —
|
|
15
|
+
// which is the whole reason this file is not optional.
|
|
16
|
+
//
|
|
17
|
+
// Run: node tests/session_token_hash.mjs
|
|
18
|
+
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
import { strict as assert } from 'node:assert';
|
|
24
|
+
import crypto from 'node:crypto';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
|
|
29
|
+
// Stand in front of the pool BEFORE the kernel binds it, and record everything.
|
|
30
|
+
const seen = [];
|
|
31
|
+
const poolPath = require.resolve('../src/bongos/pool.js');
|
|
32
|
+
require.cache[poolPath] = {
|
|
33
|
+
id: poolPath, filename: poolPath, loaded: true,
|
|
34
|
+
exports: {
|
|
35
|
+
pool: {
|
|
36
|
+
query: async (text, params) => {
|
|
37
|
+
seen.push({ text, params: params || [] });
|
|
38
|
+
return { rows: [], rowCount: 0 };
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
const kernel = require('../src/bongos/db-kernel.js');
|
|
44
|
+
|
|
45
|
+
let passed = 0, failed = 0;
|
|
46
|
+
const t = async (name, fn) => {
|
|
47
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
48
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const BEARER = 'a-perfectly-ordinary-looking-bearer-token-5f21c9';
|
|
52
|
+
const DIGEST = crypto.createHash('sha256').update(BEARER, 'utf8').digest('hex');
|
|
53
|
+
|
|
54
|
+
await t('sessionTokenHash is SHA-256 hex, pinned against an independent digest', () => {
|
|
55
|
+
assert.equal(kernel.sessionTokenHash(BEARER), DIGEST);
|
|
56
|
+
assert.equal(kernel.sessionTokenHash(BEARER).length, 64);
|
|
57
|
+
assert.match(kernel.sessionTokenHash(BEARER), /^[0-9a-f]{64}$/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await t('it is the SAME digest the migration computes, spelled the SQL way', () => {
|
|
61
|
+
// Not a re-derivation of the value — a check that the migration's expression
|
|
62
|
+
// is the one this hash corresponds to. If someone changes either side to
|
|
63
|
+
// sha512, base64, or a salted form, the two stop agreeing and every session
|
|
64
|
+
// that predates the deploy dies. The DB sibling proves the values match by
|
|
65
|
+
// running both; this catches the edit that causes it.
|
|
66
|
+
const sql = readFileSync(join(here, '..', 'migrations', 'core_249_session_token_hash.sql'), 'utf8');
|
|
67
|
+
assert.match(sql, /encode\(\s*sha256\(\s*token::bytea\s*\)\s*,\s*'hex'\s*\)/,
|
|
68
|
+
'the backfill no longer computes sha256-hex of the plaintext');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await t('THE NEGATIVE: createSession never hands the pool the plaintext', async () => {
|
|
72
|
+
seen.length = 0;
|
|
73
|
+
await kernel.createSession({ builderId: '1', token: BEARER, source: 'cli', userAgent: 'probe', expiresAt: null });
|
|
74
|
+
assert.equal(seen.length, 1, 'expected exactly one query');
|
|
75
|
+
const [q] = seen;
|
|
76
|
+
assert.ok(!q.params.includes(BEARER), 'the plaintext bearer was passed as a query parameter');
|
|
77
|
+
assert.ok(q.params.includes(DIGEST), 'the digest was not passed');
|
|
78
|
+
assert.ok(!q.text.includes('token,') && !/\btoken\b\s*=/.test(q.text),
|
|
79
|
+
'the statement still names the dropped plaintext column');
|
|
80
|
+
assert.match(q.text, /token_hash/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await t('THE NEGATIVE: lookupSession never hands the pool the plaintext', async () => {
|
|
84
|
+
seen.length = 0;
|
|
85
|
+
await kernel.lookupSession(BEARER);
|
|
86
|
+
const read = seen[0];
|
|
87
|
+
assert.ok(read, 'no query was issued');
|
|
88
|
+
assert.ok(!read.params.includes(BEARER), 'the plaintext bearer was used as the lookup key');
|
|
89
|
+
assert.deepEqual(read.params, [DIGEST]);
|
|
90
|
+
assert.match(read.text, /s\.token_hash = \$1/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await t('THE NEGATIVE: revokeSession never hands the pool the plaintext', async () => {
|
|
94
|
+
seen.length = 0;
|
|
95
|
+
const db = require('../src/bongos/db.js');
|
|
96
|
+
await db.revokeSession(BEARER);
|
|
97
|
+
const q = seen.find((s) => /revoked_at = now\(\)/.test(s.text));
|
|
98
|
+
assert.ok(q, 'no revoke statement was issued');
|
|
99
|
+
assert.ok(!q.params.includes(BEARER), 'revoke still matches on the plaintext bearer');
|
|
100
|
+
assert.deepEqual(q.params, [DIGEST]);
|
|
101
|
+
assert.match(q.text, /token_hash = \$1/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await t('the probe would CATCH a regression — it is not vacuously green', async () => {
|
|
105
|
+
// A negative assertion is worth nothing unless it can fail. This runs the same
|
|
106
|
+
// checks against a deliberately-wrong call (the plaintext passed straight
|
|
107
|
+
// through) and requires them to reject it. Without this, a stub that recorded
|
|
108
|
+
// nothing, or an `includes` against the wrong array, would pass every case
|
|
109
|
+
// above while the kernel stored plaintext.
|
|
110
|
+
seen.length = 0;
|
|
111
|
+
const fakePool = { query: async (text, params) => { seen.push({ text, params }); return { rows: [] }; } };
|
|
112
|
+
await fakePool.query('SELECT 1 FROM builder_sessions WHERE token = $1', [BEARER]);
|
|
113
|
+
const bad = seen[0];
|
|
114
|
+
assert.ok(bad.params.includes(BEARER), 'the recorder is not capturing parameters at all');
|
|
115
|
+
assert.ok(!bad.params.includes(DIGEST), 'the recorder would accept a plaintext query as hashed');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await t('the migration drops the plaintext column rather than emptying it', () => {
|
|
119
|
+
const sql = readFileSync(join(here, '..', 'migrations', 'core_249_session_token_hash.sql'), 'utf8');
|
|
120
|
+
assert.match(sql, /ALTER TABLE builder_sessions DROP COLUMN token\b/);
|
|
121
|
+
assert.match(sql, /CREATE UNIQUE INDEX IF NOT EXISTS ux_builder_sessions_token_hash/);
|
|
122
|
+
assert.match(sql, /DROP INDEX IF EXISTS idx_builder_sessions_token/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await t('the migration does NOT touch expiry — ADR 0294 and ADR 0206 stand', () => {
|
|
126
|
+
// The audit also asked for refresh-on-use to stop and an absolute session
|
|
127
|
+
// lifetime to be added. Both were decided the other way, by the owner, AFTER
|
|
128
|
+
// the audit was written: ADR 0294 keeps the roll-forward for rows that have an
|
|
129
|
+
// expiry, and ADR 0206 + ADR 0294 both reject an absolute ceiling by name. A
|
|
130
|
+
// hashing migration that quietly reintroduced either would be the tail wagging
|
|
131
|
+
// the dog, so the absence is asserted rather than left to reading.
|
|
132
|
+
const sql = readFileSync(join(here, '..', 'migrations', 'core_249_session_token_hash.sql'), 'utf8');
|
|
133
|
+
const statements = sql.split('\n').filter((l) => !l.trim().startsWith('--')).join('\n');
|
|
134
|
+
assert.ok(!/expires_at/.test(statements),
|
|
135
|
+
'core_249 writes expires_at — that belongs to ADR 0294, not to a hashing migration');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
console.log(`\nsession_token_hash: ${passed} passed, ${failed} failed`);
|
|
139
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// tests/session_token_hash_db.mjs — core_249 / task 1003372 (audit A1, real DB).
|
|
2
|
+
//
|
|
3
|
+
// The bearer stored in `builder_sessions` was plaintext, NOT NULL UNIQUE and
|
|
4
|
+
// INDEXED: every row was a directly replayable credential. This file proves the
|
|
5
|
+
// two properties that fix has to carry, and they pull against each other:
|
|
6
|
+
//
|
|
7
|
+
// 1. NOTHING REPLAYABLE IS LEFT. The plaintext column is gone, and a dump of
|
|
8
|
+
// the table yields no value that authenticates.
|
|
9
|
+
// 2. NOBODY IS SIGNED OUT. A session minted BEFORE the migration still
|
|
10
|
+
// authenticates AFTER it, with the same bearer its holder is still carrying.
|
|
11
|
+
//
|
|
12
|
+
// (2) is the one worth a real database. It is the whole reason the migration
|
|
13
|
+
// backfills instead of letting sessions re-mint, and it can only be proved by
|
|
14
|
+
// running the actual SQL against rows in the actual old shape. A mistake here
|
|
15
|
+
// does not fail loudly in review — it signs out every builder and every dev box
|
|
16
|
+
// on deploy day, including whoever would have to log in to fix it.
|
|
17
|
+
//
|
|
18
|
+
// The sibling `session_token_hash.mjs` is DB-free and pins the hash itself plus
|
|
19
|
+
// the statements' shape. What it cannot see is whether Postgres accepts the
|
|
20
|
+
// migration at all, or whether the digest the migration computes in SQL equals
|
|
21
|
+
// the one the kernel computes in Node — a mismatch that would leave every
|
|
22
|
+
// pre-existing session dead while every new one worked, which is exactly the
|
|
23
|
+
// failure a code-only test reads straight past.
|
|
24
|
+
//
|
|
25
|
+
// Real-DB integration test: self-skips without a reachable Postgres.
|
|
26
|
+
//
|
|
27
|
+
// Run: DATABASE_URL=... node tests/session_token_hash_db.mjs
|
|
28
|
+
|
|
29
|
+
import { createRequire } from 'node:module';
|
|
30
|
+
import { readFileSync } from 'node:fs';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
import { dirname, join } from 'node:path';
|
|
33
|
+
import { strict as assert } from 'node:assert';
|
|
34
|
+
|
|
35
|
+
const require = createRequire(import.meta.url);
|
|
36
|
+
const { Pool } = require('pg');
|
|
37
|
+
|
|
38
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
39
|
+
const MIGRATION = join(here, '..', 'migrations', 'core_249_session_token_hash.sql');
|
|
40
|
+
|
|
41
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
42
|
+
try {
|
|
43
|
+
await pool.query('SELECT 1');
|
|
44
|
+
} catch (e) {
|
|
45
|
+
console.log(`session_token_hash_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
46
|
+
await pool.end().catch(() => {});
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const SCHEMA = `tokhash_probe_${process.pid}`;
|
|
51
|
+
await pool.query(`CREATE SCHEMA ${SCHEMA}`);
|
|
52
|
+
await pool.query(`SET search_path TO ${SCHEMA}`);
|
|
53
|
+
|
|
54
|
+
// The table in its PRE-migration shape — plaintext `token`, NOT NULL UNIQUE,
|
|
55
|
+
// indexed. Copied from migrations/003_pms.sql so the migration is exercised
|
|
56
|
+
// against what it will actually meet on a live instance.
|
|
57
|
+
await pool.query(`
|
|
58
|
+
CREATE TABLE builders (id bigserial PRIMARY KEY, github_id text, github_login text,
|
|
59
|
+
display_name text, avatar_url text, total_credits int DEFAULT 0, rank text DEFAULT 'archon',
|
|
60
|
+
status text DEFAULT 'active', system_role text, anthropic_user_id text);
|
|
61
|
+
CREATE TABLE builder_sessions (id bigserial PRIMARY KEY, builder_id bigint REFERENCES builders(id),
|
|
62
|
+
token text NOT NULL UNIQUE, source text, user_agent text, created_at timestamptz DEFAULT now(),
|
|
63
|
+
last_used_at timestamptz DEFAULT now(), expires_at timestamptz, revoked_at timestamptz);
|
|
64
|
+
CREATE INDEX idx_builder_sessions_token ON builder_sessions (token);
|
|
65
|
+
INSERT INTO builders (github_login) VALUES ('probe');
|
|
66
|
+
`);
|
|
67
|
+
|
|
68
|
+
const client = await pool.connect();
|
|
69
|
+
await client.query(`SET search_path TO ${SCHEMA}`);
|
|
70
|
+
const poolPath = require.resolve('../src/bongos/pool.js');
|
|
71
|
+
require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: client } };
|
|
72
|
+
const kernel = require('../src/bongos/db-kernel.js');
|
|
73
|
+
|
|
74
|
+
let passed = 0, failed = 0;
|
|
75
|
+
const t = async (name, fn) => {
|
|
76
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
77
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
78
|
+
};
|
|
79
|
+
const row = async (sql, p) => (await client.query(sql, p)).rows[0];
|
|
80
|
+
|
|
81
|
+
// Sessions that exist BEFORE the migration, written the old way. These stand in
|
|
82
|
+
// for every builder holding a live bearer on deploy day.
|
|
83
|
+
const LIVE_CLI = 'pre-existing-cli-bearer-2f8a';
|
|
84
|
+
const LIVE_WEB = 'pre-existing-web-bearer-91cd';
|
|
85
|
+
await client.query(
|
|
86
|
+
`INSERT INTO builder_sessions (builder_id, token, source, expires_at)
|
|
87
|
+
VALUES (1,$1,'cli',NULL), (1,$2,'web', now() + interval '30 days')`,
|
|
88
|
+
[LIVE_CLI, LIVE_WEB]
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// ── the migration, for real ───────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
const migrationSql = readFileSync(MIGRATION, 'utf8');
|
|
94
|
+
await client.query(migrationSql);
|
|
95
|
+
|
|
96
|
+
await t('THE ONE THAT MATTERS: a bearer minted before the migration still authenticates after it', async () => {
|
|
97
|
+
// If this fails, the deploy signs out every builder holding a session — the
|
|
98
|
+
// failure the backfill exists to prevent, and the one nobody can undo from
|
|
99
|
+
// outside because fixing it needs a session.
|
|
100
|
+
const got = await kernel.lookupSession(LIVE_CLI);
|
|
101
|
+
assert.ok(got, 'the pre-existing CLI bearer no longer resolves');
|
|
102
|
+
assert.equal(got.github_login, 'probe');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await t('a pre-existing WEB session survives too, expiry intact', async () => {
|
|
106
|
+
const got = await kernel.lookupSession(LIVE_WEB);
|
|
107
|
+
assert.ok(got, 'the pre-existing web bearer no longer resolves');
|
|
108
|
+
assert.ok(got.expires_at instanceof Date, 'the web row lost its expiry');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await t('the SQL digest equals the Node digest — the two halves agree', async () => {
|
|
112
|
+
// The backfill computes encode(sha256(token::bytea),'hex') in Postgres; the
|
|
113
|
+
// kernel computes sha256 hex in Node. If these ever diverge, new sessions work
|
|
114
|
+
// and every migrated one is dead — a split nothing else here would localise.
|
|
115
|
+
const got = await row(`SELECT token_hash FROM builder_sessions WHERE source='cli'`);
|
|
116
|
+
assert.equal(got.token_hash, kernel.sessionTokenHash(LIVE_CLI));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ── nothing replayable is left ────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
await t('the plaintext column is GONE, not merely emptied', async () => {
|
|
122
|
+
const got = await row(
|
|
123
|
+
`SELECT count(*)::int AS n FROM information_schema.columns
|
|
124
|
+
WHERE table_schema=$1 AND table_name='builder_sessions' AND column_name='token'`,
|
|
125
|
+
[SCHEMA]
|
|
126
|
+
);
|
|
127
|
+
assert.equal(got.n, 0, 'builder_sessions.token still exists');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await t('no stored value authenticates — a dump of the table is inert', async () => {
|
|
131
|
+
// The actual security claim, asserted the way an attacker would test it: take
|
|
132
|
+
// everything the table holds and try to log in with it. Reading token_hash and
|
|
133
|
+
// presenting it must NOT work — if lookupSession hashed nothing, or compared
|
|
134
|
+
// the presented value to itself, this is where that shows.
|
|
135
|
+
const { rows } = await client.query(`SELECT token_hash FROM builder_sessions`);
|
|
136
|
+
assert.ok(rows.length >= 2, 'fixture rows missing — the assertion would be vacuous');
|
|
137
|
+
for (const r of rows) {
|
|
138
|
+
assert.equal(await kernel.lookupSession(r.token_hash), null,
|
|
139
|
+
'a value read straight out of the table authenticated');
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await t('the uniqueness guarantee moved with the column', async () => {
|
|
144
|
+
await assert.rejects(
|
|
145
|
+
() => client.query(
|
|
146
|
+
`INSERT INTO builder_sessions (builder_id, token_hash, source) VALUES (1,$1,'cli')`,
|
|
147
|
+
[kernel.sessionTokenHash(LIVE_CLI)]
|
|
148
|
+
),
|
|
149
|
+
/duplicate key|unique/i,
|
|
150
|
+
'two sessions may not share a bearer'
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ── round-trip through the live code path ─────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
await t('a session minted AFTER the migration resolves, and stores only its digest', async () => {
|
|
157
|
+
const fresh = 'freshly-minted-bearer-77b0';
|
|
158
|
+
await kernel.createSession({ builderId: '1', token: fresh, source: 'cli', userAgent: 'probe', expiresAt: null });
|
|
159
|
+
const got = await kernel.lookupSession(fresh);
|
|
160
|
+
assert.ok(got && got.github_login === 'probe', 'a new session did not resolve');
|
|
161
|
+
const stored = await row(`SELECT token_hash FROM builder_sessions WHERE token_hash = $1`,
|
|
162
|
+
[kernel.sessionTokenHash(fresh)]);
|
|
163
|
+
assert.ok(stored, 'the new row is not keyed on the digest');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await t('createSession hands the caller back the bearer it was given', async () => {
|
|
167
|
+
// The mint response has to carry the plaintext to the client — it is the only
|
|
168
|
+
// copy that will ever exist. It now comes from the ARGUMENT, since the row
|
|
169
|
+
// cannot return it.
|
|
170
|
+
const tok = 'returned-to-caller-a301';
|
|
171
|
+
const s = await kernel.createSession({ builderId: '1', token: tok, source: 'cli', userAgent: 'probe', expiresAt: null });
|
|
172
|
+
assert.equal(s.token, tok);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await t('revokeSession still kills a session it is handed by bearer', async () => {
|
|
176
|
+
const db = require('../src/bongos/db.js');
|
|
177
|
+
const tok = 'to-be-revoked-5e12';
|
|
178
|
+
await kernel.createSession({ builderId: '1', token: tok, source: 'cli', userAgent: 'probe', expiresAt: null });
|
|
179
|
+
assert.ok(await kernel.lookupSession(tok), 'precondition: the session should resolve first');
|
|
180
|
+
await db.revokeSession(tok);
|
|
181
|
+
assert.equal(await kernel.lookupSession(tok), null, 'revoke by bearer no longer reaches the row');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await t('the migration is idempotent — a second run is a no-op, not an error', async () => {
|
|
185
|
+
await client.query(migrationSql);
|
|
186
|
+
assert.ok(await kernel.lookupSession(LIVE_CLI), 'a re-run broke a live session');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
client.release();
|
|
190
|
+
await pool.query(`DROP SCHEMA ${SCHEMA} CASCADE`);
|
|
191
|
+
await pool.end();
|
|
192
|
+
console.log(`\nsession_token_hash_db: ${passed} passed, ${failed} failed`);
|
|
193
|
+
process.exit(failed ? 1 : 0);
|