Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
**Notary**: a native NIP-46 remote signer for Nostr. macOS (Apple Silicon), **ad-hoc signed (not notarized)**, and Linux (x86_64 and aarch64).

### What's new in v0.10.10

**Fixed: on Debian the installer never checked for GTK 4.** The check that stops you downloading an app your machine cannot run was gated on finding `ldconfig` on your PATH, and Debian keeps `ldconfig` in `/usr/sbin`, which it does not put on a normal user's PATH. So on Debian the check silently did not happen: a machine without GTK 4 got a verified download, "Installed Notary", and then nothing at all when it started. It looks for `ldconfig` by absolute path now, and searches the library directories if there is none.

**A first start that fails now says so.** Notary was launched at the end of the install with its output thrown away, so a window that died on a missing library was indistinguishable from one that opened behind something. If it exits immediately the installer prints what it said. On the app that holds your key, that silence was the wrong trade.

**The download is verified, or not installed.** If the published SHA-256 could not be fetched, the installer used to warn and install anyway, which is a check any bad minute switches off.

**"Another Notary already has this key open" is now something you can read.** Two Notary windows cannot share one key: the daemon takes an exclusive lock on the key file and answers the second one with a refusal. The second window threw that refusal away, so you typed the right passphrase, the spinner stopped, and nothing happened, with nothing anywhere saying why. It happens in ordinary use: installing Plaza and then Notary, or re-running the installer while Notary is open.

**"This Mac" really does read "this computer" now.** v0.10.7 said that sweep was done everywhere. It had missed the sentence next to the backup button, which is the one place a Linux user is told why losing this machine loses their identity.

**A dead signer no longer tells you to check `SIGNER_BIN`.** That is a developer override nobody who used the one-line installer has ever set. It names the actual problem instead, which is that the two binaries have to sit together.

To be explicit about something v0.10.7 left ambiguous: that release fixed a **compile** error on older systems, and Ubuntu 22.04 and Debian 12 still cannot run these downloads. They carry GTK 4.6 and this needs 4.10. Building from source on them works if their GTK is new enough.

### What's new in v0.10.9

**Housekeeping, and one leak.** When a relay connection has gone quiet and what to do about it now comes from the `nostr` library rather than a copy kept here: the same three numbers and the same decision existed in Notary and in Plaza, written down in neither, so a correction to one would silently not reach the other.
Expand Down
54 changes: 54 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,60 @@ While pre-1.0, minor versions add capability and patch versions are fixes.

## [Unreleased]

## [0.10.10] - 2026-09-07

### Fixed

- The Linux installer's GTK 4 check was skipped entirely on Debian. It was
gated on `command -v ldconfig`, and Debian does not put `/usr/sbin` on a
normal user's PATH, so on one of the three distributions this project names
as supported the check silently did not run: a machine without GTK 4 got a
verified download, a cheerful "Installed Notary", and an app that died on
`libgtk-4.so.1`. It looks for `ldconfig` by absolute path too, and falls back
to searching the library directories.

- The installer launched Notary with its output discarded, so a first run that
failed looked exactly like one that succeeded. It reports what the app said
if it exits immediately.

- The SHA-256 check installed anyway when the published digest could not be
fetched. It now refuses, and it requires both digests to be non-empty before
comparing them: two empty strings compare equal, and the check then reports
success over nothing at all.

- The distribution floor is now checked before GTK presence. An Ubuntu 22.04
machine has GTK 4.6, so a presence check passes there and says nothing useful
while the floor is the actual reason it cannot run this.

- A second Notary on the same key left the unlock screen silent. The daemon
answers 409 with "another Notary already has this key open", and the window
discarded it and re-synced, so the right passphrase produced no error and no
progress. Two ordinary paths reach it: installing Plaza and then Notary, and
re-running the installer while Notary is open.

- `exit_note_buf` and `onboard_error_buf` were too small for the messages
written into them. `setExitNote` formats with `catch return`, so a message one
byte too long was not truncated but dropped entirely, leaving a dead window
with no explanation at all.

