Android Studio's batch inspections reported about 800 problems besides spelling, which the project profile now leaves off (it mostly flagged the translations). Real bugs among them: the desktop's strings files had empty strings where Android has translations (German, Spanish, Brazilian Portuguese, Chinese), showing blank text; and IS_FDROID_BUILD, which the F-Droid workflow flips with sed, was read as a constant the IDE wanted to simplify away, so it is now read through BuildFlavorChecker.isFdroidBuild(). Account loads in coroutines (relay, intent and UI paths) and logout no longer block a thread with runBlocking; the blocking ContentProvider path and the desktop lock say why they do. Also remove what was dead (the drawable IconRow overload, PermissionCard, ConfirmationToastMsg, the desktop OkHttpWebSocket.Builder and DeleteAfterType.screenCode, the QR drawer's zero corner radius math and the strings only they used), move to the Duration overloads, give subject-less when blocks their subject, drop redundant opt-ins, suspend modifiers, qualifiers and SAM constructors, fix broken KDoc links, take the ktlint and KSP plugin versions from the catalog, strip editor metadata from the SVGs, align the Markdown tables and fix real grammar and spelling in comments, docs and three English strings.
21 KiB
Local socket signer (NIP-5F)
Amber Desktop can sign for command-line tools such as nak, ngit, scripts and AI agents over a local Unix domain socket. This follows the NIP-5F draft (nostr-protocol/nips#1862), with NIP-55's login and account model.
You configure every account once in Amber. Any tool on the machine can then use them without its own key storage, relay setup or pairing string. Every request still goes through Amber's normal approval and permission system.
It's always on: Amber starts the socket at launch.
Contents
- Why a socket
- Where the socket is
- Session walkthrough
- Message format
- Methods
- Logging in with get_public_key
- The tool secret
- Choosing the account
- Approval and permissions
- Errors
- Security model
- Writing a client
- How it works inside Amber
- Testing
Why a socket
- Local only. The socket is a file on your disk with owner-only permissions. No relay sees the requests, and nothing is exposed on the network.
- Fast. NIP-46 sends each request as an encrypted kind-24133 event through a relay and waits for the reply event. A request over the socket is a local read and write.
- Works offline. No relays are needed.
- Configure once. One Amber serves every tool and every account.
- Cross-platform. AF_UNIX sockets work on Linux, macOS and Windows 10 and later. The desktop app runs on JDK 21, which supports them on all three.
Where the socket is
| Build | Path |
|---|---|
| Release (installed app) | ~/.local/share/nostr/signer.sock |
Debug (./gradlew :desktop:run) |
~/.local/share/nostr/signer-debug.sock |
The path is the same on every OS. On Windows ~ is the user profile folder,
for example C:\Users\you\.local\share\nostr\signer.sock.
What happens to the file:
- Start: Amber creates
~/.local/share/nostr/with mode0700and the socket with mode0600. - Stop: Amber deletes the socket when the app exits.
- Leftover file: a socket file left behind by a crash is replaced.
- Path already in use: if another program is listening on that path, Amber doesn't take it over and logs it.
The socket stays open while Amber is locked. Requests made while it's locked
are answered with error 10 ("signer locked") instead of hanging.
Session walkthrough
First run of a tool:
tool Amber
| connect to signer.sock |
|<-- {"name":"Amber","supported_methods":[...]} handshake
|--> {"client":"nak"} hello
|--> {"id":"1","method":"get_public_key","params":[]}
| | unknown tool → approval prompt
| | you approve and pick an account
|<-- {"id":"1","result":"<hex pubkey>","secret":"<64 hex>","error":null}
| (the tool saves the secret) |
|--> {"id":"2","method":"sign_event","params":[{...}]}
| | stored permission → sign now
| | denied → error 5
| | no rule → approval prompt
|<-- {"id":"2","result":{signed event},"error":null}
Every run after that:
|<-- {"name":"Amber",...}
|--> {"client":"nak","secret":"<saved secret>"} recognized, no prompt
|--> {"id":"1","method":"sign_event","params":[{...}]}
|<-- {"id":"1","result":{signed event},"error":null}
Message format
Each message is one JSON object on one line: UTF-8, ending in \n, at most
1 MiB.
Handshake. Amber sends it first:
{"name":"Amber","supported_methods":["get_public_key","sign_event","nip04_encrypt","nip04_decrypt","nip44_encrypt","nip44_decrypt","nip44v3_encrypt","nip44v3_decrypt","decrypt_zap_event","ping"]}
Hello. The client's first line:
{"client":"nak"}
{"client":"nak","secret":"<secret from an earlier approval>"}
The name is only a label for prompts and the Applications screen. It never identifies the tool; the secret does. If a client skips the hello and sends a request straight away, Amber treats it as an unnamed client ("Local client").
Request:
{"id":"<string or number>","method":"<method>","params":["<param>"]}
Response. The id is echoed back as a string:
{"id":"1","result":"<value>","error":null}
{"id":"1","result":"<hex pubkey>","secret":"<64 hex>","error":null}
{"id":"1","result":null,"error":{"code":5,"message":"user rejected"}}
The second form, with secret, only appears on the get_public_key answer
that got the tool approved.
Rules for clients:
- Requests can overlap. A client can send more requests while an earlier
one is still waiting for approval. Answers come back as each one finishes, so
match them by
id, not by order. - One-shot pipes work for answers that need no prompt. A client can send
everything and then close its sending side, as
echo … | nc -Udoes. Amber still answers every request it already received before closing the connection. Amber can't tell a half-close from a tool that exited, so a request that would need a prompt is answered at once with error10"connection closed before approval" instead of waiting. Keep the connection open until the answers you're waiting for arrive. - Many clients at once. Each connection is a separate session. Prompts from different tools queue side by side, and each answer goes back only to the connection that asked.
Methods
The optional account param is described in
Choosing the account.
| Method | Params | Result |
|---|---|---|
get_public_key |
[account?] |
hex pubkey; logs the tool in (see below) |
sign_event |
[event, account?] |
the signed event (object) |
nip04_encrypt |
[pubkey, plaintext, account?] |
ciphertext |
nip04_decrypt |
[pubkey, ciphertext, account?] |
plaintext |
nip44_encrypt |
[pubkey, plaintext, account?] |
ciphertext |
nip44_decrypt |
[pubkey, ciphertext, account?] |
plaintext |
nip44v3_encrypt |
[pubkey, kind, scope, base64 plaintext, account?] |
ciphertext |
nip44v3_decrypt |
[pubkey, kind, scope, ciphertext, account?] |
base64 plaintext |
decrypt_zap_event |
[zap request event, account?] |
the decrypted zap request (object) |
ping |
[account?] |
"pong" |
Event params. For sign_event, the event is an unsigned event object with
kind, content, tags and created_at. A JSON string of the event is also
accepted. The result is a full signed event object with id, pubkey and
sig:
{"id":"2","method":"sign_event","params":[{"kind":1,"content":"hello","tags":[],"created_at":1700000000}]}
NIP-44 v3 params use the same layout as Amber's NIP-46 bunker:
kindis the context kind, a JSON number or a numeric string.scopeis the context scope string.- Plaintext travels as base64 in both directions: you send it base64 to encrypt, and decrypt returns it base64.
- Grants are per context kind: approving with "this kind only" or "all kinds" behaves as for NIP-46. The scope is shown in the prompt but is not part of the grant: a remembered grant for kind K covers every scope of kind K.
Not implemented: the draft's list_public_keys and any connect method.
get_public_key is the only way in, as in NIP-55.
Logging in with get_public_key
As in NIP-55, get_public_key is the login.
- Unknown tool calls it: Amber shows an approval prompt with an account picker, the sign policy (basic, manual or full trust) and a "delete after" time.
- You approve it: Amber creates an application ("Local socket" under Applications) for the account you picked. It returns that account's pubkey, plus a fresh secret.
- Anything else from a tool that isn't logged in (
sign_event, encrypt, …) is refused with error5, "not approved: call get_public_key first", and no prompt appears.
The tool secret
A socket doesn't say which program is on the other end, so a tool's name can't
be trusted. Any program running as you could claim to be nak. Amber
identifies tools by a secret instead.
- Issued on approval. When a
get_public_keyapproval goes through, Amber generates a random 32-byte secret, returns it beside the pubkey, and stores only its SHA-256 hash on the app. - Presented in every later hello.
{"client":"nak","secret":"…"}is recognized at once, with no prompt. The name it uses doesn't matter. - The name alone gets you nothing. A connection without the right secret
is a new tool. Its requests are refused until it logs in with
get_public_key, which means a new approval from you. A guessed or wrong secret is the same as none. - On the connection that got approved, the tool stays recognized even if it ignores the secret. A tool that never saves the secret simply needs approving once per connection.
- One secret per tool. If a tool that already has a secret logs in to a second account, the same secret covers both. No new secret is returned.
- Treat it like a password. Store it only where your user can read it, for
example in a
0600config file. To revoke it, delete the app under Applications.
Choosing the account
Every method takes an optional last param that names the account, as a hex pubkey or an npub.
With an account:
- Amber uses that account.
- If the tool isn't logged in to it, only
get_public_keycan fix that. It brings up the approval for exactly that account, with the picker locked. Other methods are refused with5. - If the account isn't loaded in Amber, the answer is error
4.
Without an account:
- Amber uses an account the tool is logged in to. If one of them is the current account, that one wins.
- For a tool that isn't logged in anywhere,
get_public_keybrings up the approval with the picker starting on the current account. The account you pick is the one the tool gets.
A tool that wants to act as a specific identity calls get_public_key once,
keeps the pubkey, and passes it as the last param of later calls. For example,
an agent told to "post this as soapbox" passes soapbox's npub.
Approval and permissions
After login, a tool's requests are checked against its app's stored permissions, exactly like a NIP-46 connection:
| Stored rule | What happens |
|---|---|
| allow (until a time, or always) | answered immediately |
| deny | answered with error 5 |
| none | an approval prompt in Incoming requests, with a notification if enabled |
Your choice can be remembered for a set time or always. Encryption and decryption grants are scoped by content type, as on Android. NIP-44 v3 grants are scoped by context kind only, not by the scope string.
Pending requests expire after 10 minutes. If one is dropped instead of being answered, the tool still gets a reply:
| Reason | Error |
|---|---|
| Expired | 10 "request expired" |
| Amber locked | 10 "signer locked" |
| Account removed | 10 "account removed" |
| Sending side closed | 10 "connection closed before approval" |
If the tool disconnects, or closes its sending side, its pending prompts are removed. Other tools' prompts stay.
Activity for each tool is recorded in its app history. Requests also
appear in the account log as socket / local-signer.
Errors
| Code | Meaning | Examples |
|---|---|---|
| 1 | Invalid request | not JSON, missing id or method, line over 1 MiB |
| 2 | Method not supported | sign_psbt, connect, list_public_keys |
| 3 | Invalid params | wrong param count, wrong types, a v3 kind that isn't a number, an event that can't be signed, ciphertext that can't be decrypted |
| 4 | Key not found | unknown account param, no accounts in Amber |
| 5 | User declined | rejected prompt, a remembered "deny" rule, a tool that isn't logged in |
| 10 | Internal error | signer locked, request expired, account removed |
Malformed requests are reported first, before lock or account checks, so a typo gets code 1, 2 or 3 even while Amber is locked.
Security model
- Same user only.
- Socket file:
0600, inside a0700directory. - Linux and macOS: Amber also checks the connecting process's credentials
(
SO_PEERCRED) and drops connections from other OS users. - Windows: no peer credentials. The ACLs on the user profile folder do the same job.
- Socket file:
- No impersonation. An approved tool is identified by its secret, never by its name. Another program can't use an approved tool's permissions without stealing that secret.
- Nothing signs without your approval. Any process running as you can connect, but it only gets what you approve, or what an earlier "remember" choice allows for its secret.
- Abuse is bounded. Per-sender rate limiting and bounded message buffers guard the shared request engine, and an unapproved request is answered once and never retried, so a looping or hostile client cannot make Amber spin up unbounded work.
- Keys never leave Amber. Tools get signatures, ciphertexts and plaintexts, never keys.
- Locked means locked. While the passphrase lock is on, key material isn't
in memory and every request fails with
10. - Debug builds stay separate.
:desktop:runusessigner-debug.sock, so a development build never answers for, or takes over, the installed app.
Writing a client
Minimal Python client that logs in once and reuses its secret:
import json, os, socket
SOCK = os.path.expanduser("~/.local/share/nostr/signer.sock")
STATE = os.path.expanduser("~/.config/my-tool/amber-secret") # keep it 0600
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect(SOCK)
f = s.makefile("rw", encoding="utf-8")
def call(id, method, *params):
f.write(json.dumps({"id": id, "method": method, "params": list(params)}) + "\n")
f.flush()
return json.loads(f.readline())
json.loads(f.readline()) # handshake
hello = {"client": "my-tool"}
if os.path.exists(STATE):
hello["secret"] = open(STATE).read().strip()
f.write(json.dumps(hello) + "\n")
reply = call("1", "get_public_key") # prompts in Amber the first time
if "secret" in reply: # just approved: save it
os.makedirs(os.path.dirname(STATE), exist_ok=True)
fd = os.open(STATE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
os.write(fd, reply["secret"].encode())
os.close(fd)
pubkey = reply["result"]
event = {"kind": 1, "content": "hello", "tags": [], "created_at": 1700000000}
print(call("2", "sign_event", event, pubkey)["result"])
From a shell:
socat - UNIX-CONNECT:$HOME/.local/share/nostr/signer.sock
{"client":"socat"}
{"id":"1","method":"get_public_key","params":[]}
{"id":"2","method":"sign_event","params":[{"kind":1,"content":"hi","tags":[],"created_at":1700000000}]}
How it works inside Amber
| File | Role |
|---|---|
core/LocalSignerProtocol.kt |
Wire format: the method table, parsing params into Quartz NIP-46 BunkerRequests, building responses (including the secret field), error codes. No I/O. |
core/LocalSigner.kt |
The server: starting and stopping the socket, the peer check, one session per connection, login and secret handling, the per-request flow. |
core/BunkerEngine.kt |
Shared with NIP-46: computeResult (sign, encrypt, decrypt, v3), lookupPermission, the pending queue, approve/reject, dropPending. |
core/Models.kt |
AppRecord.transport ("nip46" or "socket") and socketSecretHash. |
Request flow. For each line, LocalSigner does the following.
- Parse the line and map the method onto the NIP-46 request shape. The event object becomes the JSON string NIP-46 uses.
- Check the lock and the accounts.
- Find the app for this tool in the target accounts. A tool matches an app
that was approved on this connection, or whose
socketSecretHashis the hash of the secret from the hello.- No match,
get_public_key: generate a secret, unless the tool already has one. Queue a connectPendingBunkerRequestwith no relays, the secret's hash, and aresponderthe server waits on. Once you approve, the reply carries the new secret. - No match, anything else: error 5.
- No match,
- Run
BunkerEngine.computeResultto get the result and preview, thenlookupPermissionandisRememberedto decide:- Allow: answer now and write the history.
- Deny: error 5.
- Ask: queue a
PendingBunkerRequest(with kind and scope for v3) whoseresponderwrites the answer back to this connection.
When you approve or reject in the UI, BunkerEngine.doApprove/doReject
update permissions and history as for any connection. They then call the
request's responder instead of publishing a kind-24133 event. They never set
relays or per-connection keys on socket apps, and they store the secret hash
on new socket apps. dropPending answers waiting socket clients when requests
are removed without a decision: expiry, lock, account removal or disconnect.
Queue ids. Requests are queued under socket-<session>-<seq>-<client id>.
This keeps ids from different clients, or a client that reuses ids, from
colliding in the queue. A disconnect drops exactly that session's entries.
Testing
LocalSignerProtocolTest: parsing, the method table (noconnectorlist_public_keys), account params, error codes and response shapes.LocalSignerTest: an end-to-end run over a real socket against the real approval queue, with no relays. It covers:- socket permissions
- login, then remembered signing
- rejection
- refusal before login
- secret handout, reuse and impersonation attempts
- NIP-44 v3 round trip with per-kind prompts
- account selection and the NIP-55 login
- overlapping requests
- eight clients queuing prompts at the same instant
- dropped requests
- tools that exit with a prompt open
- half-closed clients, with and without prompts
- leftover and in-use socket files
./gradlew :desktop:test --tests '*LocalSigner*'