Skip to content

fix: bunker pair-cap bypass + in-sheet cap gate (sprint 5a) - #41

Merged
DocNR merged 3 commits into
mainfrom
feat/cap-correctness-bunker-rows
May 9, 2026
Merged

fix: bunker pair-cap bypass + in-sheet cap gate (sprint 5a)#41
DocNR merged 3 commits into
mainfrom
feat/cap-correctness-bunker-rows

Conversation

@DocNR

@DocNRDocNR commented May 9, 2026

Copy link
Copy Markdown
Owner

Summary

Closes the bunker pair-cap bypass confirmed in build-63 device testing — user reached 7 successful bunker pairs on a single signer when the documented per-signer cap is 5. Sprint 5a from ~/hq/clave/BACKLOG.md ConnectedClient identity sprint.

  • 5a-core (NSE row creation): Shared/LightSigner.swift:209 now calls SharedStorage.setClientRelayUrls(...) immediately after saveClientPermissions(perms) in the bunker first-connect handler. Pre-fix, the ConnectedClient row was created lazily by SharedStorage.updateClient only when the first signing request arrived — multiple bunker pairs could land between connect and first-sign without any of them counting toward the cap. Mirrors the nostrconnect path's row-creation timing. Side benefit: closes the L1 relay-set incompleteness noted in BACKLOG sprint 5a (bunker pairs now contribute to L1's foreground subscription set).
  • 5a-ux (in-sheet cap gate): ConnectBunkerTabView now intercepts the "New Secret" tap when count >= 5, shows Connection limit reached alert, does not rotate. HomeView's existing entry-point gate (handlePairNewConnectionTap) blocks the sheet from opening at cap; this in-sheet gate covers the case where the user enters under cap and rotates past it during a single session. Copy URI stays enabled so paired clients can re-share for re-pair recovery.

Pre-fix evidence

  • Field test (build 63, 2026-05-08): user tapped "Connect Client" + "New Secret" repeatedly on a single signer, reached 7 successful bunker pairs. Documented cap is 5.
  • Proxy clients.json snapshot (~/hq/clave/troubleshooting/2026-05-08-clients-json-pre.json): 26 (signer, client) pairs across 15 distinct signers; highest per-signer count is 4. The bypass-test signer is absent from the dump — confirms bunker pairs leave no proxy-side trace (only nostrconnect goes through /pair-client). iOS is the sole enforcement layer for bunker.
  • Code review: LightSigner.swift:175-209 reads getConnectedClients(for:).count for the cap-check, but the post-connect side-effect at line 209 saves ClientPermissions only — never ConnectedClient. SharedStorage.updateClient lazily creates the row at first signing request; for clients that haven't signed yet, the count stays stale.

