Sign in with a private key (nsec / hex / ncryptsec) #8

Merged
laoc merged 5 commits from feat/nsec-login into main 2026-08-06 10:26:23 +00:00
Owner

Stacks on #7 (feat/app-relays), which stacks on #6. Merge in that order.

Closes the nsec half of fa12577d.

Why

Someone with an nsec in a password manager and no signer app currently has nowhere to go. Amber needs a second app; a bunker needs a running signer and a relay. This is the way in that needs neither.

What it is

Three pieces, split deliberately.

PrivateKeyInput classifies the pasted text before any key material is touched — pure string work, no rust-nostr, no Android. Two reasons it is its own step:

  • The password field has to appear for an ncryptsec and stay away for an nsec, and that is decided while the user is still typing.
  • A paste in the wrong box is the likeliest failure here. Keys.parse on an npub says the string is not a secret key, which reads as you typed it wrong and sends someone hunting for a typo that does not exist. Now it says it is a public key. Same for a bunker:// code, which is pointed at the row above.

LocalKeySigner is the signer. It re-signs the JSON the app hands it rather than rebuilding the event from what it thinks the app meant — same seam the NIP-55 path uses, so a bug in our tag construction shows up here instead of being quietly corrected into something the app never produced.

Storage is a sealed slot in StashPreferences under its own keystore alias, not the bunker's. A NIP-46 client key can be revoked at the bunker and re-paired; this key is the identity, so dropping one login must not be able to make the other unopenable — in either direction. There is a test for exactly that.

The order on screen

Amber → bunker → private key, and the private-key row carries a plain-words warning rather than a warning icon. Nostrord puts private key first. I think that is the wrong default for a beta app people are sideloading, but it is a one-line change and it is @laoc42's call.

The honest cost

StashPreferences used to document itself as "this process never holds a private key at all". After this it does. That comment is now corrected rather than left standing. The key is AES-GCM sealed by a keystore key (hardware-backed where the device has it), but it is on the phone, and anything that reads it is the user, permanently.

Verification

device      76/76   at a966c32, clean emulator lifecycle   (was 56; +20 new)
JVM        389 tests, 0 skipped, 0 failures
mutations   12/12 caught

Test keys are NIP-19's and NIP-49's own published vectors, so a pass says the app agrees with the specs rather than with itself. My first draft of the ncryptsec was recalled from memory and was wrong by one character; classification would have passed on it either way, which is why it needed checking against the spec rather than against the code.

The restart test asserts a publish, not a screen

a_stored_key_still_signs_after_a_restart shares a link and saves it, then reads the event off the relay. Reaching the library would have proved nothing: the app opens on its cache with no signer at all, so "not the login screen" is true even when nothing can sign. It also polls from inside the running Activity — finishing it first cancels the publish in viewModelScope, and an empty relay then reads exactly like a signer that was never rebuilt.

Mutations

Each row patched one line, asserted the patch landed with git diff --numstat, and ran the class that claims to cover it:

mutation caught by
npub branch removed PrivateKeyInputTest
ncryptsec stops asking for a password PrivateKeyInputTest
hex length check dropped PrivateKeyInputTest
input not trimmed PrivateKeyInputTest
created_at rebuilt instead of carried LocalKeySignerE2ETest
wrong password reported as an unusable key LocalKeySignerE2ETest
secret stored in the clear LocalKeyStorageE2ETest
local key shares the bunker's keystore alias LocalKeyStorageE2ETest
login does not store the key PrivateKeyLoginE2ETest
startup does not rebuild the signer PrivateKeyLoginE2ETest
logout leaves the key behind PrivateKeyLoginE2ETest
a refusal never reaches the screen PrivateKeyLoginE2ETest

One row first reported CAUGHT (does not compile). That is not a verdict — it means my harness conflated a build failure with a caught mutation. Re-run on its own it compiles fine and is genuinely caught (4 passed, 1 failed, and the failure is the alias test). Reported because a scripted trial name is not evidence.

Not proven

  • An ncryptsec written by another app. The vector under test is the spec's own, so the format is right, but nothing here has read a file produced by Amber or nsec.app.
  • Everything is an emulator with a local relay.

