fix(security): strip BYOK nsec from Keycast OAuth wire (#3359)#3784
fix(security): strip BYOK nsec from Keycast OAuth wire (#3359)#3784realmeylisdev wants to merge 2 commits into
Conversation
This comment has been minimized.
This comment has been minimized.
|
Moving this to draft so it does not merge while we discuss the product implications in the issue. This changes the current Keycast BYOK contract and breaks the secure-account flow's "backup your key" behavior. I left more context here: #3359 (comment) Let's settle that first. |
d9e2fc7 to
feb110b
Compare
This comment has been minimized.
This comment has been minimized.
|
@realmeylisdev @dcadenas I took a fresh pass on #3784 and on the current main code path. My read is:
So I don’t think the right outcomes here are either merge this now as normal or close it as unnecessary. I think we need one explicit decision in #3359:
If it would help move this forward, @rabble and I can help turn that into a concrete go/no-go decision in #3359, but I don’t think this PR should just sit in ambiguous draft state. |
Draft review notesThe core security fix is directionally strong: removing Top risks before marking ready
ReadinessKeep draft until manual/staging secure-account upgrade behavior is verified or explicitly waived, the skipped-test exception is resolved/accepted, and the PII/logging nit is addressed. |
Pre-fix code transmitted the user's nsec to the Keycast server over two channels during BYOK secure-account upgrade: the top-level `nsec` field in `POST /api/headless/register` and an `nsec1...` suffix embedded in the PKCE `code_verifier` (later POSTed as `code_verifier` to `/api/oauth/token`). This Phase 1 emergency block removes both surfaces from the package API entirely so no caller can re-introduce the leak. Trade-off: until the proof-of-possession contract from divinevideo/keycast#197 ships and Phase 2 lands, BYOK secure-account upgrades fall through to the server's auto-generate path. The user's existing local pubkey identity is not preserved across the upgrade, but the local nsec stays on-device and never crosses the network. Also adds: - A POSIX-grep CI guard (mobile/tools/ci/grep_for_nsec_payload.sh) that fails if any production code re-introduces body['nsec'] or 'nsec':-as-key payload shapes. - A pending_verification_service.load() migration that discards any persisted verifier still carrying a legacy <random>.<nsec1...> shape, forcing a clean re-registration. - A defense-in-depth strip of the verifier value from EmailVerificationCubit's missing-code error log. Coordination: divinevideo/keycast#197 tracks the server-side proof-of-possession redesign that Phase 2 depends on.
Three small follow-ups from review on the Phase 1 nsec-strip PR: - session_expired_banner_test.dart: drop the 180-line dead body that sat behind `markTestSkipped + return + // ignore: dead_code`. The pre-skip body is preserved in commit feb110b and can be restored when Phase 2 lands. Eliminates the dead-code lint suppression and the now-unused imports. - pending_verification_service.dart: broaden the verifier migration guard from `.nsec1` to `nsec1`. Random base64url verifiers contain the literal `nsec1` only at ~1e-7 probability, so the false-positive cost is negligible against the one-time migration value. Catches any historical variant shape we didn't ship documentation for. Adds a test case for the bare-prefix shape. - mobile_ci.yaml: drop the `Install ripgrep` step. The guard script uses POSIX find + grep -E only; the apt-install was wasted setup and adds ~5-10s per CI run for no benefit.
feb110b to
b0cb1ee
Compare
|
Thanks for the review @rabble. Items (1), (3), and (5) are addressed in b0cb1ee:
Two open items below.
Confirmed from an end-to-end trace:
Concrete user-visible impact for an anonymous user who upgrades:
The test plan's
Repo-wide audit — email is logged at info/warning level in the same flow today at 5 other sites:
The new warning at line 113 is the same shape as the 4 sibling logs in the same Two coherent options:
Defaulting to (1) unless you'd rather see (2) shipped in this PR. |
Email addresses were logged in plaintext at 6 sites across the auth flow (3 in PendingVerificationService, 2 in DivineAuthCubit, 1 in EmailVerificationCubit). Flagged in #3784's review (point 4) but kept out of the emergency-security scope of that PR — addressed here as the systematic fix. Approach: - New `redactEmailForLogs(String)` helper in lib/utils/sensitive_uri_for_logs.dart. Partial-redaction (`user@example.com` → `u***@example.com`) preserves the domain so ops can correlate failure patterns per provider without identifying individual accounts. Empty / malformed input returns the existing `redactedSensitiveLogPlaceholder`. - Broadened `sanitizeForCrashReport` in lib/observability/reportable_error.dart to also strip emails before forwarding to Crashlytics — defense-in-depth so any future call site that forgets the helper still gets sanitized when its error flows through `Reportable.toString()`. - All 6 call sites on origin/main wrapped with the helper. The 7th site (the legacy-nsec migration warning added by #3784) is intentionally NOT included here — whichever of #3784 / this PR merges second receives a small rebase to wrap that site too. Tests: - 9 new cases for `redactEmailForLogs` (standard, single-char, long local-part, subdomains, empty, no-`@`, empty local-part, no-TLD domain, whitespace). - 4 new cases for `sanitizeForCrashReport` covering email stripping, multiple emails, mixed npub/nsec/email, and domain preservation. Closes #4254.
* fix(security): redact email PII from auth-flow logs (#4254) Email addresses were logged in plaintext at 6 sites across the auth flow (3 in PendingVerificationService, 2 in DivineAuthCubit, 1 in EmailVerificationCubit). Flagged in #3784's review (point 4) but kept out of the emergency-security scope of that PR — addressed here as the systematic fix. Approach: - New `redactEmailForLogs(String)` helper in lib/utils/sensitive_uri_for_logs.dart. Partial-redaction (`user@example.com` → `u***@example.com`) preserves the domain so ops can correlate failure patterns per provider without identifying individual accounts. Empty / malformed input returns the existing `redactedSensitiveLogPlaceholder`. - Broadened `sanitizeForCrashReport` in lib/observability/reportable_error.dart to also strip emails before forwarding to Crashlytics — defense-in-depth so any future call site that forgets the helper still gets sanitized when its error flows through `Reportable.toString()`. - All 6 call sites on origin/main wrapped with the helper. The 7th site (the legacy-nsec migration warning added by #3784) is intentionally NOT included here — whichever of #3784 / this PR merges second receives a small rebase to wrap that site too. Tests: - 9 new cases for `redactEmailForLogs` (standard, single-char, long local-part, subdomains, empty, no-`@`, empty local-part, no-TLD domain, whitespace). - 4 new cases for `sanitizeForCrashReport` covering email stripping, multiple emails, mixed npub/nsec/email, and domain preservation. Closes #4254. * fix(security): redact persisted verification email log * fix(test): drop redundant mock argument * fix(security): share email redaction between logs and crash reports * docs(security): clarify shared email redaction path --------- Co-authored-by: Liz Sweigart <127434495+NotThatKindOfDrLiz@users.noreply.github.com>
Filed #4349 for the systematic option-2 path (new `redactEmailForLogs` helper colocated with `lib/utils/sensitive_uri_for_logs.dart`, applied across all 6 audit sites + orphaned ARB key removal + lessons capture). Defaulting to option 1 in this PR per the existing-convention rationale upthread; the cleanup will land in a small focused follow-up after #3784 merges. |
|
@rabble I think we need your direct read before this leaves draft. Core concernProof-of-possession proves control of a pubkey, but it does not give Keycast the key material needed for managed RPC/Nostr signing.
Why binding is a big changeIf the replacement is account/pubkey binding to an autogenerated Keycast key, that opens the identity-binding can of worms.
Questions
Prior contextThis is the identity divergence problem I was trying to call out earlier: Simpler product alternativeThe only practical alternative I see is to explicitly choose a simpler product rule:
SummaryProof-of-possession-only Phase 2 may preserve account/pubkey binding, but it does not restore Keycast managed signing/backup. |
Agreed — that reframing (pubkey-binding ≠ managed signing) is the right one. Phase 2 as currently scoped in #3786 / divinevideo/keycast#197 is PoP-style pubkey-binding, not key custody. For users with an existing local What I can speak to as PR author:
Q1, Q2, Q3, Q6, Q7, Q8 are product/protocol-contract calls about what BYOK should be after Phase 2 lands. Deferring to @rabble — agree this needs his direct read before #3784 leaves draft. |
Description
Phase 1 emergency block for #3359 — strips the user's nsec from the
Keycast OAuth registration wire on both channels:
body['nsec']field inPOST /api/headless/register<random>.<nsec1...>suffix embedded in the PKCEcode_verifier(later POSTed as
code_verifierto/api/oauth/token)The fix removes the parameters from the
keycast_flutterpackage APIentirely so a future caller cannot re-introduce the leak. A POSIX-grep
CI guard locks the rule in.
Related Issue: Closes #3359
Cross-repo coordination: divinevideo/keycast#197 tracks the
server-side proof-of-possession redesign that Phase 2 depends on.
This PR ships without preserving BYOK identity binding — that is
restored once Phase 2 lands.
Type of Change
KeycastOAuth.headlessRegisterandKeycastOAuth.getAuthorizationUrlAPIs no longer acceptnsec— intentional, for security)Trade-off
Until Phase 2 ships, BYOK secure-account upgrades fall through to the
server's auto-generate path. The user's existing local pubkey identity
is not preserved across the upgrade, but the local nsec stays
on-device and never crosses the network. This is the intentional
emergency-block trade-off — stop the leak today, restore identity
preservation in Phase 2 (depends on divinevideo/keycast#197).
What's in this PR
mobile/packages/keycast_flutter/lib/src/oauth/pkce.dartgenerateVerifierno longer takesnsec; verifier is always pure-random base64urlmobile/packages/keycast_flutter/lib/src/oauth/oauth_client.dartgetAuthorizationUrl&headlessRegisterdropnsec/byokPubkeyparameters;body['nsec']removed;byok_pubkeyURL param removedmobile/packages/keycast_flutter/test/{pkce,oauth_client}_test.dartmobile/lib/services/pending_verification_service.dartload()discards any persisted verifier containing.nsec1and clears storage (defense-in-depth migration)mobile/lib/blocs/email_verification/email_verification_cubit.dart_pendingVerifiervaluemobile/lib/screens/auth/secure_account_screen.dartauthService.exportNsec()and thensec:argmobile/test/services/pending_verification_service_test.dartmobile/tools/ci/grep_for_nsec_payload.sh.github/workflows/mobile_ci.yamlnsec-leak-guardjobmobile/integration_test/auth/session_expired_banner_test.dartmarkTestSkippeduntil Phase 2 — depends on BYOK preservationTest plan
flutter analyze lib test integration_test packages/keycast_flutter/{lib,test}— no issuesflutter test packages/keycast_flutter/— 167/167 passingflutter test test/services/pending_verification_service_test.dart test/screens/auth/secure_account_screen_test.dart test/blocs/email_verification/email_verification_cubit_test.dart— 38/38 passingflutter test test/blocs/divine_auth/— 64/64 passing (regression-safety on the path that never carried nsec)bash mobile/tools/ci/grep_for_nsec_payload.sh— exits 0 on this branchbody['nsec']+'nsec':; ignorescontainsKey('nsec')+any(named: 'nsec')Follow-ups (not in this PR)
authUnableToAccessKeys(no longer referenced from Dart) — left in place to avoid touching 16 locale files; safe to remove in a follow-up