Files
Amber/desktop/LOCAL_SIGNER.md
greenart7c3 d8e4caad67 Fix Android Studio's inspection findings and turn off its spell check
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.
2026-10-26 17:28:06 -03:00

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

  • 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 mode 0700 and the socket with mode 0600.
  • 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 -U does. 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 error 10 "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:

  • kind is the context kind, a JSON number or a numeric string.
  • scope is 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 error 5, "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_key approval 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 0600 config 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_key can fix that. It brings up the approval for exactly that account, with the picker locked. Other methods are refused with 5.
  • 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_key brings 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 a 0700 directory.
    • 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.
  • 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:run uses signer-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.

  1. Parse the line and map the method onto the NIP-46 request shape. The event object becomes the JSON string NIP-46 uses.
  2. Check the lock and the accounts.
  3. Find the app for this tool in the target accounts. A tool matches an app that was approved on this connection, or whose socketSecretHash is the hash of the secret from the hello.
    • No match, get_public_key: generate a secret, unless the tool already has one. Queue a connect PendingBunkerRequest with no relays, the secret's hash, and a responder the server waits on. Once you approve, the reply carries the new secret.
    • No match, anything else: error 5.
  4. Run BunkerEngine.computeResult to get the result and preview, then lookupPermission and isRemembered to decide:
    • Allow: answer now and write the history.
    • Deny: error 5.
    • Ask: queue a PendingBunkerRequest (with kind and scope for v3) whose responder writes 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 (no connect or list_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*'