Harness notes for whoever is next

The login screen now scrolls, so the private-key row sits below the fold. performClick does not scroll to its target, and five tests failed with the click silently doing nothing rather than with an error — which reads as "the button is broken". performScrollTo() first. Same for Sign out on the settings screen.

🤖 Generated with Claude Code

Stacks on #7 (`feat/app-relays`), which stacks on #6. Merge in that order. Closes the nsec half of `fa12577d`. ## Why Someone with an nsec in a password manager and no signer app currently has **nowhere to go**. Amber needs a second app; a bunker needs a running signer and a relay. This is the way in that needs neither. ## What it is Three pieces, split deliberately. **`PrivateKeyInput`** classifies the pasted text before any key material is touched — pure string work, no rust-nostr, no Android. Two reasons it is its own step: - The password field has to appear for an `ncryptsec` and stay away for an `nsec`, and that is decided while the user is still typing. - A paste in the wrong box is the likeliest failure here. `Keys.parse` on an npub says the string is not a secret key, which reads as *you typed it wrong* and sends someone hunting for a typo that does not exist. Now it says it is a public key. Same for a `bunker://` code, which is pointed at the row above. **`LocalKeySigner`** is the signer. It **re-signs the JSON the app hands it** rather than rebuilding the event from what it thinks the app meant — same seam the NIP-55 path uses, so a bug in our tag construction shows up here instead of being quietly corrected into something the app never produced. **Storage** is a sealed slot in `StashPreferences` under **its own keystore alias**, not the bunker's. A NIP-46 client key can be revoked at the bunker and re-paired; this key *is* the identity, so dropping one login must not be able to make the other unopenable — in either direction. There is a test for exactly that. ## The order on screen `Amber → bunker → private key`, and the private-key row carries a plain-words warning rather than a warning icon. Nostrord puts private key first. I think that is the wrong default for a beta app people are sideloading, but it is a one-line change and it is @laoc42's call. ## The honest cost `StashPreferences` used to document itself as *"this process never holds a private key at all"*. **After this it does.** That comment is now corrected rather than left standing. The key is AES-GCM sealed by a keystore key (hardware-backed where the device has it), but it is on the phone, and anything that reads it is the user, permanently. ## Verification ``` device 76/76 at a966c32, clean emulator lifecycle (was 56; +20 new) JVM 389 tests, 0 skipped, 0 failures mutations 12/12 caught ``` Test keys are **NIP-19's and NIP-49's own published vectors**, so a pass says the app agrees with the specs rather than with itself. My first draft of the ncryptsec was recalled from memory and was wrong by one character; classification would have passed on it either way, which is why it needed checking against the spec rather than against the code. ### The restart test asserts a publish, not a screen `a_stored_key_still_signs_after_a_restart` shares a link and saves it, then reads the event **off the relay**. Reaching the library would have proved nothing: the app opens on its cache with no signer at all, so *"not the login screen"* is true even when nothing can sign. It also polls from **inside** the running Activity — finishing it first cancels the publish in `viewModelScope`, and an empty relay then reads exactly like a signer that was never rebuilt. ### Mutations Each row patched one line, asserted the patch landed with `git diff --numstat`, and ran the class that claims to cover it: | mutation | caught by | |---|---| | npub branch removed | `PrivateKeyInputTest` | | `ncryptsec` stops asking for a password | `PrivateKeyInputTest` | | hex length check dropped | `PrivateKeyInputTest` | | input not trimmed | `PrivateKeyInputTest` | | `created_at` rebuilt instead of carried | `LocalKeySignerE2ETest` | | wrong password reported as an unusable key | `LocalKeySignerE2ETest` | | secret stored in the clear | `LocalKeyStorageE2ETest` | | local key shares the bunker's keystore alias | `LocalKeyStorageE2ETest` | | login does not store the key | `PrivateKeyLoginE2ETest` | | startup does not rebuild the signer | `PrivateKeyLoginE2ETest` | | logout leaves the key behind | `PrivateKeyLoginE2ETest` | | a refusal never reaches the screen | `PrivateKeyLoginE2ETest` | One row first reported `CAUGHT (does not compile)`. That is not a verdict — it means my harness conflated a build failure with a caught mutation. Re-run on its own it compiles fine and is genuinely caught (4 passed, 1 failed, and the failure is the alias test). Reported because a scripted trial name is not evidence. ## Not proven - **An `ncryptsec` written by another app.** The vector under test is the spec's own, so the format is right, but nothing here has read a file produced by Amber or nsec.app. - Everything is an emulator with a local relay. ## Harness notes for whoever is next The login screen now scrolls, so the private-key row sits below the fold. `performClick` does **not** scroll to its target, and five tests failed with the click silently doing nothing rather than with an error — which reads as "the button is broken". `performScrollTo()` first. Same for `Sign out` on the settings screen. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The third way in, after Amber (NIP-55) and a bunker (NIP-46): a user with
an nsec in a password manager and no signer app has nowhere to go today.