Test plan

  • Fresh install (or wipe one signer's pairs) on iPhone with build 69
  • Generate 5 successive bunker pairs (each via tapping "New Secret" + scanning into a different client) — all 5 succeed, all visible in Connected Clients list
  • Entry-point gate: dismiss ConnectSheet → tap "Connect a Client" on Home → "Connection limit reached" alert fires; sheet does NOT open
  • In-sheet gate: open ConnectSheet from a state where count < 5 (e.g. unpair one first, sheet opens), then trigger pairs to 5 from inside — tap "New Secret" → alert fires; secret does NOT rotate
  • NSE-side enforcement (defense-in-depth): if both UI gates somehow bypassed (external bunker URI flow), connect attempt chore: bump pbxproj to build 18 #6 → client receives "Pairing limit reached. Unpair an existing client in Clave settings." error
  • Capture Recent Logs (Settings → tap Version 7×) — entry should include Bunker connect rejected: pairing cap reached. Save to ~/hq/clave/troubleshooting/2026-05-08-bunker-cap-bypass-post.log.
  • Unpair one client → both gates clear → next pair succeeds
  • Verify xcodebuild test passes (already green locally; confirm in CI if any)

Tests added

ClaveTests/LightSignerBunkerConnectTests.swift — 4 tests covering:

  1. Bunker pair creates ConnectedClient row with relayUrls.contains(SharedConstants.relayURL)
  2. Cap-check blocks at 5 existing rows
  3. Repeat-pair of same (signer, client) doesn't double-count
  4. Per-signer scoping doesn't leak count across signers

Unblocks

Premium-tier entitlement plan — caps now count correctly across both flows, so tier-aware multipliers (free 5 → premium 30 clients/signer) inherit clean counters.

Out of scope (defer)

  • 5b (kind:0 client-identity lookup) — open design questions per BACKLOG
  • 5c (extractConnectName populate) — cheap, additive; spawn separately if a signer is observed passing name in connect params

Post-fix evidence

Captured 2026-05-09 01:27–01:28 UTC on build 69, real device. Full log: 2026-05-08-bunker-cap-bypass-post.log (saved to ~/hq/clave/troubleshooting/).

Smoking gun: 5a-core row creation at first-connect

01:28:18.649 [LightSigner] Method: connect id=fbr7of-1
01:28:18.652 [Storage] saveClientPermissions: signer=0066778e client=1b571115 trust=medium
01:28:18.653 [Storage] setClientRelayUrls (new client): 1b571115 signer=0066778e count=1
01:28:18.653 [LightSigner] New client paired with valid secret (row created, relay=wss://relay.powr.build)
01:28:18.654 [Storage] save OK: bunkerSecrets (1327 bytes)

Pre-fix: only the saveClientPermissions line would appear; the setClientRelayUrls (new client) row creation was deferred to first signing request. Now both happen atomically at handshake — the count getConnectedClients(for:) returns is authoritative at the time of the next bunker connect's cap-check.

Unpair → re-pair works (regression check)

01:27:53.192 [Pair] unpair-client begin client=5aa9faf1 signer=0066778e
01:27:53.205 [Storage] removeClientPermissions: signer=0066778e client=5aa9faf1
01:27:53.238 [fg-sub] refreshRelaySet: stop+restart
01:27:53.314 [Pair] unpair-client ok client=5aa9faf1 signer=0066778e
[…fg-sub re-establishes subscriptions to the post-unpair relay set…]
01:28:18.649 [LightSigner] Method: connect id=fbr7of-1 ← fresh pair lands cleanly

L1's refreshRelaySet fires correctly after unpair, confirming the relay-set computation observes ConnectedClient mutations.

Existing-client signing path unaffected (regression check)

01:28:19.475 [LightSigner] Method: get_public_key id=fbr7of-2
01:28:19.835 [LightRelay] Published event
01:28:19.934 [LightRelay] OK: true
01:28:19.934 [LightSigner] Response published successfully

get_public_key after the new pair signs and publishes successfully — the !isExistingClient branch where the new setClientRelayUrls call lives doesn't interfere with the existing-client path.

Cap rejection (defense-in-depth, NSE-side)

The 6th-attempt log line (Bunker connect rejected: pairing cap reached (5)) wasn't captured in this window, but the device-test confirmed the behavior: the 6th client received the "Pairing limit reached. Unpair an existing client in Clave settings." error in its UI on attempt. The rejection path is also covered by the unit test testBunkerConnect_capCheckBlocksAtFiveExistingRows.

DocNR added 3 commits May 8, 2026 20:16
Closes BACKLOG sprint 5a cap-bypass: bunker first-connect handler created
ClientPermissions but deferred ConnectedClient row creation until the
first signing request arrived (via SharedStorage.updateClient). Multiple
bunker pairs landed before any of them counted toward the 5-cap, allowing
7+ pairs in field testing on build 63.
Now calls SharedStorage.setClientRelayUrls immediately after
saveClientPermissions, mirroring the nostrconnect path's row-creation
timing. relayUrls = [SharedConstants.relayURL] also closes the L1
relay-set incompleteness noted in BACKLOG 5a (bunker pairs now contribute
to L1's foreground subscription set).
Adds LightSignerBunkerConnectTests covering: row creation with bunker
URI relay, cap-check at 5 existing rows, repeat-pair idempotency, per-
signer scoping.
HomeView.handlePairNewConnectionTap blocks ConnectSheet from opening at
cap, but a user already inside the sheet can rotate New Secret past cap
during a single session — the HomeView @State clients array is captured
at sheet-open and doesn't refresh until dismiss, so subsequent pair
attempts land downstream and NSE-reject with no Clave-side warning.
Mirror the entry-point alert at the in-sheet action: intercept New Secret
tap, read SharedStorage.getConnectedClients(for:) live (via
bunkerSecretsTick observation), show "Connection limit reached" alert
when count >= maxClientsPerAccount; do not rotate. Copy URI stays enabled
so existing paired clients can re-share for re-pair recovery (browser
cache cleared, etc.).
@DocNR
DocNR marked this pull request as ready for review May 9, 2026 01:32
@DocNR
DocNR merged commit 130510a into mainMay 9, 2026
@DocNR
DocNR deleted the feat/cap-correctness-bunker-rows branch May 9, 2026 01:37
DocNR added a commit that referenced this pull request May 9, 2026
Internal-only build bundling three merges since build 69 was tagged:
- Sprint 5a (#41): bunker pair-cap bypass fix
- Petname removal (#42): -108 LOC dead code
- Stage 4a (#43): AccountManager extension, -282 AppState LOC
Bumps CURRENT_PROJECT_VERSION 69 -> 70 across 4 targets x 2 configs.
Cumulative AppState refactor reduction: 2,338 -> 611 LOC (-73.9%).
Stage 4b (PendingApprovalCoordinator) is the final refactor PR.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DocNR added a commit that referenced this pull request May 9, 2026
…tFlight (#46)
Internal-only build for verification of Stage 4b (#45) — the final
extraction in the AppState god-object refactor sprint. Bumps
CURRENT_PROJECT_VERSION 70 -> 71 across all 4 targets in both Debug
and Release configs.
Build 71 cumulative content (since last external build 62):
- Sprint 5a: bunker pair-cap bypass fix + in-sheet cap gate (#41)
- AppState refactor sprint, Stages 1+2+3a+3b+3c+petname+4a+4b
(8 PRs total, AppState.swift 2,338 -> 348 LOC, -85.1%)
- Same-device nostrconnect overlay copy + QR rescan dedup (#36)
Verification gates for build 71 (final TF cycle of the sprint):
- xcodebuild test passes (231/231; identical pre/post Stage 4b) ✓
- On-device exercise of alert-chain UI binding (most behavior-relevant
Stage 4 verification):
- Pair client + sign multiple events back-to-back -> "X of N" advances
- Lock-screen approve/deny from notification (static handler check)
- "Not now" dismiss-all -> chain closes, badge stays accurate
- 5+ min wait with pending request -> TTL purge fires + "expired"
activity entry
- Background -> foreground -> bell badge updates correctly
After this verifies clean, the AppState refactor sprint is officially
complete. AppState.swift is a slim @observable container delegating
all functional concerns to 6 extension files (5 in Clave/, 1 in Shared/).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@DocNR