mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
docs(b2-9,hp-2): security owners closed, HP-2 sign-off scorecard (#1444)
* docs(b2-7): record the #1443 squash SHA in the evidence block * docs(b2-9): SEC-03 sized and re-tagged to B5, verdict recorded * test(e2ee): HP-2 adversarial membership and key-change cases Three cases from docs/trust-model.md that had no dedicated test: a modified server adding an unknown member at first contact (pinned as today's behaviour, a known gap with its RED recorded in the HP-2 scorecard), a second device's key overwriting the one-per-account pin so the first device mismatches, and a peer resumed across a rotation being re-keyed with the rotated key (OC-0316, holder side). The last two were proven able to fail by temporary code mutation. * docs(b2-9): close the owner table; advisory placeholders for SEC-01/SEC-04 * docs(hp-2): protocol and threat-model sign-off scorecard Seven questions answered with commands and their output; B2 exit gate walked (nine conditions). Adds the trust-model anchor checker beside the scorecard, the HP-2 evidence block, and the plan index / roadmap slice updates. Owner lines (reader, review date, decision, signature) are left blank on purpose.
This commit is contained in:
@@ -1842,3 +1842,149 @@ describe("E2EEManager", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── HP-2 question 4: adversarial membership and key-change rules ───────────
|
||||
// Each test pins one rule from docs/trust-model.md §"What is end-to-end
|
||||
// encrypted" that had no dedicated test before HP-2, or records a known gap
|
||||
// so the fix has a RED waiting for it.
|
||||
|
||||
describe("E2EEManager — HP-2 adversarial membership and key-change rules", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockMembers.clear();
|
||||
mockMembers.set(PEER_ID, { identityPublicKey: "peer-identity-b64" });
|
||||
mockVoiceState.voiceUsers.clear();
|
||||
vi.mocked(getIdentityPin).mockResolvedValue({ status: "unpinned" });
|
||||
vi.mocked(storeIdentityPin).mockResolvedValue("stored");
|
||||
});
|
||||
|
||||
it("[HP-2 known gap] a modified server that adds an unknown member at first contact gets the room key wrapped to it", async () => {
|
||||
// Membership is server-controlled and the client accepts any first-sight
|
||||
// identity (verifyPeerAnnounce). A server that inserts a member row it
|
||||
// holds the identity key for, then relays a well-signed announce for it,
|
||||
// is keyed by the holder like any real peer. The client has no
|
||||
// independent membership evidence — the voice roster is server state
|
||||
// too, and here it does not even list the newcomer.
|
||||
//
|
||||
// This pins TODAY's behaviour. When authenticated membership (or
|
||||
// "refuse unrecognised participants") lands, this test goes RED and the
|
||||
// expectations below invert. docs/trust-model.md §"What beta does not
|
||||
// claim" names the gap.
|
||||
const INTRUDER = 99;
|
||||
mockMembers.set(INTRUDER, { identityPublicKey: "server-supplied-identity-b64" });
|
||||
expect(mockVoiceState.voiceUsers.size).toBe(0);
|
||||
const ws = { send: vi.fn() };
|
||||
const mgr = createManager(ws);
|
||||
await mgr.setupKeyExchange(true, 1);
|
||||
ws.send.mockClear();
|
||||
|
||||
await mgr.handleAnnounce(INTRUDER, "aW50cnVkZXI=", "sig-the-server-can-make");
|
||||
|
||||
// Today: keyed, pinned, verified — the RED of the desired rule (no offer,
|
||||
// no pin) is recorded in docs/plans/hp-2-scorecard-2026-08-29.md Q4.
|
||||
const offers = sendsOfType(ws, "voice_e2ee_offer");
|
||||
expect(offers).toHaveLength(1);
|
||||
expect((offers[0] as any).payload.target_user_id).toBe(INTRUDER);
|
||||
expect(mgr.peerPublicKeys.has(INTRUDER)).toBe(true);
|
||||
expect(storeIdentityPin).toHaveBeenCalledWith(
|
||||
"localhost:7880",
|
||||
String(INTRUDER),
|
||||
"server-supplied-identity-b64",
|
||||
);
|
||||
expect(setPeerVerification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: INTRUDER, status: "verified" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("[HP-2] a second device's key, once trusted, overwrites the account pin — the first device then mismatches", async () => {
|
||||
// Pins are one per account ({host}:{userId}) while identity keys are per
|
||||
// install (identity.ts; migration 017 holds one identity_public_key per
|
||||
// user). Trusting device 2 therefore evicts device 1's pin, and device
|
||||
// 1's next announce is blocked as a mismatch. docs/trust-model.md
|
||||
// §"What is end-to-end encrypted" states the flip-flop; this pins it.
|
||||
const DEVICE1 = "device1-identity-b64";
|
||||
const DEVICE2 = "device2-identity-b64";
|
||||
vi.mocked(getIdentityPin).mockResolvedValue({ status: "pinned", pin: DEVICE1 });
|
||||
mockMembers.set(PEER_ID, { identityPublicKey: DEVICE2 }); // server row: last announcer wins
|
||||
const ws = { send: vi.fn() };
|
||||
const mgr = createManager(ws);
|
||||
await mgr.setupKeyExchange(true, 1);
|
||||
ws.send.mockClear();
|
||||
|
||||
// Device 2 announces: pinned key differs → blocked, nothing wrapped.
|
||||
await mgr.handleAnnounce(PEER_ID, "ZGV2aWNlMg==", "sig2");
|
||||
expect(setPeerVerification).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ userId: PEER_ID, status: "mismatch" }),
|
||||
);
|
||||
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(0);
|
||||
|
||||
// The human clicks "Trust new key": the ONE slot is overwritten and the
|
||||
// buffered announce replays against the new pin, so device 2 is keyed.
|
||||
vi.mocked(getIdentityPin).mockResolvedValue({ status: "pinned", pin: DEVICE2 });
|
||||
expect(await mgr.rePinPeerIdentity(PEER_ID, DEVICE2)).toBe(true);
|
||||
expect(storeIdentityPin).toHaveBeenCalledTimes(1);
|
||||
expect(storeIdentityPin).toHaveBeenCalledWith("localhost:7880", String(PEER_ID), DEVICE2);
|
||||
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(1);
|
||||
const importsBefore = vi.mocked(importPublicKey).mock.calls.length;
|
||||
|
||||
// Device 1 comes back (the server row carries its key again) and is the
|
||||
// one that mismatches now — no offer, no key imported, no second pin.
|
||||
mockMembers.set(PEER_ID, { identityPublicKey: DEVICE1 });
|
||||
await mgr.handleAnnounce(PEER_ID, "ZGV2aWNlMQ==", "sig1");
|
||||
expect(setPeerVerification).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ userId: PEER_ID, status: "mismatch" }),
|
||||
);
|
||||
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(1);
|
||||
expect(vi.mocked(importPublicKey).mock.calls.length).toBe(importsBefore);
|
||||
expect(storeIdentityPin).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("[HP-2 / OC-0316] a peer whose socket dropped across a rotation is re-keyed with the rotated key when the server replays its announce", async () => {
|
||||
// Server side: hub.go re-relays the resumed client's stored announce to
|
||||
// the channel (TestRegisterNow_ReannouncesOwnKeyOnResume). Holder side,
|
||||
// pinned here: that replay is a duplicate announce, and the offer it
|
||||
// triggers must carry the CURRENT room key and epoch — not the key the
|
||||
// peer held before its outage.
|
||||
// Round-trip import/export so the replayed announce is recognised as the
|
||||
// SAME ephemeral key (the duplicate path), not a changed one.
|
||||
vi.mocked(importPublicKey).mockImplementation(
|
||||
async (b64: string) => ({ type: `peer-key-${b64}` }) as unknown as CryptoKey,
|
||||
);
|
||||
vi.mocked(exportPublicKey).mockImplementation(async (key: CryptoKey) =>
|
||||
(key as unknown as { type: string }).type.replace("peer-key-", ""),
|
||||
);
|
||||
try {
|
||||
const ws = { send: vi.fn() };
|
||||
const mgr = createManager(ws);
|
||||
await mgr.setupKeyExchange(true, 1);
|
||||
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
||||
expect(mgr.epoch).toBe(1);
|
||||
|
||||
// Peer's WebSocket drops (media stays up, so no participant-left); the
|
||||
// periodic rotation fires meanwhile.
|
||||
await mgr.rotateKeyPeriodically();
|
||||
expect(mgr.epoch).toBe(2);
|
||||
const rotatedKey = (mgr as any)._roomKey as Uint8Array;
|
||||
ws.send.mockClear();
|
||||
vi.mocked(wrapRoomKey).mockClear();
|
||||
vi.mocked(importPublicKey).mockClear();
|
||||
|
||||
// Peer resumes; the server replays its unchanged announce to us.
|
||||
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
|
||||
|
||||
expect(importPublicKey).not.toHaveBeenCalled(); // duplicate, not a new key
|
||||
const offers = sendsOfType(ws, "voice_e2ee_offer");
|
||||
expect(offers).toHaveLength(1);
|
||||
expect((offers[0] as any).payload.target_user_id).toBe(PEER_ID);
|
||||
expect(wrapRoomKey).toHaveBeenCalledTimes(1);
|
||||
const [, , wrappedKey, wrappedEpoch] = vi.mocked(wrapRoomKey).mock.calls[0]!;
|
||||
expect(wrappedKey).toBe(rotatedKey);
|
||||
expect(wrappedEpoch).toBe(2);
|
||||
} finally {
|
||||
vi.mocked(importPublicKey).mockImplementation(
|
||||
async () => ({ type: "public" }) as unknown as CryptoKey,
|
||||
);
|
||||
vi.mocked(exportPublicKey).mockImplementation(async () => "bW9ja2VwaGVtZXJhbA==");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,8 @@ authority**.
|
||||
| [b1-repository-foundation-2026-08-25](b1-repository-foundation-2026-08-25.md) | **B1-0 through B1-8 all done.** B1 execution plan. Re-verifies every RL-\* claim against HEAD; several are refuted. |
|
||||
| [hp-0-scorecard-2026-08-25](hp-0-scorecard-2026-08-25.md) | **HP-0 accepted 2026-08-25.** The single baseline-acceptance artifact. Part-closes `R-08`. |
|
||||
| [hp-1-scorecard-2026-08-27](hp-1-scorecard-2026-08-27.md) | **HP-1 accepted 2026-08-27.** Structural-diff proofs for the flatten and module rename, plus the B1 exit gate. |
|
||||
| [b2-protocol-trust-compat-2026-08-28](b2-protocol-trust-compat-2026-08-28.md) | **B2 in progress from 2026-08-28.** B2-0, B2-1, B2-8 done 2026-08-28; B2-2 done 2026-08-29 (PR #1438, B2-3 and B2-4 folded in). **B2-5 is next**, serialized; B2-6/7/9 in parallel. Ends at HP-2. |
|
||||
| [b2-protocol-trust-compat-2026-08-28](b2-protocol-trust-compat-2026-08-28.md) | **B2 execution complete 2026-08-29, pending HP-2 signature.** B2-0, B2-1, B2-8 done 2026-08-28; B2-2 (B2-3/B2-4 folded in), B2-5, B2-6, B2-7, B2-9 done 2026-08-29. Scorecard below. |
|
||||
| [hp-2-scorecard-2026-08-29](hp-2-scorecard-2026-08-29.md) | **HP-2 awaiting owner signature (written 2026-08-29).** Seven questions answered with commands; B2 exit gate, nine conditions; one E2EE gap recorded and disclaimed. Owner fills the reader, review and signature lines. |
|
||||
| [audit-2026-08-19-remediation](audit-2026-08-19-remediation.md) | Phases 1–6 done 2026-08-20; **phase 7 pending**. Its header still reads "in progress 2026-08-19" — stale; the phase table is correct. |
|
||||
|
||||
## Partially implemented
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
`v1.2.0-alpha.4` — claims verified at `64d2e108`; the branch was rebased
|
||||
onto `dd7ed091` (#1432) before merge
|
||||
**Status:** in progress — entry gate 1 of 3 met at draft time (see below); B2-0,
|
||||
B2-1 and B2-8 landed 2026-08-28, B2-2 (with B2-3 and B2-4 folded in) and B2-5 on 2026-08-29 (evidence in their sections); B2-6 landed 2026-08-29 (PR #1441); B2-7 is in review 2026-08-29 (evidence in its section).
|
||||
B2-1 and B2-8 landed 2026-08-28, B2-2 (with B2-3 and B2-4 folded in) and B2-5 on 2026-08-29 (evidence in their sections); B2-6 landed 2026-08-29 (PR #1441); B2-7 landed 2026-08-29 (PR #1443 = `88c7a824`); B2-9 done and the HP-2 scorecard written 2026-08-29, in review — **HP-2 awaits the owner's signature** ([hp-2-scorecard-2026-08-29.md](hp-2-scorecard-2026-08-29.md)).
|
||||
Update this line, not only the step table, when a step lands.
|
||||
|
||||
Primary inputs:
|
||||
@@ -42,11 +42,11 @@ one to one and a half weeks with agents working steps in parallel.
|
||||
| **B2-3** | Server-first updates through the signed manifest — folded into B2-2 | ½ day | after B2-2 |
|
||||
| **B2-4** | Compatibility matrix — folded into B2-2 | ½ day | after B2-2 |
|
||||
| **B2-5** | One permission predicate per security property — **DONE 2026-08-29 (PR #1440)** | 1–2 days | serialized |
|
||||
| **B2-6** | Safe audit coverage | ½ day | B2-1, B2-7 |
|
||||
| **B2-7** | Trust model, absence proofs, plugin boundary | 1 day | B2-1, B2-6 |
|
||||
| **B2-8** | The nine B2-tagged findings | 1 day | before B2-2 |
|
||||
| **B2-9** | Security owners and acceptance tests | spread | — |
|
||||
| **HP-2** | Protocol and threat-model sign-off | — | — |
|
||||
| **B2-6** | Safe audit coverage — **DONE 2026-08-29 (PR #1441)** | ½ day | B2-1, B2-7 |
|
||||
| **B2-7** | Trust model, absence proofs, plugin boundary — **DONE 2026-08-29 (PR #1443)** | 1 day | B2-1, B2-6 |
|
||||
| **B2-8** | The nine B2-tagged findings — **DONE 2026-08-28 (PR #1436)** | 1 day | before B2-2 |
|
||||
| **B2-9** | Security owners and acceptance tests — **DONE 2026-08-29** (SEC-03 → B5; table closed) | spread | — |
|
||||
| **HP-2** | Protocol and threat-model sign-off — **scorecard written 2026-08-29, awaiting signature** | — | — |
|
||||
|
||||
Order: B2-0, B2-1, B2-8, then B2-2 → B2-3 → B2-4; B2-5 is serialized on its
|
||||
own; B2-6, B2-7 and B2-9 run in parallel where the table allows.
|
||||
@@ -571,7 +571,9 @@ Runs in parallel with B2-1 and B2-6.
|
||||
neither is not an option.
|
||||
|
||||
**Evidence, 2026-08-29** — branch `feat/b2-7-trust-model` from `dev`
|
||||
`2b2d58ab`; PR #1443 to `dev`. HP-2 questions 3 and 6 cite this block.
|
||||
`2b2d58ab`; PR #1443 to `dev`, squash-merged 2026-08-29 as `88c7a824`
|
||||
(16 commits; 11 Codex rounds, all documentation-only). HP-2 questions 3 and 6
|
||||
cite this block.
|
||||
|
||||
- Pre-squash SHAs, one commit per item: `a4cd077b` (item 1, trust model +
|
||||
links), `083d87d9` (item 2, absence test + outbound-host table), `cbfcf702`
|
||||
@@ -837,15 +839,15 @@ The seven local reports in `docs/security-findings/` (gitignored, never
|
||||
committed; the directory-to-row mapping lives in its local README) and
|
||||
where each goes:
|
||||
|
||||
| Public row | Owner phase | Acceptance test lives | Lands with |
|
||||
| ---------- | ------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------- |
|
||||
| S-01 | **B2** | landed with B2-5 (`Server/service/predicate_parity_test.go`) | B2-5 (PR #1440) — done |
|
||||
| SEC-02 | **B2** (server half) | landed with B2-5 (`Server/ws/voice_moderation_overrides_test.go`) | B2-5 (PR #1440) — done; UI half in B5 |
|
||||
| C-09 | **B2** (contract) / B7 (client) | beside the report | contract in B2-7 docs; code in B7 |
|
||||
| SEC-03 | B2 if small, else **B5** | beside the report | B2-9 or B5 item 11 |
|
||||
| SEC-01 | **B4** | private GitHub advisory (owner creates it) | B4 |
|
||||
| SEC-04 | **B3/B6** | private GitHub advisory (owner creates it) | B6 |
|
||||
| OC-0324 | **B4** | beside the report; no advisory — the tracked ledger already carries this finding in full | B4 |
|
||||
| Public row | Owner phase | Acceptance test lives | Lands with |
|
||||
| ---------- | ------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| S-01 | **B2** | landed with B2-5 (`Server/service/predicate_parity_test.go`) | B2-5 (PR #1440, `67fdd18d`) — done |
|
||||
| SEC-02 | **B2** (server half) | landed with B2-5 (`Server/ws/voice_moderation_overrides_test.go`) | B2-5 (PR #1440, `67fdd18d`) — done; UI half in B5 |
|
||||
| C-09 | **B2** (contract) / B7 (client) | beside the report | contract in B2-7 docs (PR #1443, `88c7a824`) — done; code in B7 |
|
||||
| SEC-03 | **B5** (decided 2026-08-29) | beside the report | B5 item 11 — see evidence below |
|
||||
| SEC-01 | **B4** | private GitHub advisory (owner creates it) | B4 |
|
||||
| SEC-04 | **B3/B6** | private GitHub advisory (owner creates it) | B6 |
|
||||
| OC-0324 | **B4** | beside the report; no advisory — the tracked ledger already carries this finding in full | B4 |
|
||||
|
||||
An acceptance test demonstrates the defect, so it is exploit detail: it stays
|
||||
local until its fix lands, then lands publicly in the same PR. The two
|
||||
@@ -854,7 +856,43 @@ New draft), not by CLI with the report text; their IDs are recorded in
|
||||
`docs/security-findings/README.md`, which is local. Public commits, issues and
|
||||
PR bodies never name the mechanism (`docs/security.md`).
|
||||
|
||||
## HP-2 — Protocol and threat-model sign-off
|
||||
**Evidence, 2026-08-29** — branch `feat/b2-9-hp2` from `dev` `88c7a824`;
|
||||
PR to `dev` recorded below. HP-2 cites this block for exit-gate condition 7.
|
||||
|
||||
- **SEC-03 verdict: B5, not B2.** Sized against the code the local report
|
||||
cites at `88c7a824` before deciding. What the register's closure line
|
||||
requires — streaming limits enforced before any buffering, aggregate
|
||||
memory and concurrency budgets, timeout, cancellation, adversarial
|
||||
boundary tests — is not one guard: it is a shared bounded reader behind
|
||||
every automatic fetch the desktop renderer makes (four call sites across
|
||||
three client modules, ~1.1k lines), byte-weighted budgets at more than
|
||||
one level, cache eviction by bytes instead of entry count, plus server
|
||||
support (bounded thumbnails, range-capable media) that does not exist.
|
||||
A per-response cap alone is a few dozen lines but would not close the
|
||||
row, and it would land in the per-call-site TypeScript layer that the
|
||||
C-09 contract (`docs/trust-model.md` §"Desktop preview destination
|
||||
policy", clause 6: "bound time, bytes and concurrency … enforced while
|
||||
reading") exists to retire. Doing it twice is the wrong kind of small.
|
||||
So: register row re-tagged `B2/B5` → `B5`; roadmap B5 item 11 loses its
|
||||
"unless B2-9 already landed it" clause and names clause 6 as the shape
|
||||
the fix takes, so B5 implements the byte accounting once, where B7's
|
||||
broker will own it. No acceptance test lands here — it stays local
|
||||
beside the report until the fix, per the rule above.
|
||||
- **Table closed.** B2-owned rows, all done with their public acceptance
|
||||
tests in the tree: S-01 and the SEC-02 server half (B2-5, PR #1440 →
|
||||
`67fdd18d`), the C-09 contract (B2-7, PR #1443 → `88c7a824`; the client
|
||||
code is B7's). Rows that stay where the table puts them: SEC-03 → B5
|
||||
(above), OC-0324 and SEC-01 → B4, SEC-04 → B6. Exit-gate condition 7 ("no
|
||||
unresolved B2 security advisory remains") is therefore met on the B2-owned
|
||||
set: zero B2-owned rows open, and no advisory was needed for any of them.
|
||||
- **Advisories for the deferred rows** — created by the owner in the GitHub
|
||||
UI (Security → Advisories → New draft), never by CLI, report text never
|
||||
leaves the local package. IDs, once created (the local
|
||||
`docs/security-findings/README.md` is the record of which report each maps
|
||||
to): SEC-01 — `GHSA-____-____-____`; SEC-04 — `GHSA-____-____-____`.
|
||||
- Pre-squash SHAs: `355b1fc1` (records #1443's squash SHA in the B2-7
|
||||
block), `be8454d0` (item 1, SEC-03 verdict), the commit carrying this
|
||||
bullet (item 2, table closure); HP-2's commits are listed in its own block.
|
||||
|
||||
`docs/plans/hp-2-scorecard-<date>.md`, in the HP-1 shape. Questions it must
|
||||
answer with commands, not assertions:
|
||||
@@ -880,6 +918,29 @@ answer with commands, not assertions:
|
||||
The owner signs. Acceptance authorises B3 and claims nothing about beta
|
||||
readiness.
|
||||
|
||||
**Evidence, 2026-08-29** — the scorecard is
|
||||
[hp-2-scorecard-2026-08-29.md](hp-2-scorecard-2026-08-29.md), measured at
|
||||
`83a535c3` on `feat/b2-9-hp2` (same PR as B2-9).
|
||||
|
||||
- All seven questions answered with commands and their output; the B2 exit
|
||||
gate walked, nine conditions, all met (condition 1 at the slim one-epoch
|
||||
scope, condition 4 with one gap recorded). Every pre-squash SHA the
|
||||
questions cite was resolved on the fetched PR refs.
|
||||
- Question 4 added three adversarial tests (`a51e2e89`,
|
||||
`Client/tests/unit/livekit-e2ee.test.ts`): the modified-server unknown
|
||||
member at first contact (a known gap — pinned as today's behaviour, its RED
|
||||
against the desired rule recorded in the scorecard), the second device
|
||||
overwriting the one-per-account pin, and the holder side of OC-0316
|
||||
(resumed peer re-keyed with the rotated key). The last two were proven able
|
||||
to fail by temporary code mutation, restored with `git checkout`.
|
||||
- `docs/plans/hp-2-trust-model-anchors.py` is the Question 3 check: 117
|
||||
`path:line` anchors in `trust-model.md`, 0 unresolvable at HEAD.
|
||||
- Pre-squash SHAs: `a51e2e89` (Q4 tests), the commit carrying this block
|
||||
(scorecard, this block, plan index, roadmap slice); B2-9's are in its block.
|
||||
- **Owner lines, left blank on purpose:** the BPR-051 reader line in the B2-7
|
||||
block (Question 3 quotes it), the owner-review date in Question 3, and the
|
||||
decision and signature lines at the top and bottom of the scorecard.
|
||||
|
||||
## Exit gate
|
||||
|
||||
The roadmap's seven conditions, plus two:
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
# HP-2 — Protocol and threat-model sign-off scorecard
|
||||
|
||||
**Hold point:** HP-2, defined in
|
||||
[repo-health-roadmap-2026-08-23.md](repo-health-roadmap-2026-08-23.md)
|
||||
**Commits reviewed:** the pre-squash commits of #1435, #1438, #1440, #1443 and
|
||||
the B2-9/HP-2 branch (table below)
|
||||
**Measured at:** `83a535c3`, the `feat/b2-9-hp2` branch off `dev` `88c7a824`
|
||||
**Measured:** 2026-08-29
|
||||
**Evidence base:**
|
||||
[b2-protocol-trust-compat-2026-08-28.md](b2-protocol-trust-compat-2026-08-28.md)
|
||||
(every B2-_n_ evidence block), [hp-1-scorecard-2026-08-27.md](hp-1-scorecard-2026-08-27.md)
|
||||
(open items carried in)
|
||||
|
||||
**Decision: \_\_\_\_ — 2026-08-\_\_ by J3vb (repository owner).**
|
||||
|
||||
HP-2 asks seven questions. Each is answered below with the command that
|
||||
produces the evidence and what it printed on the measured tree, not with an
|
||||
assertion. Then the B2 exit gate's nine conditions are walked. It follows the
|
||||
shape of [hp-1-scorecard-2026-08-27.md](hp-1-scorecard-2026-08-27.md).
|
||||
|
||||
Acceptance authorises B3 to begin. It claims nothing about beta readiness.
|
||||
|
||||
## The commits under review are not on `dev`
|
||||
|
||||
`dev` is squash-merge only. The commit structure HP-2 reviews — fixtures
|
||||
captured _before_ the first protocol change, one commit per permission
|
||||
property, one commit per trust-model item — survives only on the pull-request
|
||||
refs:
|
||||
|
||||
```bash
|
||||
git fetch origin 'refs/pull/1435/head:pr-1435' 'refs/pull/1438/head:pr-1438' \
|
||||
'refs/pull/1440/head:pr-1440' 'refs/pull/1443/head:pr-1443'
|
||||
```
|
||||
|
||||
| PR | On `dev` | Pre-squash commits |
|
||||
| ----------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| #1435 — B2-1 epoch-1 fixtures | `1fe3df79` | `dd638f1c` retirement, `54cae614` capture, `c0719519` barriers, `d5fe06e5` null forms; head `069412db` |
|
||||
| #1438 — B2-2 negotiation | `9c9b8be6` | `2ac9b5ba` schema + constants, `77051648` server handshake, `41ef091d` client handshake, `899c956f` server-first updates |
|
||||
| #1440 — B2-5 predicates | `67fdd18d` | `00761523` predicates, `94aba833` send, `0271cbbe` view, `802101a0` voice join, `aeee37e8` voice moderation, `fdd2a3ff` Codex fix |
|
||||
| #1443 — B2-7 trust model | `88c7a824` | `a4cd077b` trust model, `083d87d9` absence proofs, `cbfcf702` plugin boundary, `56f23a36` L-08 decision; 11 Codex rounds after, all documentation-only |
|
||||
| this PR — B2-9 + HP-2 | — | `355b1fc1` records #1443's SHA, `be8454d0` SEC-03 verdict, `a51e2e89` Q4 tests, `83a535c3` table closure, and the commit carrying this file |
|
||||
|
||||
Every SHA above resolves on the fetched refs (`git log -1 <sha>` for each of
|
||||
the nineteen, 2026-08-29; dates in Question 1).
|
||||
|
||||
## Question 1 — is the epoch wire frozen?
|
||||
|
||||
| Element | Where | Value on the measured tree |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| The number | `protocol/schema.json:4` | `"protocol_epoch": 1` |
|
||||
| Go constant (generated) | `Server/ws/message_types.go:11` | `const ProtocolEpoch = 1` |
|
||||
| TS constant (generated) | `Client/src/lib/protocolTypes.ts:12` | `export const PROTOCOL_EPOCH = 1;` |
|
||||
| Constant ↔ schema pin | `Server/ws/protocol_contract_test.go:234` | `TestProtocolEpochMatchesSchema` — PASS |
|
||||
| `auth` shape | client sends `epoch: PROTOCOL_EPOCH` (`Client/src/lib/ws.ts:452`); absent = 0 | server accepts `[minClientEpoch, ProtocolEpoch]` = `[0, 1]` (`Server/ws/serve_auth.go:62`, `messages.go:385`) |
|
||||
| `auth_error` shape | `Server/ws/messages.go:389-408` | `code: "protocol_epoch_unsupported"`, `client_epoch`, `server_epoch`, `min_epoch`, message naming which side |
|
||||
| Client reads the refusal | `Client/src/lib/dispatcher.ts:295-298` | `server_epoch > PROTOCOL_EPOCH` → `updateRequiredHost` → Update Now banner on the connect page |
|
||||
| `ready` shape | unchanged by B2-2 | the epoch-1 fixtures replay verbatim (Question 2) |
|
||||
| Close code | 1008, the same as every handshake failure | no 4426 — nothing reads close codes (B2-2 evidence block, "not shipped") |
|
||||
| Update metadata | signed manifest `protocol_epoch`; `GET /api/v1/client-update` answers 204 for a newer epoch | `Server/updater/release_epoch_test.go:27` `TestReleaseProtocolEpoch` — 4/4 PASS |
|
||||
|
||||
```bash
|
||||
grep -n protocol_epoch protocol/schema.json # 4: "protocol_epoch": 1,
|
||||
grep -n ProtocolEpoch Server/ws/message_types.go # 11:const ProtocolEpoch = 1
|
||||
grep -n PROTOCOL_EPOCH Client/src/lib/protocolTypes.ts # 12:export const PROTOCOL_EPOCH = 1;
|
||||
git grep -nE 'minClientEpoch|protocol_epoch_unsupported' -- 'Server/ws/*.go' ':!*_test.go'
|
||||
```
|
||||
|
||||
**Ordering — fixtures before the first protocol change (exit condition 8):**
|
||||
|
||||
```bash
|
||||
for s in 54cae614 c0719519 d5fe06e5 069412db 2ac9b5ba 77051648; do git log -1 --format='%h %ci %s' $s; done
|
||||
```
|
||||
|
||||
```
|
||||
54cae614 2026-08-28 12:37:34 +0200 test(ws): capture the epoch-1 wire fixtures
|
||||
c0719519 2026-08-28 13:19:23 +0200 test(ws): harden the epoch-1 fixtures against trailing frames and absent op
|
||||
d5fe06e5 2026-08-28 14:25:11 +0200 test(ws): freeze the null forms of auth_ok and member_join user fields
|
||||
069412db 2026-08-28 15:31:43 +0200 test(client): compare auth-frame key sets order-independently
|
||||
2ac9b5ba 2026-08-29 06:32:50 +0200 feat(b2-2): declare protocol_epoch in the schema and generate both constants
|
||||
77051648 2026-08-29 06:35:14 +0200 feat(b2-2): check the client's protocol epoch in the auth handshake
|
||||
```
|
||||
|
||||
#1435 merged 2026-08-28 13:43 UTC as `1fe3df79`; #1438 merged 2026-08-29
|
||||
05:23 UTC as `9c9b8be6`. The capture precedes the first negotiation commit by
|
||||
eighteen hours and one merge, in its own PR.
|
||||
|
||||
**Verdict: PASS.** One number, generated into both consumers, pinned to the
|
||||
schema by a test; every handshake shape anchored; fixtures captured first and
|
||||
their pre-squash SHAs recorded (exit condition 9).
|
||||
|
||||
## Question 2 — does downgrade behave?
|
||||
|
||||
By the owner's 2026-08-29 decision B2-2 shipped **one accepted epoch**: the
|
||||
window is `[minClientEpoch, ProtocolEpoch]` = `[0, 1]`, where 0 is "absent
|
||||
`epoch`" — the alpha.4 client. So the roadmap's N, N-1, N-2 rows collapse to
|
||||
two: this epoch and the epoch-less client before it. The matrix is
|
||||
`TestAuth_ProtocolEpoch`, driven over a real socket, plus the fixture replay
|
||||
that proves the accepted client is byte-compatible:
|
||||
|
||||
```bash
|
||||
cd Server && go test -count=1 -run 'TestAuth_ProtocolEpoch$|TestEpoch1Fixtures$|TestProtocolEpochMatchesSchema$' -v ./ws/
|
||||
```
|
||||
|
||||
```
|
||||
--- PASS: TestProtocolEpochMatchesSchema (0.00s)
|
||||
--- PASS: TestEpoch1Fixtures (6.17s)
|
||||
--- PASS: TestEpoch1Fixtures/fresh-connect (0.62s)
|
||||
--- PASS: TestEpoch1Fixtures/auth-failure (0.01s)
|
||||
--- PASS: TestEpoch1Fixtures/ping (0.31s)
|
||||
--- PASS: TestEpoch1Fixtures/chat-send-fanout (0.62s)
|
||||
--- PASS: TestEpoch1Fixtures/chat-edit-delete (0.61s)
|
||||
--- PASS: TestEpoch1Fixtures/reaction-add-remove (0.61s)
|
||||
--- PASS: TestEpoch1Fixtures/typing (0.61s)
|
||||
--- PASS: TestEpoch1Fixtures/mark-read (0.31s)
|
||||
--- PASS: TestEpoch1Fixtures/dm-send (0.62s)
|
||||
--- PASS: TestEpoch1Fixtures/resume-replay (1.22s)
|
||||
--- PASS: TestEpoch1Fixtures/voice-join-e2ee-leave (0.62s)
|
||||
--- PASS: TestAuth_ProtocolEpoch (0.06s)
|
||||
--- PASS: TestAuth_ProtocolEpoch/absent (0.01s)
|
||||
--- PASS: TestAuth_ProtocolEpoch/zero (0.01s)
|
||||
--- PASS: TestAuth_ProtocolEpoch/current (0.01s)
|
||||
--- PASS: TestAuth_ProtocolEpoch/newer (0.01s)
|
||||
--- PASS: TestAuth_ProtocolEpoch/negative (0.01s)
|
||||
ok github.com/J3vb/OwnCord/Server/ws 7.446s
|
||||
```
|
||||
|
||||
| Client epoch | Case | Outcome | Actionable? |
|
||||
| ---------------- | ---------- | ------------------------------------------------------------------------ | ------------------------------------------------------------ |
|
||||
| absent (alpha.4) | `absent` | accepted as 0; every epoch-1 fixture replays unchanged | n/a — it works |
|
||||
| 0 | `zero` | accepted | n/a |
|
||||
| 1 (= N) | `current` | accepted | n/a |
|
||||
| 2 (newer) | `newer` | `auth_error` `protocol_epoch_unsupported`, `server_epoch: 1`, close 1008 | yes — message says update the server; client keeps its login |
|
||||
| −1 | `negative` | `auth_error` `protocol_epoch_unsupported`, close 1008 | yes — message says update the client; Update Now banner |
|
||||
| browser client | — | **n/a until B8** — no bundled browser client exists | exit condition 1 records this as n/a, as the plan does |
|
||||
|
||||
The update side of downgrade — a client release newer than the server's epoch
|
||||
is withheld, a tampered manifest is refused — is `TestReleaseProtocolEpoch`
|
||||
(`signed_manifest_declares_the_epoch`, `manifest_without_the_field_is_epoch_0`,
|
||||
`release_without_a_manifest_is_epoch_0`, `tampered_manifest_is_an_error`), all
|
||||
PASS.
|
||||
|
||||
**Verdict: PASS, at the slim scope the owner chose.** Both accepted client
|
||||
generations are tested on a real socket, the refused ones fail with a coded
|
||||
frame that names the side to update, and the client turns that frame into the
|
||||
same Update Now flow it already had. A three-wide window and its matrix are
|
||||
one constant away (`minClientEpoch`) if a future epoch bump needs them.
|
||||
|
||||
## Question 3 — are the trust claims true?
|
||||
|
||||
`docs/trust-model.md` carries **117** `path:line` anchors and names **20**
|
||||
distinct Go tests plus the vitest cases in its E2EE table. The mechanical half
|
||||
of the question is whether every anchor still resolves on the measured tree:
|
||||
|
||||
```bash
|
||||
python docs/plans/hp-2-trust-model-anchors.py
|
||||
```
|
||||
|
||||
```
|
||||
117 path:line anchors checked (24 short-form resolved by unique basename), 0 unresolvable
|
||||
```
|
||||
|
||||
(The 24 short forms are the document's `file.go:NN` after a full path in the
|
||||
same sentence; each resolves to exactly one tracked file.) Line-range drift
|
||||
after a future edit is not caught by this check — it proves the file and the
|
||||
line exist, not that the line still says what the sentence claims; the 11
|
||||
Codex rounds on #1443 were the line-by-line read, every finding accepted and
|
||||
fixed in the document (B2-7 evidence block).
|
||||
|
||||
The human half:
|
||||
|
||||
- **Owner review** of `docs/trust-model.md` at `88c7a824`: **\_\_\_\_** (date).
|
||||
- **Non-developer read** of "The short answer" — BPR-051's exit evidence.
|
||||
Reader: **\_\_\_\_**. Date: **\_\_\_\_**. Answer given to "who can read my
|
||||
messages?": **\_\_\_\_**. (Quoted from the B2-7 evidence block, where the
|
||||
owner fills it in; both places must agree.)
|
||||
|
||||
**Verdict: PASS on the mechanical half; the human half is the signature.**
|
||||
Two claims in the document are absences with no positive test — the server
|
||||
holds no room key; text is not encrypted — and the document says so rather
|
||||
than inventing a test for nothing.
|
||||
|
||||
## Question 4 — are the E2EE membership and key-change rules stated and tested?
|
||||
|
||||
The rules are the table in `docs/trust-model.md` §"What is end-to-end
|
||||
encrypted". Inventory of what pinned each before HP-2, and the three
|
||||
adversarial cases HP-2 added (`a51e2e89`, all in
|
||||
`Client/tests/unit/livekit-e2ee.test.ts` under "HP-2 adversarial membership
|
||||
and key-change rules"):
|
||||
|
||||
| Rule | Pinned by (before HP-2) | Added by HP-2 |
|
||||
| ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| Room key made on a participant's machine | `livekit-e2ee.test.ts` "setupKeyExchange as key holder generates the room key…" | — |
|
||||
| Key holder wraps for each peer (ECDH + AES-GCM) | `e2eeCrypto.test.ts` "round-trips a room key between two keypairs" | — |
|
||||
| Frames encrypted before leaving; SFU relays ciphertext | `livekit-session.test.ts` "enables E2EE on the room created by createRoom (OC-0095)" | — |
|
||||
| Server relays wrapped keys opaquely, to channel members only | `TestE2EE_Offer_KeyHolderCanSend`, `_RejectsNonKeyHolder`, `_TargetChannelCheckAtomicWithLookup` | — |
|
||||
| Server holds no room key | absence — stated, no positive test | — |
|
||||
| Identity TOFU: pin on first sight, block on change | `livekit-session.test.ts` "pins the peer identity key on first sight…", "blocks and emits identity-tofu when the pinned identity key changed" | — |
|
||||
| Re-pin TOCTOU: re-pin the key the human saw | `livekit-session.test.ts` "re-pins the verified key, not a store re-read a malicious server mutated (TOCTOU)" | — |
|
||||
| Rekey on leave (forward secrecy) | `livekit-session.test.ts` "rotates the room key when a keyed peer leaves while I stay key holder" | — |
|
||||
| Timed rotation | `livekit-e2ee.test.ts` "[T-47] arms the periodic rotation timer…" | — |
|
||||
| Rotation during outage (OC-0316), server side | `TestRegisterNow_ReannouncesOwnKeyOnResume` | — |
|
||||
| Rotation during outage (OC-0316), **holder side** | receiver side only (`[OC-0007] confirms the room key after a reconnect re-announce…`) | "[HP-2 / OC-0316] a peer whose socket dropped across a rotation is re-keyed with the rotated key…" |
|
||||
| Second device overwrites the one-per-account pin; first device then mismatches | stated in the document (Codex round 6), untested | "[HP-2] a second device's key, once trusted, overwrites the account pin — the first device then mismatches" |
|
||||
| **Known gap:** modified server adds an unknown member at first contact | stated under "What beta does not claim" (Codex round 7), untested | "[HP-2 known gap] a modified server that adds an unknown member at first contact gets the room key…" |
|
||||
| Server decides who may join and publish | `TestE2EE_VoiceToken_IncludesIsKeyHolder`; `voice_moderation_overrides_test.go` | — |
|
||||
|
||||
All three were written test-first. The RED each produced:
|
||||
|
||||
**Known gap** — written first against the _desired_ rule (no offer, no pin
|
||||
for a first-sight peer the client has no independent membership evidence for;
|
||||
the voice roster does not even list them):
|
||||
|
||||
```
|
||||
× [HP-2 known gap] a modified server that adds an unknown member at first contact gets the room key wrapped to it
|
||||
AssertionError: expected [ { type: 'voice_e2ee_offer', …(1) } ] to have a length of +0 but got 1
|
||||
```
|
||||
|
||||
That is the gap, measured: the holder wraps the room key to whoever the server
|
||||
says is a member, because membership is server-controlled and the client
|
||||
accepts any first-sight identity (`livekitE2EE.ts` `verifyPeerAnnounce`). The
|
||||
test now pins **today's** behaviour — offer sent, key pinned, peer "verified"
|
||||
— with a comment saying it inverts when authenticated membership (or "refuse
|
||||
unrecognised participants") lands. That fix has its RED waiting.
|
||||
|
||||
**Second device** — proven able to fail by temporarily disabling the pinned-key
|
||||
mismatch block (`if (false && pin !== null && …)`), then restored with
|
||||
`git checkout`:
|
||||
|
||||
```
|
||||
× [HP-2] a second device's key, once trusted, overwrites the account pin — the first device then mismatches
|
||||
AssertionError: expected last "vi.fn()" call to have been called with [ ObjectContaining{…} ]
|
||||
```
|
||||
|
||||
**Rotation during outage, holder side** — proven able to fail by temporarily
|
||||
making a duplicate announce skip the re-offer (`!isDuplicate && this._isKeyHolder …`),
|
||||
then restored. The first version of this test did not fail under that
|
||||
mutation: the file's `exportPublicKey` mock returns one fixed string, so the
|
||||
replayed announce took the "key changed" path instead of the duplicate path
|
||||
the rule is about. Fixed by round-tripping import/export as the OC-0001 test
|
||||
does, after which:
|
||||
|
||||
```
|
||||
× [HP-2 / OC-0316] a peer whose socket dropped across a rotation is re-keyed with the rotated key when the server replays its announce
|
||||
AssertionError: expected [] to have a length of 1 but got +0
|
||||
```
|
||||
|
||||
Restored tree: `Tests 3 passed | 59 skipped (62)` for the `-t "HP-2"` filter;
|
||||
the full client gate is in "The gate run" below.
|
||||
|
||||
**Verdict: PASS with one known gap recorded, not hidden.** Every rule the
|
||||
document states has a test; the one adversarial case the model cannot defend
|
||||
against today — a modified server inserting a member at first contact — is
|
||||
pinned as current behaviour so the fix cannot land silently, and stays under
|
||||
"What beta does not claim". It is not assigned a phase: the owner decides at
|
||||
signature whether beta claims it (then B4 or B6 needs an authenticated
|
||||
membership item) or keeps the disclaimer. The recommendation is to keep the
|
||||
disclaimer for beta — the fix needs a server-side membership proof the client
|
||||
can verify, which is a protocol change and belongs after B3.
|
||||
|
||||
## Question 5 — is there one predicate per property?
|
||||
|
||||
Before: the B2-5 evidence block's inventory table, thirteen decision sites
|
||||
across `permissions/checker.go`, `service/` and `ws/`, each deciding view,
|
||||
send, type, admit, join or moderate by hand. After: six predicates in
|
||||
`Server/permissions/predicates.go`, each site delegating, with parity tables
|
||||
(`Server/service/predicate_parity_test.go`,
|
||||
`Server/ws/predicate_parity_internal_test.go`) that ran the old logic and the
|
||||
predicate over the same fixture before the old logic was deleted.
|
||||
|
||||
Residue — direct bit-helper calls outside `Server/permissions`, non-test, at
|
||||
the measured tree:
|
||||
|
||||
```bash
|
||||
git grep -nE 'HasPerm|HasAnyPerm|HasServerPerm|EffectivePerms|EffectiveChannelPerms|HasAdmin' \
|
||||
-- 'Server/*.go' ':!Server/*_test.go' ':!Server/permissions/*'
|
||||
```
|
||||
|
||||
28 lines print; 7 are comments (`admin/handlers_channel_perms.go:143,230,430`,
|
||||
`admin/middleware.go:99`, `api/middleware.go:186`, `db/channel_queries.go:158`,
|
||||
`service/mentions.go:286`). The **21 code hits**, each in a class the B2-5
|
||||
block already lists with its reason:
|
||||
|
||||
| Class | Hits | Why it is not a channel predicate |
|
||||
| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| Server-scoped permission, no channel to resolve a `Subject` for | `HasServerPerm` ×6: `api/middleware.go:200`, `admin/middleware.go:109`, `service/emoji.go:95`, `service/moderation.go:51`, `service/role.go:82`; `HasAnyPerm` `admin/middleware.go:84` | these _are_ the canonical server-wide predicate |
|
||||
| `HasAdmin` as a fetch short-circuit (skip the override query) | `service/channel.go:59`, `service/message_perms.go:25`, `service/permission.go:224`, `ws/serve.go:780`, `ws/serve_ready.go:169`, `ws/voice_join.go:355` | optimisation before the predicate, not a decision |
|
||||
| `HasAdmin` as an authorization input | `admin/handlers_channel_perms.go:95,325`, `admin/logstream.go:452`, `api/upload_handler.go:404`, `service/role.go:104` | role hierarchy / admin perimeter — the 2026-08-18 measurement's "no `Outranks`" class |
|
||||
| Bulk @everyone reader walk | `service/mentions.go:262,263,266` | per-role layer walk; owner declined the mechanical conversion 2026-08-18 |
|
||||
| Base-bit early rejection ahead of `CanModerateVoice` | `ws/voice_moderation.go:65` | never admits; keeps FORBIDDEN ahead of the voice-state lookup (B2-5 decision) |
|
||||
|
||||
Zero hits outside those five classes. The plan's step 5 condition ("no file
|
||||
outside `Server/permissions` calls the bit helpers") is **not met** and was
|
||||
not expected to be — the `authz-chokepoint` invariant rule stays with B3 item
|
||||
15, where the residue above is the allowlist it starts from.
|
||||
|
||||
**Verdict: PASS as "listed residue with reasons".** Every channel-scoped
|
||||
security property has exactly one predicate; what remains is server-scoped,
|
||||
an optimisation, or a declined conversion, each named.
|
||||
|
||||
## Question 6 — are the deferred systems bounded?
|
||||
|
||||
**Absence.** Three contract tests walk the mounted router, the WebSocket wire
|
||||
types in `protocol/schema.json`, and every `koanf` key of `config.Config`,
|
||||
failing on `(?i)federat|directory|discover|listing`:
|
||||
|
||||
```bash
|
||||
cd Server && go test -count=1 -run TestAbsenceContract -v ./api/
|
||||
```
|
||||
|
||||
```
|
||||
--- PASS: TestAbsenceContract_NoFederationDirectoryOrListingRoutes (0.02s)
|
||||
--- PASS: TestAbsenceContract_NoFederationDirectoryOrListingWireTypes (0.00s)
|
||||
--- PASS: TestAbsenceContract_NoFederationDirectoryOrListingConfigKeys (0.00s)
|
||||
ok github.com/J3vb/OwnCord/Server/api 1.352s
|
||||
```
|
||||
|
||||
Each was proven able to fail in B2-7 (a temporary `/api/v1/directory` route; a
|
||||
temporary `directory_list` wire type — outputs in the B2-7 evidence block).
|
||||
The bound is stated in `trust-model.md`: vocabulary at three boundaries,
|
||||
network by the outbound-host table (ten server rows, each with trigger,
|
||||
purpose, condition-plus-control and anchor — B6's capture checklist),
|
||||
semantics by review.
|
||||
|
||||
**Plugins.** `docs/architecture/plugins.md` §"Status: off, twice — and not in
|
||||
the release at all" is the configuration audit:
|
||||
|
||||
| Shape | Verdict | Why |
|
||||
| -------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Fresh install | off | default `false` (`config.go:352`); generated `config.yaml` block commented out (`:452-457`) |
|
||||
| Upgraded install | off | no `plugins` key in an older file; koanf loads defaults first; a misspelled key is warned and ignored (`:520`) |
|
||||
| Docker | off | image built without the tag (`Dockerfile:13`); the env flag can flip `plugins.enabled` but the binary cannot execute a module |
|
||||
| Standalone release binary | off | no tag (`release.yml:261`, `:268`) |
|
||||
| Source build with the flag | **on** | `go build -tags wazero` **and** `plugins.enabled: true` together — the developer path and the only one |
|
||||
|
||||
Read back on the measured tree: `release.yml:261` and `:268` are plain
|
||||
`go build -o chatserver… -ldflags "-s -w -X main.version=$VERSION" .`;
|
||||
`Server/Dockerfile:12-13` is `CGO_ENABLED=0 GOOS=linux go build -o /chatserver`.
|
||||
No `-tags wazero` on any shipped path. "No API promise" is the document's
|
||||
second section; the beta release-notes paragraph is in the same file.
|
||||
|
||||
**L-08** (WASM example build gate): re-tagged B1/B10 by the B2-7 decision,
|
||||
reason recorded there — compare-in-CI cannot pass in principle (TinyGo embeds
|
||||
host paths, no `-trimpath`), compile-only needs a second Go SDK on every PR,
|
||||
and the subsystem is compiled out of every artifact a release contains.
|
||||
|
||||
**Verdict: PASS.** Federation, directory and listing are absent by test at
|
||||
three boundaries; WASM is off by default, off on upgrade, and cannot execute
|
||||
in any shipped artifact.
|
||||
|
||||
## Question 7 — is `dev` strict?
|
||||
|
||||
```bash
|
||||
gh api repos/J3vb/OwnCord/branches/dev/protection --jq '{strict: .required_status_checks.strict, checks: (.required_status_checks.contexts|length), enforce_admins: .enforce_admins.enabled, approvals: .required_pull_request_reviews.required_approving_review_count, force: .allow_force_pushes.enabled, del: .allow_deletions.enabled}'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"approvals": 0,
|
||||
"checks": 12,
|
||||
"del": false,
|
||||
"enforce_admins": true,
|
||||
"force": false,
|
||||
"strict": true
|
||||
}
|
||||
```
|
||||
|
||||
The twelve required checks: Server Build & Test (ubuntu-latest), Server Build
|
||||
& Test (windows-latest), Client Static Checks, Client Unit Tests, Rust Unit
|
||||
Tests, Client E2E (Playwright), Client E2E (parity subset, blocking), Analyze
|
||||
(go), Analyze (javascript-typescript), Analyze (actions), Repository Hygiene,
|
||||
Docs & Ledger Consistency.
|
||||
|
||||
**Verdict: PASS.** This closes HP-1's condition 6, accepted there as a stated
|
||||
limitation: with `strict: true` every squash that lands on `dev` was tested
|
||||
against the base it lands on. The cost HP-1 predicted — an "Update branch" on
|
||||
every open PR when another merges — has been paid on every B2 PR since B2-0
|
||||
and is the reason the plan keeps one PR in flight.
|
||||
|
||||
## B2 exit gate
|
||||
|
||||
| # | Condition | Status | Evidence |
|
||||
| --- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Clients from epochs N, N-1, N-2 pass the matrix; N-3 fails safely and actionably; the bundled browser client matches the server epoch | **met at the slim scope** | Question 2 — one accepted epoch by owner decision; both accepted generations tested; browser client n/a until B8 |
|
||||
| 2 | Protocol and update-metadata changes are generated, documented, and downgrade-tested | **met** | Question 1 (`npm run generate` drift gate in `check:server`), `TestReleaseProtocolEpoch`, `docs/protocol.md` § Compatibility |
|
||||
| 3 | Effective-permission and resource-existence sibling cases have parity tests | **met** | Question 5 — two parity tables, red before delegation for S-01 and SEC-02 |
|
||||
| 4 | Voice/video/screen E2EE membership and key-change behaviour pass adversarial tests | **met, one gap recorded** | Question 4 — every stated rule pinned by a test; the first-contact membership gap pinned as current behaviour and disclaimed |
|
||||
| 5 | No central identity, directory, federation path, or required external service exists | **met** | Question 6 — three absence tests + outbound-host table |
|
||||
| 6 | WASM disabled by default; release artifacts do not imply API stability | **met** | Question 6 — configuration audit, no `-tags wazero` in any shipped build, "No API promise" |
|
||||
| 7 | No unresolved B2 security advisory remains | **met** | B2-9 evidence block — zero B2-owned rows open; SEC-01/SEC-04 advisories are B4/B6 rows the owner creates |
|
||||
| 8 | _(added)_ Epoch-1 fixtures were captured before the first protocol change, in a separate commit | **met** | Question 1 — `54cae614` (08-28 12:37) precedes `2ac9b5ba` (08-29 06:32), separate PRs |
|
||||
| 9 | _(added)_ Pre-squash SHAs recorded for the fixture and negotiation commits | **met** | The table at the top; every SHA resolved on the fetched refs |
|
||||
|
||||
### The gate run
|
||||
|
||||
Measured 2026-08-29 on the branch, before each commit, per the `ci-check`
|
||||
skill. Every step exited 0.
|
||||
|
||||
| Step | Result |
|
||||
| ------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `npm run check:client` (vitest, `tsc --noEmit`, eslint) | pass — **193 files, 5273 tests**, the three Q4 cases included |
|
||||
| `npx knip` (blocking in CI, not part of `check:client`) | pass — four configuration hints, exit 0 |
|
||||
| `npm run check:docs` | pass — 21 claims across 9 watched documents agree with the ledger |
|
||||
| `npm run check:hygiene` | pass — prettier clean; shellcheck/actionlint skipped locally, CI runs them |
|
||||
| `go test ./ws/ ./api/ ./updater/` (Questions 2 and 6 subsets) | pass — outputs above |
|
||||
|
||||
No Go or Rust source changed on this branch, so the four build-tag variants,
|
||||
`-race`, `-tags deadlock` and `golangci-lint` were not re-run locally; CI's
|
||||
required checks run them on the PR.
|
||||
|
||||
## Open items carried past B2
|
||||
|
||||
Recorded, not fixed. None blocks B3's entry.
|
||||
|
||||
| Item | State |
|
||||
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| E2EE first-contact membership gap | **open, disclaimed.** Question 4. Pinned as current behaviour; owner decides at signature whether beta claims it (needs a phase) or keeps the disclaimer. |
|
||||
| SEC-03 — bounded preview/media reads | **B5 item 11**, re-tagged by B2-9 with the sizing reason; shape is the C-09 contract clause 6. |
|
||||
| SEC-01, OC-0324 | **B4.** SEC-01's private advisory is the owner's to create; ID line in the B2-9 block. |
|
||||
| SEC-04 | **B6.** Same — advisory ID line in the B2-9 block. |
|
||||
| C-09 client code | **B7** — the native fetch broker implements the contract written in B2-7. |
|
||||
| `authz-chokepoint` invariant rule | **B3 item 15**, allowlist = the Question 5 residue. |
|
||||
| L-08 WASM example build gate | **B10**, by the B2-7 decision. |
|
||||
| OC-0349 `voice_join` ordering hazard | **open, low** — ledger; a later fix batch. |
|
||||
| BPR-051 non-developer read | **pending owner** — the `____` line in the B2-7 block and Question 3. |
|
||||
| `bughunt-fix` workflow gate list | tooling — phantom `gate: FAIL` (`format:check` gone, `knip` missing); observation logged, not B2. |
|
||||
| Release binaries lack `-tags wazero` | **by design**, now stated in `plugins.md`; revisit only if a release is ever meant to run a plugin. |
|
||||
|
||||
## Hand-off to B3
|
||||
|
||||
The roadmap's current slice: when HP-2 closes, B3 opens with the "First
|
||||
actionable slice" of
|
||||
[developer-experience-layout-refactor-2026-08-29.md](developer-experience-layout-refactor-2026-08-29.md)
|
||||
— inventory, before-state graph, auth characterization tests, the auth
|
||||
vertical slice, HP-3. Nothing from that plan touches the client before B7.
|
||||
B3 item 15 (`authz-chokepoint`) starts from Question 5's residue table.
|
||||
|
||||
## What acceptance does and does not authorise
|
||||
|
||||
Accepting HP-2 authorises B3 to begin. It does **not** claim:
|
||||
|
||||
- that OwnCord is beta-ready — every B2 exit condition is a contract frozen,
|
||||
not a product qualified;
|
||||
- that the E2EE model resists a modified server — it resists an operator who
|
||||
reads; the first-contact membership gap is pinned and disclaimed (Question 4);
|
||||
- that a three-wide epoch window exists — one epoch is accepted by policy
|
||||
(Question 2);
|
||||
- that every direct bit-helper call is gone — the residue is listed with
|
||||
reasons (Question 5);
|
||||
- that the deferred security rows are fixed — they are owned and scheduled
|
||||
(B2-9 table), not resolved.
|
||||
|
||||
**Signed:** **\_\_\_\_** (J3vb, repository owner), 2026-08-\_\_.
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Every `path:line[-line]` anchor in docs/trust-model.md must name a tracked
|
||||
file whose line count covers the cited line. A bare basename (the document's
|
||||
short form after a full path in the same sentence) resolves when exactly one
|
||||
tracked file has that basename. Prints the misses; exit 1 if any."""
|
||||
import os, re, subprocess, sys
|
||||
|
||||
doc = open("docs/trust-model.md", encoding="utf-8").read()
|
||||
tracked = [p for p in subprocess.check_output(["git", "ls-files"], text=True).split("\n") if p]
|
||||
tracked_set = set(tracked)
|
||||
by_base = {}
|
||||
for p in tracked:
|
||||
by_base.setdefault(os.path.basename(p), []).append(p)
|
||||
pat = re.compile(r"`([A-Za-z0-9_./-]+\.(?:go|ts|rs|md|json|yml|toml|sql)):(\d+)(?:-(\d+))?")
|
||||
seen, short, bad = 0, 0, []
|
||||
for m in pat.finditer(doc):
|
||||
path, lo, hi = m.group(1), int(m.group(2)), int(m.group(3) or m.group(2))
|
||||
seen += 1
|
||||
if path not in tracked_set:
|
||||
cands = by_base.get(path, [])
|
||||
if len(cands) != 1:
|
||||
bad.append(f"{path}:{lo} -- {'ambiguous ' + str(cands) if cands else 'not a tracked file'}")
|
||||
continue
|
||||
path = cands[0]
|
||||
short += 1
|
||||
n = sum(1 for _ in open(path, encoding="utf-8", errors="replace"))
|
||||
if hi > n:
|
||||
bad.append(f"{path}:{lo}-{hi} -- file has {n} lines")
|
||||
print(f"{seen} path:line anchors checked ({short} short-form resolved by unique basename), {len(bad)} unresolvable")
|
||||
for b in bad:
|
||||
print(" " + b)
|
||||
sys.exit(1 if bad else 0)
|
||||
@@ -151,12 +151,12 @@ This register carries only non-sensitive security properties and opaque
|
||||
remediation families; an apparently related engineering row is not evidence
|
||||
that any private report is fixed.
|
||||
|
||||
| ID | Pri | State | Opaque remediation family | Phase | Public closure evidence |
|
||||
| ------ | --: | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| SEC-01 | P1 | confirmed | Atomic concurrent password-confirmation admission. | B4 | One server-owned admission decision, bounded concurrent attempts, and race/load regression coverage. |
|
||||
| SEC-02 | P1 | resolved/superseded | Effective channel-level voice moderation permissions. Server half landed in B2-5 (PR #1440): voice moderation decides on the effective permission in the target channel, with override and denial tests. | B5 | UI half only: the moderation controls surface effective permissions (B5 item 11). |
|
||||
| SEC-03 | P1 | confirmed | Bounded per-response and aggregate preview/media reads. | B2/B5 | Streaming limits are enforced before buffering; aggregate memory/concurrency budgets, timeout, cancellation, and adversarial boundary tests pass. |
|
||||
| SEC-04 | P1 | confirmed | Durable per-user/server storage quotas and disk headroom. | B3/B6 | Transaction-safe quotas cover files and cumulative storage; low-disk behavior fails safely and is exercised by restart/concurrency tests. |
|
||||
| ID | Pri | State | Opaque remediation family | Phase | Public closure evidence |
|
||||
| ------ | --: | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| SEC-01 | P1 | confirmed | Atomic concurrent password-confirmation admission. | B4 | One server-owned admission decision, bounded concurrent attempts, and race/load regression coverage. |
|
||||
| SEC-02 | P1 | resolved/superseded | Effective channel-level voice moderation permissions. Server half landed in B2-5 (PR #1440): voice moderation decides on the effective permission in the target channel, with override and denial tests. | B5 | UI half only: the moderation controls surface effective permissions (B5 item 11). |
|
||||
| SEC-03 | P1 | confirmed | Bounded per-response and aggregate preview/media reads. B2-9 (2026-08-29) sized it and re-tagged it to B5: the closure line below is the whole boundary, not one guard, and the C-09 contract names where it lives. | B5 | Streaming limits are enforced before buffering; aggregate memory/concurrency budgets, timeout, cancellation, and adversarial boundary tests pass. |
|
||||
| SEC-04 | P1 | confirmed | Durable per-user/server storage quotas and disk headroom. | B3/B6 | Transaction-safe quotas cover files and cumulative storage; low-disk behavior fails safely and is exercised by restart/concurrency tests. |
|
||||
|
||||
## Client engineering issues
|
||||
|
||||
|
||||
@@ -656,9 +656,12 @@ operation before building their full cross-client experience.
|
||||
rotation, and stale-subscription cleanup. There is no OwnCord relay.
|
||||
10. Keep automation optional. Human moderation authority and audit remain core
|
||||
even if automation becomes a post-beta plugin candidate.
|
||||
11. _(added 2026-08-28)_ SEC-03 (bounded per-response and aggregate
|
||||
preview/media reads) is first in line unless B2-9 already landed it. It is
|
||||
P1 and confirmed.
|
||||
11. _(added 2026-08-28; re-tagged here by B2-9 on 2026-08-29)_ SEC-03 (bounded
|
||||
per-response and aggregate preview/media reads) is first in line. It is P1
|
||||
and confirmed. Shape: the C-09 contract in `docs/trust-model.md` clause 6
|
||||
(time, streaming byte ceiling, content-type list, concurrency cap) plus
|
||||
aggregate budgets and byte-weighted cache eviction; implement the byte
|
||||
accounting once, at the boundary B7's native broker will own.
|
||||
|
||||
### Hold point HP-5 — Abuse and privacy review
|
||||
|
||||
@@ -1242,8 +1245,11 @@ in this order:
|
||||
4. B2-2 — protocol epoch and negotiation, with B2-3 and B2-4 folded in
|
||||
(one accepted epoch, so no matrix) — **done 2026-08-29**, PR #1438 =
|
||||
`9c9b8be6`;
|
||||
5. B2-5 — serialized, **next**; B2-6, B2-7 and B2-9 in parallel where the
|
||||
plan allows; then HP-2.
|
||||
5. B2-5 (PR #1440 = `67fdd18d`), B2-6 (PR #1441 = `2b2d58ab`), B2-7
|
||||
(PR #1443 = `88c7a824`) and B2-9 — **done 2026-08-29**;
|
||||
6. HP-2 — scorecard written 2026-08-29
|
||||
([hp-2-scorecard-2026-08-29.md](hp-2-scorecard-2026-08-29.md)),
|
||||
**awaiting the owner's signature**.
|
||||
|
||||
Do not begin B3 domain extraction, client platform extraction, or browser work
|
||||
before HP-2 closes. When it does, B3 opens with the "First actionable slice"
|
||||
|
||||
Reference in New Issue
Block a user