- "This Mac holds the only copy" next to the backup button now says "this
computer". 0.10.7 claimed that sweep covered everywhere it appeared; it had
missed the one sentence a Linux user reads about losing their identity.

- A signer that fails to start no longer tells the reader to check `SIGNER_BIN`,
a developer override documented only in `gui/README.md`.

- `tar` failures, unknown arguments and `--help` now produce messages and the
right exit codes. The `EXIT` trap returned the status of its own failed test
when no temp directory had been made, so `--help` exited 1.

### Removed

- Two committed empty files named `no-such-key.ncryptsec`, at the repository
root and in `daemon/`. A test opens that path expecting it to be absent, and
it was reading the working directory, so the file the test is named for
existed. The test now uses its own temp directory.

## [0.10.9] - 2026-09-07

### Changed
Expand Down
Empty file removed daemon/no-such-key.ncryptsec
Empty file.
49 changes: 46 additions & 3 deletions daemon/src/approval_http.zig
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const ipc = nostr.signer_ipc;
const Broker = approval.Broker;
const Pending = approval.Pending;
const Gate = onboarding.Gate;
const keystore = nostr.keystore;

/// Live connection state of one relay, reported per relay on `/info`.
/// A relay connection's live state.
Expand Down Expand Up @@ -748,7 +749,7 @@ fn handleSetup(self: *Server, io: std.Io, w: *std.Io.Writer, body: []const u8) !
}

/// What `/forget` requires in its body before it will delete anything. Typed by
/// the reader, not clicked: this removes the only copy of a key on this Mac.
/// the reader, not clicked: this removes the only copy of a key on this machine.
pub const forget_confirmation = "delete my key";

/// POST /forget, remove the key file so another account can be set up.
Expand Down Expand Up @@ -954,7 +955,7 @@ fn handleUnlock(self: *Server, io: std.Io, w: *std.Io.Writer, body: []const u8)
error.BadPassphrase => respond(w, 401, "{\"error\":\"bad passphrase\"}"),
error.NotLocked => respond(w, 409, "{\"error\":\"not locked\"}"),
// Not the reader's mistake, and not something a passphrase fixes.
// Another Notary on this Mac has this key open; two processes
// Another Notary on this machine has this key open; two processes
// cannot share a decrypted key, so one of them has to close.
error.AlreadyOpenElsewhere => respond(w, 409, "{\"error\":\"another Notary already has this key open\"}"),
else => respond(w, 500, "{\"error\":\"could not unlock\"}"),
Expand Down Expand Up @@ -2174,7 +2175,12 @@ test "a wrong passphrase is written down, because a run of them is the only warn

var log = audit.Log{ .dir = tmp.dir, .path = "audit.log" };
var broker: Broker = .{};
var gate = Gate.init(gpa, std.Io.Dir.cwd(), "no-such-key.ncryptsec", .locked);
// The tmp dir, not the CWD. Pointed at the working directory this test read
// "no-such-key.ncryptsec" relative to wherever it happened to run, and two
// empty files of that name had been committed at the top of this repo and
// inside daemon/, so the file the test is named for existed. They are gone,
// and this no longer depends on that.
var gate = Gate.init(gpa, tmp.dir, "no-such-key.ncryptsec", .locked);
var server = Server{ .gpa = gpa, .broker = &broker, .gate = &gate, .token = "t", .log = &log, .info = .{ .relays = &.{}, .timeout_ms = 1000 }, .host = "127.0.0.1", .port = 0 };

var out = std.Io.Writer.Allocating.init(gpa);
Expand All @@ -2188,6 +2194,43 @@ test "a wrong passphrase is written down, because a run of them is the only warn
try testing.expect(std.mem.indexOf(u8, written, "hunter2") == null);
}

