Phase 8 P1b-a — Offline-Locked Auth Spec (Frozen Thinking Outline)
Status: P1b-a frozen · P1b-b implemented (inert; gates hard-false). Bundled Tier 3 merge to main.
This document freezes WHAT and HOW for the offline-locked auth feature in Knowtation so the
Auto build (P1b-b) implements mechanically against a fixed spec, per RULE #8 (Orchestrator) and
SD-3 (Thinking → Auto split). It is the contract layer; the running credential store, live login
route, and live CLI bootstrap are build-later (P1b-b and beyond).
Authored on branch feat/phase-8-p1b-offline-locked-auth-spec (Knowtation).
§0 — Scope reminder (P1b-a Thinking only)
This Thinking step freezes the contract for offline-locked auth. It does not ship code.
In scope (P1b-a):
- Precondition + gap recap (§1) — why P1b is OPEN, what closes it.
- Deployment mode + env gate (§2).
- Local credential store contract (§3) — Argon2id, no OAuth when gate on.
- First-admin bootstrap on air-gapped install (§4).
- JWT
subformat + role-store parity (§5). - Gateway + bridge route parity + fail-closed OAuth blocking (§6).
- Rate limiting, lockout, audit events — no secrets in logs (§7).
- CLI / MCP token issuance path for offline-locked mode (§8).
- Seven-tier test matrix (§9).
- Scooling impact note (§10).
- Posture constants, all hard-
false(§11).
Not in scope (build-later, P1b-b): any production code, any live credential store file, any live login route, any live CLI bootstrap command, any env flip (Tier 3). This doc adds no production code.
Design-now vs build-later boundary: P1b-a freezes contract/seam shapes and parameters. P1b-b implements those seams as live code + seven-tier tests, then a separate Tier 3 session flips the env gate.
§1 — Precondition + gap recap (why P1b is OPEN)
From scooling/docs/PHASE-8A-LOCAL-OFFLINE-INFRASTRUCTURE-OUTLINE.md §1:
| # | Precondition | State | Owner | Gap |
|---|---|---|---|---|
| P1b | True offline-locked auth (no internet for login at all) | OPEN → this spec closes it (design only) | Knowtation | Self-hosted login still uses Google/GitHub OAuth (own client id/secret) → needs provider reachability. No documented air-gapped local-credential login. |
What "offline-locked" means (frozen definition):
- The Hub process can start, serve, and authenticate users with zero outbound network calls during the login flow. No OAuth provider (Google, GitHub) is contacted.
- The credential verifier is local-only: the Argon2id hash lives on disk under
DATA_DIR, verification happens in-process, and no third party can validate or reset the credential. - A login attempt that tries to reach an OAuth provider (e.g. legacy
/auth/login?provider=google) fails closed when the gate is on (§6). - This is strictly stronger than "self-hosted with OAuth" — self-hosted today still requires provider reachability at login time. Offline-locked removes that dependency.
What this spec does NOT change:
- Hosted path (knowtation.store gateway + canister) — unaffected. The hosted gateway is never offline-locked; it always uses OAuth. The env gate has no effect on hosted Netlify deploys.
- Connected-private and local-network profiles — unaffected. They may keep using OAuth when network is available; offline-locked is an opt-in for the strict fully-local/offline profile.
- Existing
subformatprovider:idfor OAuth users — unchanged. P1b adds a newlocal:prefix, it does not alter OAuth subjects.
Gate consequence: Once P1b-b is built and the env gate is flipped on a given install, that
install's Scooling consumer reads sign_in_auth: "available" for the fully_local_offline
profile (§10). Until then it stays open_precondition.
§2 — Deployment mode + env gate
§2.1 Env gate
KNOWTATION_OFFLINE_LOCKED_AUTH=enabled # default: not set (= off)
- Off (default): Behavior is identical to today. OAuth is the only login path. The local credential store, if present, is ignored. No new routes are active. This is the safe default for hosted, connected-private, and any install that has not explicitly opted in.
- On (
enabled): The Hub activates the local credential verifier (§3), the local login route (§6), the local CLI/MCP token issuance path (§8), and fails closed on any OAuth attempt (§6). Outbound calls toaccounts.google.com,github.com, and passport OAuth strategies are blocked at the route layer before any network egress.
§2.2 Where the gate is read
The gate is read once at Hub boot (process startup), cached as a boolean, and used by:
hub/server.mjs(self-hosted legacy Hub) — login route dispatch.hub/gateway/server.mjs(gateway) — login route dispatch + OAuth fail-closed.hub/bridge/server.mjs(bridge) — credential store + role resolution parity.- CLI bootstrap (
bin/knowtation.mjsor equivalent) — admin bootstrap command (§4, §8).
Reading once at boot means flipping the gate requires a Hub restart — this is intentional (no mid-flight auth-mode switching; eliminates a whole class of TOCTOU race conditions).
§2.3 Fail-closed on misconfiguration
If KNOWTATION_OFFLINE_LOCKED_AUTH=enabled but no local credential store exists yet (no
admin bootstrapped), the Hub:
- Starts normally.
- Refuses all login attempts with
503 OFFLINE_LOCKED_NOT_BOOTSTRAPPED(§4). - Logs the bootstrap instruction to stdout exactly once at boot.
This prevents an operator from accidentally bricking an install by enabling the gate without bootstrapping — they get a clear, actionable error instead of a silent 401 loop.
§3 — Local credential store contract
§3.1 Store location + file format
The credential store lives at DATA_DIR/hub_local_credentials.json — same directory
convention as the existing hub_roles.json, hub_invites.json, hub_workspace.json (verified
in hub/bridge/server.mjs lines 126–130).
{
"schema": "knowtation.hub_local_credentials/v0",
"credentials": {
"local:admin_001": {
"userId": "admin_001",
"username": "admin",
"argon2id": "$argon2id$v=19$m=65536,t=3,p=4<base64-salt>$<base64-hash>",
"createdAt": "2026-07-01T18:00:00.000Z",
"mustRotatePassphrase": false
}
},
"userCounter": 1
}
userIdis an opaque, monotonically-incrementing local identifier (admin_001,user_002, …). Never the username — usernames can change; userIds are stable.usernameis the login identifier the user types. Case-sensitive, normalized at write time (NFC, trimmed, lowercase-folded for comparison only).argon2idis the standard PHC string format ($argon2id$v=19$m=…$…). The store never holds the plaintext passphrase.mustRotatePassphrase— settrueafter bootstrap with a setup token (§4) so the user is forced to set their own passphrase on first login. Cleared after a successful rotation.
§3.2 Argon2id parameters (OWASP 2024 — frozen)
From knowtation/docs/COMPANION-APP-MODEL-ROUTING-AND-ENRICHMENT-ARCHITECTURE.md §9.2:
time_cost (t) ≥ 3
memory_cost (m) ≥ 65536 KiB (64 MiB)
parallelism (p) = 4
hash_length = 32 bytes
salt_length = 16 bytes (random, per-credential)
variant = 2 (Argon2id)
version = 0x13 (19)
- These are the minimum parameters. P1b-b may tune
time_costupward on capable hardware; it must never tune below these floors. - The salt is generated with
crypto.randomBytes(16)at credential-write time and embedded in the PHC string. No reused salts, no hardcoded salts.
§3.3 Verification flow
1. Receive (username, passphrase) over HTTPS (or localhost HTTP).
2. Lookup credential by normalized username. If absent → 401 INVALID_CREDENTIALS
(same error as bad passphrase — never reveal which failed).
3. argon2id.verify(storedPHC, passphrase) → boolean.
4. On true: issue JWT (§5). On false: increment failure counter (§7).
5. Plaintext passphrase is zeroed from memory immediately after verify.
Timing-safe comparison: Argon2id verification is inherently constant-time per attempt, but the lookup-by-username step must not leak whether a username exists. The implementation must perform a dummy Argon2id verify against a fixed decoy hash when the username is not found, so the response time is indistinguishable from a real verify. (Prevents username enumeration via timing.)
§3.4 At-rest protection
- The file is JSON plaintext. This matches the existing convention (
hub_roles.jsonis also plaintext JSON). Filesystem permissions are the primary at-rest control:0600mode, owned by the Hub process user. P1b-b mustfs.chmod(0o600)on every write. - The Argon2id hash itself is the credential; stealing the file does not yield passphrases without an offline brute-force attack that the 64 MiB memory cost makes expensive.
- Envelope encryption of the credential store is out of scope for P1b-b — it is a ZK-tier concern (companion doc §9.3) and would conflict with the bootstrap flow (§4) which must work before any user-derived key exists.
§4 — First-admin bootstrap on air-gapped install
The bootstrap problem: when KNOWTATION_OFFLINE_LOCKED_AUTH=enabled and no credential store
exists, nobody can log in. There must be a deterministic, auditable way to create the first
admin without any network call. Two frozen mechanisms; P1b-b implements both.
§4.1 Mechanism A — Setup token (one-time, out-of-band)
# Operator runs this on the air-gapped host, once:
KNOWTATION_OFFLINE_LOCKED_BOOTSTRAP_TOKEN=$(node bin/knowtation.mjs auth \
generate-setup-token --username admin --expires-in 15m)
- Generates a 256-bit random token (
crypto.randomBytes(32)→ base64url). - Hashes it (SHA-256) and writes a one-time bootstrap record to
DATA_DIR/hub_local_bootstrap.jsonwith{ username, tokenHash, expiresAt, consumed: false }. - Prints the raw token once to stdout. The operator copies it to the user out-of-band (paper, secure messenger, in-person). It is never persisted in plaintext.
- The user submits
POST /api/v1/auth/local/bootstrapwith{ setupToken, username, passphrase }. - The handler verifies the token hash, checks
expiresAt(default 15 minutes) andconsumed: false, then creates the credential (§3), sets roleadmin(§5), and atomically marksconsumed: true. - The bootstrap record is single-use: a second submission with the same token returns
410 BOOTSTRAP_TOKEN_CONSUMED. This prevents replay.
Hard rules:
- The setup-token command refuses to run if a credential store already exists with ≥1 admin. This prevents privilege escalation via re-bootstrap.
expiresAtis capped at 60 minutes; the CLI rejects longer windows.- The file is
0600and is deleted (fs.unlink) after the first successful consume. If the file exists on next Hub boot and is expired, P1b-b prunes it automatically.
§4.2 Mechanism B — CLI bootstrap (interactive, on-host)
# Operator runs this on the air-gapped host, once, interactively:
node bin/knowtation.mjs auth bootstrap-admin --username admin
# (prompts for passphrase, does not echo, uses readline with muted output)
- The command reads the passphrase from a TTY prompt (not argv, not stdin pipe — prevents the
passphrase appearing in shell history or
ps). - Writes the credential directly to
DATA_DIR/hub_local_credentials.jsonwith roleadmin. - Refuses to run if a credential store already exists with ≥1 admin (same guard as §4.1).
- Prints only
{ ok: true, userId: "admin_001" }on success — never the passphrase or hash.
Which to use when:
- Mechanism A is for the case where the operator is not the user (e.g. IT sets up the box, hands the token to the teacher).
- Mechanism B is for the case where the operator is the user (single-admin air-gapped laptop).
Both produce identical state: one credential in the store, one admin role in hub_roles.json,
zero network calls.
§5 — JWT sub format + role-store parity
§5.1 JWT payload shape
P1b reuses the exact issueToken payload shape from hub/gateway/server.mjs lines 185–200
and hub/server.mjs lines 278–286. The only difference is the sub and provider/id fields:
| Field | OAuth (existing) | Local (P1b) |
|---|---|---|
sub |
google:123 / github:456 |
local:admin_001 |
provider |
google / github |
local |
id |
provider-specific numeric id | admin_001 (the local userId) |
name |
displayName from OAuth | username from credential store |
role |
from roleForSub / effectiveRole |
same resolver, same values |
iat / exp |
jwt standard | jwt standard |
// P1b-b will add to hub/lib/local-auth.mjs (frozen shape):
function issueLocalToken(credential, role) {
const sub = `local:${credential.userId}`;
return jwt.sign(
{
sub,
provider: 'local',
id: credential.userId,
name: credential.username,
role,
},
SESSION_SECRET, // same secret as OAuth path; never a separate one
{ expiresIn: JWT_EXPIRY } // same expiry; default '24h'
);
}
Parity guarantee: a downstream consumer (getUserId, requireAdmin, the bridge role
resolver, the canister proxy) cannot distinguish a local-issued JWT from an OAuth-issued JWT
except by inspecting the provider field. Authorization logic is identical.
§5.2 Role-store parity
P1b reuses the existing hub_roles.json store and the effectiveRole() resolver
(hub/bridge/server.mjs lines 772–777) unchanged:
function effectiveRole(uid, storedRoles) {
if (!uid) return 'member';
const stored = storedRoles && storedRoles[uid];
if (stored && VALID_ROLES.has(stored)) return stored;
return adminUserIdsSet.has(uid) ? 'admin' : 'member';
}
VALID_ROLES = admin | editor | viewer | evaluator— unchanged.- The
uidpassed in is thesub(local:admin_001), so role lookups key on the fullsub. - Bootstrap (§4) writes
roles['local:admin_001'] = 'admin'intohub_roles.json. - Admin can promote other local users via the existing
POST /api/v1/rolesroute — same contract, same validation, same audit. No new RBAC surface.
§5.3 Self-hosted default-admin parity
The self-hosted Hub (hub/server.mjs) has a special case: when roleMap.size === 0, everyone
gets admin (line 280, 297). This makes a fresh OAuth install work with no manual role setup.
For offline-locked mode this special case is dangerous (anyone who bootstraps first becomes
unrestricted admin, and there is no OAuth allowlist to bound who can log in). P1b-b must
disable the empty-roleMap default for the offline-locked path: when
KNOWTATION_OFFLINE_LOCKED_AUTH=enabled, the role resolver treats an empty store as denied
rather than admin-for-everyone. Bootstrap (§4) is the only way to seed the first admin.
This is the one place where offline-locked diverges from self-hosted OAuth parity, and it is the security-critical divergence.
§6 — Gateway + bridge route parity + fail-closed OAuth blocking
§6.1 New local login route (mounted only when gate on)
POST /api/v1/auth/local/login
Content-Type: application/json
{ "username": "admin", "passphrase": "…" }
→ 200 { "token": "<jwt>", "expiresIn": "24h" }
→ 401 { "error": "Invalid credentials", "code": "INVALID_CREDENTIALS" }
→ 401 { "error": "Account locked", "code": "ACCOUNT_LOCKED", "retryAfterSeconds": 900 }
→ 429 { "error": "Rate limit exceeded", "code": "RATE_LIMITED", "retryAfterSeconds": 60 }
→ 503 { "error": "Offline-locked not bootstrapped", "code": "OFFLINE_LOCKED_NOT_BOOTSTRAPPED" }
- Issued when: gate on AND credential store has ≥1 admin.
- Sets the same HttpOnly refresh cookie as OAuth path (reuses
issueRefreshCookieSafe). - Response body shape matches the OAuth redirect fragment (
token=…) so the Hub UI'spostLoginRedirectlogic works unchanged. - Mounted on
hub/server.mjs(self-hosted) andhub/gateway/server.mjs(gateway). The hosted Netlify gateway never mounts it — hosted is never offline-locked.
§6.2 Bootstrap route (mounted only when gate on)
POST /api/v1/auth/local/bootstrap
Content-Type: application/json
{ "setupToken": "…", "username": "admin", "passphrase": "…" }
→ 200 { "ok": true, "userId": "admin_001" }
→ 410 { "error": "Bootstrap token consumed", "code": "BOOTSTRAP_TOKEN_CONSUMED" }
→ 410 { "error": "Bootstrap token expired", "code": "BOOTSTRAP_TOKEN_EXPIRED" }
→ 409 { "error": "Already bootstrapped", "code": "ALREADY_BOOTSTRAPPED" } (≥1 admin exists)
→ 400 { "error": "Passphrase too weak", "code": "WEAK_PASSPHRASE" }
- Passphrase strength policy (frozen): ≥12 characters, ≥1 of each of (lowercase, uppercase, digit, symbol), not in a small known-breached-password list (P1b-b uses a 100k-entry HIBP-style SHA-1 hash set bundled offline — no network lookup).
- The route is the only way to consume a setup token (Mechanism A, §4.1). The CLI (Mechanism B, §4.2) writes the store directly and never calls this route.
§6.3 Fail-closed OAuth blocking (when gate on)
When KNOWTATION_OFFLINE_LOCKED_AUTH=enabled, every OAuth-touching route must fail closed
before any network egress:
| Route | Behavior when gate on |
|---|---|
GET /auth/login |
403 { error: "OAuth disabled in offline-locked mode", code: "OAUTH_DISABLED" } |
GET /auth/login?provider=google\|github |
same 403 |
GET /auth/callback/google |
same 403 |
GET /auth/callback/github |
same 403 |
GET /api/v1/auth/login |
same 403 |
GET /api/v1/auth/providers |
200 { google: false, github: false, local: true } — reports local available |
MCP OAuth 2.1 router (mcp-oauth-provider.mjs) |
not mounted at boot |
Native OAuth 2.1 router (native-oauth-provider.mjs) |
not mounted at boot |
passport.use(GoogleStrategy) |
not registered at boot |
passport.use(GitHubStrategy) |
not registered at boot |
Critical invariant: the OAuth strategies are not just "disabled at runtime" — they are
never registered with passport. This means there is no code path through which an
OAuth callback can execute, even if an attacker tricks the browser into hitting
/auth/callback/google. The route handler returns 403 before passport is consulted.
P1b-b implements this as an early-return guard at the top of each OAuth route + a conditional
around each passport.use(...) call, gated on the cached boot-time boolean (§2.2).
§6.4 Bridge parity
The bridge (hub/bridge/server.mjs) does not handle login itself today — it proxies to the
canister and resolves roles. For offline-locked mode:
- The bridge's
effectiveRole()resolver (line 772) works unchanged because localsubs are just another key inhub_roles.json. - The bridge's
POST /api/v1/rolesvalidation (VALID_ROLES.has(r), line 938) works unchanged. - No new bridge routes. The bridge does not need to know whether auth was OAuth or local;
the JWT carries the
provider:localmarker but authorization is role-based, not provider-based.
This parity is the reason §5 reuses the JWT shape so strictly — the bridge and canister see
the same X-User-Id they always have.
§7 — Rate limiting, lockout, audit events (no secrets in logs)
§7.1 Per-username rate limit
- 5 failed attempts per username per 15 minutes, then the username is locked for 15 minutes. Counter is reset on a successful login.
- Implementation: a small JSON file at
DATA_DIR/hub_local_login_attempts.jsonwith shape{ "local:admin_001": { "failures": 3, "firstFailureAt": "…", "lockedUntil": null } }. P1b-b may use an in-memory map with periodic flush, but the on-disk copy is authoritative across restarts. - The lock is per-username, not per-IP. Offline-locked deployments are typically single-origin (the user's own machine), so IP-based limiting would be useless and username-based limiting is the correct defense against brute force.
- A locked account returns
401 ACCOUNT_LOCKEDwithretryAfterSecondseven if the passphrase is correct — the user cannot "guess their way through" a lockout.
§7.2 Global rate limit
- 20 login attempts per minute per Hub process (all usernames combined). This is a backstop against an attacker spraying many usernames.
- Implemented as a token bucket in-process. Resets on Hub restart. Logged when tripped.
§7.3 Audit events (frozen event names)
All audit events are written to the existing data/hub_audit.log (or equivalent). The event
names are frozen; the field schema is frozen. No secrets appear in any event.
| Event name | When emitted | Fields (never the passphrase or hash) |
|---|---|---|
local_auth.login_success |
Successful local login | sub, username, ip, ua, ts |
local_auth.login_failure |
Failed local login (bad passphrase OR unknown user) | username_attempted, ip, ua, ts, reason:"invalid_credentials" |
local_auth.account_locked |
Lockout threshold reached | sub or username_attempted, ip, ts, lockedUntil |
local_auth.bootstrap_consumed |
Setup token used successfully | sub, username, ip, ts, bootstrapTokenId (opaque, not the token) |
local_auth.bootstrap_rejected |
Setup token rejected (consumed/expired/unknown) | reason, ip, ts (no token value) |
local_auth.passphrase_rotated |
User rotated passphrase after mustRotatePassphrase=true |
sub, ts |
local_auth.oauth_blocked |
OAuth route hit while gate on | route, ip, ts |
local_auth.role_changed |
Local user role changed via /api/v1/roles |
sub, oldRole, newRole, actorSub, ts |
Hard log rule (RULE #6): the passphrase, the Argon2id hash, the setup token, and the JWT must never appear in any log line, audit event, crash report, or error response. P1b-b tests enforce this in the security tier (§9).
§7.4 Setup-token rate limit
- 5 bootstrap attempts per 15 minutes per Hub process. Prevents token brute-force.
- Each attempt against an unknown token hash still triggers a dummy SHA-256 verify against a fixed decoy hash, so timing does not reveal whether the token exists.
§8 — CLI / MCP token issuance path for offline-locked mode
§8.1 The gap
Today (per docs/AGENT-INTEGRATION.md line 282), a CLI/MCP user obtains a token by:
- Logging into the Hub UI via OAuth.
- Clicking Settings → Integrations → Copy Hub URL, token & vault.
- Pasting
KNOWTATION_HUB_TOKEN(the raw JWT) into their env.
In offline-locked mode, step 1 is impossible (OAuth blocked). There must be a CLI path that issues a JWT directly from the credential store, without a browser.
§8.2 CLI token issuance command
node bin/knowtation.mjs auth token \
--username admin \
--vault-id default \
[--expires-in 24h]
# Prompts for passphrase (TTY-muted, same as bootstrap-admin).
# On success, prints to stdout (and only stdout):
# KNOWTATION_HUB_URL=http://localhost:3333
# KNOWTATION_HUB_TOKEN=eyJhbGciOi...
# KNOWTATION_HUB_VAULT_ID=default
- Verifies the passphrase against the credential store (§3.3).
- Issues a JWT with the same shape as the login route (§5.1) —
provider: local,sub: local:<userId>, role fromeffectiveRole. - Respects the same rate limit + lockout (§7.1) — a CLI brute-force is no easier than a web one.
- The command writes the env-export lines to stdout only, never to a file, never to a
shell-history file. The operator pipes it into their env as needed:
eval "$(node bin/knowtation.mjs auth token -u admin -v default)". - Optional
--expires-inoverrides the default 24h; capped at 168h (7 days).
§8.3 MCP usage of the token
- The token works against
/mcpexactly as an OAuth-issued token does — sameAuthorization: Bearer <token>header, samegetUserIdresolution, same role-based access. - MCP OAuth 2.1 dynamic registration is not available in offline-locked mode (the OAuth router is not mounted, §6.3). MCP clients that require OAuth 2.1 (rather than bearer JWT) cannot be used offline-locked; they must use the bearer path. This is a documented limitation.
- The Hub UI's Settings → Integrations panel, when gate on, shows a Copy CLI command
button instead of the OAuth-derived token — it copies the
auth tokencommand (without the passphrase), and the user runs it in their terminal.
§8.4 Token revocation
- A CLI-issued JWT is revoked the same way as an OAuth-issued one: it expires (24h default), or the admin rotates the user's passphrase (which invalidates the refresh cookie family), or the admin deletes the user's credential entirely.
- There is no server-side JWT blocklist in scope for P1b-b. Short expiry + rotation is the revocation mechanism. (A blocklist is a future ZK-tier concern, out of scope.)
§9 — Seven-tier test matrix (test file names, not impl)
P1b-b ships all seven tiers (RULE #0). File naming follows the existing Knowtation convention
test/<tier>/phase8-p1b-<focus>.test.mjs (or .test.ts where the harness is TS). All run with
the env gate off by default; the security + data-integrity tiers additionally exercise the
gate-on path against a fixture store.
| Tier | Focus (frozen) | File name (P1b-b) |
|---|---|---|
| unit | Argon2id hash/verify round-trip; PHC string parse; parameter floor enforcement (rejects t<3, m<65536, p!=4); username normalization (NFC, trim, case-fold); credential store CRUD with 0600 perms; issueLocalToken payload shape equals OAuth payload minus provider; effectiveRole parity for local: subs; empty-store-denied (§5.3) vs empty-store-admin-for-everyone (legacy OAuth). |
test/unit/phase8-p1b-local-credential-store.test.mjs |
| integration | Full login flow against a fixture Hub: bootstrap-admin (Mechanism B) → POST /api/v1/auth/local/login → JWT → GET /api/v1/auth/session → role resolves → GET /api/v1/roles returns the bootstrapped admin. OAuth routes return 403 when gate on. Refresh-cookie issuance mirrors OAuth path. Bridge /api/v1/role resolves a local sub correctly. |
test/integration/phase8-p1b-local-login-flow.test.mjs |
| e2e | Air-gapped install walkthrough on a fixture host: enable gate → boot → 503 OFFLINE_LOCKED_NOT_BOOTSTRAPPED → run auth generate-setup-token → consume via bootstrap route → login → issue CLI token → MCP /mcp call succeeds with bearer. Network stub asserts zero outbound calls to accounts.google.com or github.com during the entire walkthrough. |
test/e2e/phase8-p1b-airgapped-walkthrough.test.mjs |
| stress | 10,000 rapid failed logins against one username → lockout engages at 5, never lets the 6th through even with correct passphrase, unlocks at 15min. 1,000 concurrent bootstrap attempts against one token → exactly one succeeds, rest get 410 BOOTSTRAP_TOKEN_CONSUMED. Argon2id verify under concurrent load does not exhaust file descriptors or memory. |
test/stress/phase8-p1b-lockout-and-bootstrap-contention.test.mjs |
| data-integrity | Credential store file survives Hub restart (write → stop → start → verify reads back). Bootstrap record is fs.unlink'd after consume (not left on disk). Login-attempts file is authoritative across restart. Role store (hub_roles.json) and credential store (hub_local_credentials.json) stay consistent on role change + passphrase rotation. mustRotatePassphrase cleared only after a successful rotation, never on a failed one. |
test/data-integrity/phase8-p1b-store-persistence.test.mjs |
| performance | Argon2id verify latency on the floor parameters (t=3,m=64MiB,p=4) is measured: assert ≥50ms (sanity — proves the KDF is actually memory-hard) and ≤2000ms on the test hardware class (regression — prevents a future param bump that bricks login). Login route end-to-end latency ≤500ms excluding the Argon2id verify. Bootstrap latency ≤300ms. |
test/performance/phase8-p1b-argon2id-latency.test.mjs |
| security | (a) No-secret rule: grep all log output, audit events, error responses, and crash fixtures for the test passphrase, hash, setup token, and JWT → zero matches. (b) Timing-safe username lookup: response time for unknown-username vs known-username-with-bad-passphrase is within ±10ms over 100 samples. (c) OAuth fail-closed: every OAuth route returns 403 before any outbound fetch() call (stub fetch and assert never called with an OAuth provider URL). (d) Setup-token replay rejected. (e) mustRotatePassphrase forces rotation before any non-rotate route succeeds. (f) File perms on hub_local_credentials.json are 0600 after every write. (g) Passphrase zeroed from process memory after verify (best-effort; tested by stub). |
test/security/phase8-p1b-no-secrets-and-fail-closed.test.mjs |
Tier-7 security is split across two files because the surface is large; P1b-b may also ship
test/security/phase8-p1b-bootstrap-replay.test.mjs as a focused replay-attack file if the
combined file becomes unwieldy.
Cross-slice e2e target (frozen): the air-gapped walkthrough (e2e tier) must complete with
zero outbound network calls — verified by a global fetch stub that fails the test if any
call hits a non-localhost host. This is the single most important acceptance criterion for the
entire spec.
§10 — Scooling impact note
File: src/phase8/offlineConnectedMatrices.ts (Scooling repo)
Current state (verified, line 49):
fully_local_offline: {
// …
sign_in_auth: "open_precondition", // ← line 49
// …
}
The sign_in_auth capability for the fully_local_offline profile is the only matrix cell
that P1b closes. When P1b-b is built and a given Knowtation install has the gate on, this cell
becomes "available" for that install.
Why this matters: the offline-mode capability matrix (OFFLINE_CAPABILITY_MATRIX in this
file) is the Scooling consumer's source of truth for what a fully-local-offline deployment can
do. Today, sign-in is the lone open_precondition cell — meaning the entire profile is marked
"works offline except login." Closing P1b removes that caveat.
P1b-verify scope (5c, after P1b-b merge):
- No change to the
OFFLINE_CAPABILITY_KEYStuple —sign_in_authis already a key (line 22). - The
OFFLINE_CAPABILITY_MATRIX.fully_local_offline.sign_in_authcell flips from"open_precondition"to"available". - The two other profiles (
local_network,connected_private) already read"available"(lines 65, 81) — no change. - The frozen provenance/local-meter shapes (
phase8ProvenanceRecordSchema,localMeterRecordSchema) are unaffected — they never carried auth-mode metadata. - Seven-tier Scooling tests for the matrix change:
test/unit/phase8-8b6-offline-connected-matrices.test.ts— update the one assertion.- The other six tiers — assert no behavioral regression; offline/connected transitions stay
consistent; the now-
availablecell does not enable any cloud lane (cloud lanes remain gated off independently).
Hard boundary (frozen): Scooling is a consumer of Knowtation auth, never the authority.
The matrix cell reports capability; it does not perform auth. Scooling must not implement its
own credential verifier, its own Argon2id, or its own bootstrap flow — all of that is Knowtation.
The Scooling adapter (signInAdapter or equivalent) continues to call the Knowtation Hub route;
it merely stops gating the call behind the open_precondition caveat.
No posture flip in P1b sessions (hard stop from the handover prompt): the matrix cell change is a P1b-verify deliverable (5c), not a P1b-a or P1b-b deliverable. P1b-a (this spec) does not touch the Scooling repo at all.
§11 — Posture constants (all hard-false until a separate Tier 3 authorization)
P1b-b introduces one new compile-time constant in Knowtation (not Scooling — Scooling has no auth posture constant of its own for this; the matrix cell is the Scooling-side signal):
| Constant | Value | Gate |
|---|---|---|
KNOWTATION_OFFLINE_LOCKED_AUTH_AVAILABLE |
false |
Later Tier 3 — feature-flag for whether the offline-locked code paths ship enabled |
Wait — re-reading the existing Knowtation codebase, the gate is env-driven (KNOWTATION_OFFLINE_LOCKED_AUTH=enabled), not compile-time. This matches the existing Knowtation convention
(everything auth-related reads env at boot: SESSION_SECRET, GOOGLE_CLIENT_ID, HUB_ADMIN_USER_IDS).
Scooling's PHASE8_* gates are compile-time; Knowtation's are env-time. The two repos have
different conventions and we respect both.
Frozen decision: env-gate, not compile-time. There is no new compile-time posture constant.
The env var KNOWTATION_OFFLINE_LOCKED_AUTH is the sole gate. This is consistent with:
GOOGLE_CLIENT_ID/GITHUB_CLIENT_ID(env gates OAuth)HUB_ADMIN_USER_IDS(env gates admin role list)TASK_WRITES_ENABLED(env gates task writes on the bridge)
The only compile-time signal P1b-b adds is a feature-flag in case we need to ship the code inert before any operator enables it:
| Flag | File | Value | Purpose |
|---|---|---|---|
OFFLINE_LOCKED_AUTH_CODE_SHIPPED |
hub/lib/local-auth-feature-flag.mjs (new in P1b-b) |
false in P1b-b's first commit, flipped true when tests green |
Lets P1b-b ship the code path inert; even with KNOWTATION_OFFLINE_LOCKED_AUTH=enabled, login refuses until the flag is true. Double-lock with the env gate. |
This double-lock (compile-time flag AND env gate, both must be on) mirrors the Scooling
pattern (PHASE8_* compile + SCOOLING_* env) adapted to Knowtation's env-first convention.
Acceptance (P1b-a — this Thinking step)
- [x] Gap recap with owner + evidence (§1).
- [x] Deployment mode + env gate frozen, fail-closed on misconfig (§2).
- [x] Local credential store contract — file format, Argon2id parameters (OWASP 2024), timing-safe verify, at-rest controls (§3).
- [x] First-admin bootstrap — setup token (Mechanism A) + CLI interactive (Mechanism B) (§4).
- [x] JWT
subformat (local:<userId>) + role-store parity; self-hosted default-admin divergence frozen (§5). - [x] Gateway + bridge route parity; OAuth fail-closed before any network egress (§6).
- [x] Rate limiting (per-username + global), lockout, 8 audit event names, no-secret log rule (§7).
- [x] CLI/MCP token issuance path; MCP OAuth 2.1 limitation documented (§8).
- [x] Seven-tier test matrix with file names (§9).
- [x] Scooling impact note —
sign_in_authcell flip in P1b-verify, hard boundary restated (§10). - [x] Posture constants — env gate + compile-time feature flag, double-lock (§11).
Non-goals (P1b-a)
- No production code, no live credential store, no live login route, no live CLI command.
- No env flip or Tier 3 authorization.
- No envelope encryption of the credential store (ZK-tier, out of scope).
- No server-side JWT blocklist (ZK-tier, out of scope).
- No MCP OAuth 2.1 support in offline-locked mode (documented limitation, §8.3).
- No change to hosted path (knowtation.store gateway) — hosted is never offline-locked.
- No change to OAuth
subformat for OAuth users — only addslocal:prefix. - No Scooling posture flip — Scooling repo untouched in P1b-a.
- No SD-8 / MuseHub plugin work.
- No bundling with Phase 2F-b or Track C.
Governance sync (both docs together, per RULE #8)
knowtation/docs/ROADMAP.md(if present) — Phase P1b-a status row → DONE on branch; P1b-b → TODO.knowtation/docs/OVERSEER-HANDOVER.md(if present) — PRIMARY rotated to P1b-b Auto build prompt when this branch merges.- Scooling
docs/OVERSEER-HANDOVER.md— 5a row → DONE on branch / 5b TODO; this update happens in the Scooling repo, not here, and is a separate Tier 1 commit.
Branch
feat/phase-8-p1b-offline-locked-auth-specoff Knowtation Musemain@sha256:8f2c3eae….- Muse + Git commit on the feature branch (Tier 1, pre-authorized per Knowtation
AGENTS.md). - No PR to
mainin this session — SD-11 forbids docs-only PRs tomain. Merge waits for P1b-b code+tests to bundle, or explicit operator request for an urgent docs merge. - When spec is committed and test matrix is complete, stop and ask operator for Tier 3 merge approval (per handover prompt).