Three pieces, and the split is deliberate. PrivateKeyInput classifies the
text before any key material is touched — the password field has to appear
for an ncryptsec and stay away for an nsec while the user is still typing,
and the likeliest failure here is a paste in the wrong box. Keys.parse on
an npub says the string is not a secret key, which reads as "you typed it
wrong" and sends someone hunting for a typo that does not exist; now it
says it is a public key.

LocalKeySigner is the signer itself, re-signing the JSON the app hands it
exactly as the NIP-55 path does rather than rebuilding the event. Same
seam as the other two signers, so a bug in our tag construction shows up
here instead of being quietly corrected.

StashPreferences gains a sealed slot for the secret key, under its own
keystore alias — not the bunker's. A NIP-46 client key can be revoked and
re-paired; this key IS the identity, so dropping one session must not be
able to make the other unopenable, in either direction.

The device tests use NIP-19's and NIP-49's own published vectors, so a
pass says the app agrees with the specs rather than with itself. The first
draft of the ncryptsec was recalled from memory and was wrong by one
character — classification would have passed on it either way, which is
exactly why it needed checking against the spec.

Not wired to any screen yet.
The login screen now has three rows: Amber, a bunker code, and a private
key — in that order, deliberately. The first two exist so that the key
need not be in this process, and that is not taste: a NIP-46 pairing can
be revoked and re-made, whereas an nsec IS the account. The row says so
in plain words rather than a warning icon.

The password field appears only for an ncryptsec, decided as the user
types. Refusals land beside the field, not in the snackbar — the user is
looking at what they typed and every one of them is fixed by editing it.

The ViewModel rebuilds the signer at startup from the sealed key, and
logout deletes both the ciphertext and the keystore key that opens it.
Restore deliberately does NOT fall through to Amber when the stored key
is unopenable: someone who never installed a signer app must not be sent
to one.

The restart test asserts a PUBLISH, not a screen. Reaching the library
proves only that an identity was restored — the app opens on its cache
with no signer at all, so "not the login screen" is true even when
nothing can sign. It polls the relay from inside the running Activity
too: finishing it first cancels the publish in viewModelScope, and an
empty relay then reads exactly like a signer that was never rebuilt.

Two harness notes worth keeping. The login screen now scrolls, so the
private-key row is below the fold on the emulator; performClick does not
scroll, and five tests failed with the click silently doing nothing
rather than with an error. Same for Sign out on the settings screen.

76/76 on the device, 389 JVM.
Bump versionCode so the private-key test build upgrades
All checks were successful
build / build (push) Successful in 6m56s
a966c32750
The APK laoc already has from feat/app-relays is versionCode 16, and
Android refuses a sideload at an equal code. versionName stays 0.12.0 —
same release, more in it.
Bump versionCode for the build carrying the bunker relay-list fix
All checks were successful
build / build (push) Successful in 6m52s
73d51e1297
17 is already on laoc's phone and Android refuses an equal code.
laoc changed target branch from feat/app-relays to main 2026-08-06 10:26:16 +00:00
laoc merged commit 1386cfb728 into main 2026-08-06 10:26:23 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
laoc/stash-bookmark!8
No description provided.