test "a second Notary is told WHY, in the words the window matches on" {
// The window reads this body. It shows its own sentence when it finds
// "already has this key open" in a 409, and re-syncs silently otherwise,
// because 409 also means the benign "initialized out from under us". So the
// wording here is a contract between two binaries that cannot import each
// other, and changing it silently returns the window to the failure this
// fixed: the right passphrase, the spinner stopping, and nothing happening.
const gpa = testing.allocator;
const io = testing.io;
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();

const secret = [_]u8{0xC2} ** 32;
const passphrase = "correct horse battery staple";
const ncryptsec = try keystore.encryptKey(gpa, io, secret, passphrase, .known_secure);
defer gpa.free(ncryptsec);
try keystore.writeNewKeyFile(io, tmp.dir, "key.ncryptsec", ncryptsec);

var holder = Gate.init(gpa, tmp.dir, "key.ncryptsec", .locked);
try holder.unlock(io, passphrase);

var log = audit.Log{ .dir = tmp.dir, .path = "audit.log" };
var broker: Broker = .{};
var gate = Gate.init(gpa, tmp.dir, "key.ncryptsec", .locked);
var server = Server{ .gpa = gpa, .broker = &broker, .gate = &gate, .token = "t", .log = &log, .info = .{ .relays = &.{}, .timeout_ms = 1000 }, .host = "127.0.0.1", .port = 0 };

var out = std.Io.Writer.Allocating.init(gpa);
defer out.deinit();
try handleUnlock(&server, io, &out.writer, "{\"passphrase\":\"correct horse battery staple\"}");

const written = out.written();
try testing.expect(std.mem.indexOf(u8, written, "409") != null);
try testing.expect(std.mem.indexOf(u8, written, "already has this key open") != null);
// And never as a passphrase problem: the passphrase was right.
try testing.expect(std.mem.indexOf(u8, written, "401") == null);
}

test "the key leaving the machine is written down, in the form it left as" {
const gpa = testing.allocator;
const io = testing.io;
Expand Down
2 changes: 1 addition & 1 deletion daemon/src/onboarding.zig
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ pub const Gate = struct {
/// onboards a fresh key.
///
/// This is the destructive half of switching accounts, and the key file is
/// the only copy of that identity on this Mac. Callers must have said so to
/// the only copy of that identity on this machine. Callers must have said so to
/// the reader FIRST; nothing here can un-delete it.
///
/// The in-memory key is zeroed too, but that is not the whole story and the
Expand Down
2 changes: 1 addition & 1 deletion gui/app.zon
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
.name = "notary",
.display_name = "Notary",
.description = "Notary: approve or deny Nostr signing requests; your key stays in the signer daemon.",
.version = "0.10.9",
.version = "0.10.10",
.icons = .{"assets/icon.png"},
.platforms = .{ "macos", "linux" },
.permissions = .{ "view", "command", "clipboard" },
Expand Down
2 changes: 1 addition & 1 deletion gui/src/app.native
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
<if test="{backup_closed}">
<row gap="8">
<button variant="secondary" on-press="reveal_backup">Back up your key</button>
<text grow="1" foreground="text_muted" wrap="true">This Mac holds the only copy. Without a backup, losing it loses the identity: a nostr key cannot be replaced.</text>
<text grow="1" foreground="text_muted" wrap="true">This computer holds the only copy. Without a backup, losing it loses the identity: a nostr key cannot be replaced.</text>
</row>
</if>
<if test="{backup_open}">
Expand Down
45 changes: 39 additions & 6 deletions gui/src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,11 @@ pub const Model = struct {
relays_len: usize = 0,
/// Short human note for the `.daemon_exited` state, e.g. "signer exited
/// (code 1)".
exit_note_buf: [64]u8 = [_]u8{0} ** 64,
// 160, not 64. `setExitNote` formats into this and `catch return`s, so a
// message one byte too long is not truncated, it is DROPPED: the reader
// gets a dead window with no note at all, which is the failure the note
// exists to explain.
exit_note_buf: [160]u8 = [_]u8{0} ** 160,
exit_note_len: usize = 0,

rows: [max_pending]Row = [_]Row{.{}} ** max_pending,
Expand Down Expand Up @@ -422,7 +426,9 @@ pub const Model = struct {

/// A `/setup` or `/unlock` POST is in flight (disables the submit button).
submitting: bool = false,
onboard_error_buf: [96]u8 = [_]u8{0} ** 96,
// 160, not 96. The longest message here names two things to quit and was
// silently truncated at 96, which turns an instruction into a fragment.
onboard_error_buf: [160]u8 = [_]u8{0} ** 160,
onboard_error_len: usize = 0,

// -- config accessors --
Expand Down Expand Up @@ -555,7 +561,7 @@ pub const Model = struct {
///
/// This window ships inside more than one app. Started from Plaza, the
/// signer beside it is Plaza's, and naming Notary's sends a reader to a
/// path that does not exist on their Mac unless they also installed Notary
/// path that does not exist on their machine unless they also installed Notary
/// on its own. So the real neighbour wins whenever there is one.
///
/// The constant stays for the case it was written for: a dev build with no
Expand Down Expand Up @@ -884,7 +890,14 @@ pub const Model = struct {

fn setExitNote(self: *Model, exit: native_sdk.EffectExit) void {
const s = switch (exit.reason) {
.spawn_failed, .rejected => std.fmt.bufPrint(&self.exit_note_buf, "The signer failed to start, check SIGNER_BIN.", .{}),
// Names a path, not an environment variable. `SIGNER_BIN` is a
// developer override documented in gui/README.md, and a reader who
// installed with the one-line installer has never heard of it: the
// message told them to check something they do not have. What is
// actually true for them is that the two binaries have to sit
// together, which is what both packagers arrange and what an
// interrupted install breaks.
.spawn_failed, .rejected => std.fmt.bufPrint(&self.exit_note_buf, "The signer did not start. It has to sit beside Notary in the same folder; installing again puts it back.", .{}),
.signaled => std.fmt.bufPrint(&self.exit_note_buf, "The signer was terminated (signal).", .{}),
else => std.fmt.bufPrint(&self.exit_note_buf, "The signer exited (code {d}).", .{exit.code}),
} catch return;
Expand Down Expand Up @@ -1789,8 +1802,28 @@ fn onOnboardResponse(model: *Model, fx: *Effects, r: native_sdk.EffectResponse,
if (r.outcome == .ok) switch (r.status) {
401 => model.setOnboardError("Wrong passphrase."),
400 => model.setOnboardError(if (kind == .setup) "Check the passphrase and key." else "Bad request."),
// Initialized/unlocked out from under us: re-sync from /info.
409 => fetchInfo(model, fx),
409 => {
// TWO different situations answer 409, and they need different
// words. One is benign: the key was initialized or unlocked out
// from under us, so re-syncing from /info lands on the right
// screen. The other is not: another Notary already holds this key
// open (the daemon takes a non-blocking exclusive lock on the key
// file), and re-syncing alone leaves the reader on the unlock
// screen with no error at all. They type the RIGHT passphrase, the
// spinner stops, and nothing happens, forever, with nothing
// anywhere saying why.
//
// Two ways in, both ordinary: installing Plaza and then running
// Notary's installer, which starts a second Notary while Plaza's
// embedded keyholder holds the lock; and re-running the installer
// to upgrade while Notary is open.
//
// The daemon says which it is, so read it rather than guess.
if (std.mem.indexOf(u8, r.body, "already has this key open") != null) {
model.setOnboardError("Another Notary already has this key open. Quit it, or the app that started it, then try again.");
}
fetchInfo(model, fx);
},
else => model.setOnboardError("The signer rejected the request."),
} else {
model.setOnboardError("Could not reach the signer.");
Expand Down
Empty file removed no-such-key.ncryptsec
Empty file.
Loading
Loading