privateer-agent 0.9.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -5
- package/bin/privateer-acp.mjs +40 -0
- package/bin/privateer-launch.mjs +13 -0
- package/extensions/privateer-gate.ts +17 -1
- package/package.json +2 -1
- package/src/acp/protocol.ts +145 -0
- package/src/acp/run.ts +376 -0
- package/src/acp/server.ts +0 -0
- package/src/channels/bridge.ts +170 -24
- package/src/channels/platforms.ts +64 -0
- package/src/channels/run.ts +24 -7
- package/src/channels/types.ts +84 -13
- package/src/channels/whatsapp.ts +40 -4
- package/src/cli/chat.ts +3 -2
- package/src/config/inlineMoat.ts +67 -0
- package/src/ext/headlessUi.ts +116 -0
- package/src/harbor/buildLock.ts +19 -0
- package/src/harbor/index.ts +9 -12
- package/src/nostr/bech32.ts +117 -0
- package/src/nostr/event.ts +92 -0
- package/src/nostr/keys.ts +172 -0
- package/src/nostr/tags.ts +60 -0
- package/src/permissions/classify.ts +52 -1
- package/src/remote/channelsControl.ts +8 -16
- package/src/remote/liveTaskSession.ts +16 -3
- package/src/remote/relayClient.ts +87 -12
package/src/channels/whatsapp.ts
CHANGED
|
@@ -87,14 +87,50 @@ export class WhatsAppAdapter implements ChannelAdapter {
|
|
|
87
87
|
|
|
88
88
|
async start(onMessage: (m: InboundMessage) => void): Promise<void> {
|
|
89
89
|
this.onMessage = onMessage;
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
const server = createServer((req, res) => this.handle(req, res));
|
|
91
|
+
this.server = server;
|
|
92
|
+
// Bind with a bounded retry. A restart of THIS platform (config change) may race
|
|
93
|
+
// the previous listener's last keep-alive socket draining; stop() destroys those,
|
|
94
|
+
// but the kernel can still hold the port for a beat. Retrying turns a lost
|
|
95
|
+
// platform into a sub-second delay. Anything other than EADDRINUSE fails fast.
|
|
96
|
+
for (let attempt = 0; ; attempt++) {
|
|
97
|
+
try {
|
|
98
|
+
await new Promise<void>((resolve, reject) => {
|
|
99
|
+
const onError = (e: NodeJS.ErrnoException) => reject(e);
|
|
100
|
+
server.once("error", onError);
|
|
101
|
+
server.listen(this.port, () => {
|
|
102
|
+
server.off("error", onError);
|
|
103
|
+
resolve();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
break;
|
|
107
|
+
} catch (e) {
|
|
108
|
+
const code = (e as NodeJS.ErrnoException)?.code;
|
|
109
|
+
if (code !== "EADDRINUSE" || attempt >= 2) {
|
|
110
|
+
this.server = undefined;
|
|
111
|
+
throw e;
|
|
112
|
+
}
|
|
113
|
+
this.log(`port ${this.port} still in use — retrying in 500ms`);
|
|
114
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
92
117
|
this.log(`webhook listening on :${this.port}${this.path} — expose it publicly for Meta to reach.`);
|
|
93
118
|
}
|
|
94
119
|
|
|
95
|
-
|
|
96
|
-
|
|
120
|
+
// Async, and it DESTROYS live connections. `server.close()` alone stops accepting
|
|
121
|
+
// new connections but resolves only once every existing keep-alive socket drains —
|
|
122
|
+
// Meta holds those open, so a plain close() can leave the port bound for a long
|
|
123
|
+
// time and the next listen() fails. Awaiting this is what makes a targeted
|
|
124
|
+
// per-platform restart safe.
|
|
125
|
+
async stop(): Promise<void> {
|
|
126
|
+
const server = this.server;
|
|
97
127
|
this.server = undefined;
|
|
128
|
+
this.onMessage = undefined;
|
|
129
|
+
if (!server) return;
|
|
130
|
+
await new Promise<void>((resolve) => {
|
|
131
|
+
server.close(() => resolve());
|
|
132
|
+
server.closeAllConnections?.();
|
|
133
|
+
});
|
|
98
134
|
}
|
|
99
135
|
|
|
100
136
|
async sendText(chatId: string, text: string): Promise<void> {
|
package/src/cli/chat.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { fileURLToPath } from "node:url"; // builtin, safe pre-boot
|
|
|
13
13
|
import { cliPalette } from "../ui/palette.ts"; // no Pi deps → safe pre-boot
|
|
14
14
|
import { noQuarterActive, setNoQuarter } from "../permissions/noQuarter.ts"; // no Pi deps → safe pre-boot
|
|
15
15
|
import type { GateController } from "../ext/permissionGate.ts"; // type-only → erased, safe pre-boot
|
|
16
|
+
import { createUIContext } from "../ext/headlessUi.ts"; // no Pi deps → safe pre-boot
|
|
16
17
|
|
|
17
18
|
// This lean REPL has no Pi TUI (and so no Theme), so it detects the terminal background
|
|
18
19
|
// itself (COLORFGBG) and picks a palette — on a light terminal the standard "\x1b[33m"
|
|
@@ -447,7 +448,7 @@ async function main() {
|
|
|
447
448
|
// is correct: this REPL is interactive. The abort signal Pi passes (to dismiss a
|
|
448
449
|
// dialog on interrupt) is threaded through so a cancelled turn doesn't wedge.
|
|
449
450
|
const driven = (): boolean => bridge.getRemote() && bridge.isConnected();
|
|
450
|
-
const uiContext = {
|
|
451
|
+
const uiContext = createUIContext({
|
|
451
452
|
// Pick one of `options`. Returns the chosen string, or undefined if cancelled.
|
|
452
453
|
async select(title: string, options: string[], opts?: { signal?: AbortSignal }): Promise<string | undefined> {
|
|
453
454
|
if (!options.length) return undefined;
|
|
@@ -492,7 +493,7 @@ async function main() {
|
|
|
492
493
|
console.log(`${color}${message}${RESET}`);
|
|
493
494
|
if (driven()) bridge.sendNotice(message);
|
|
494
495
|
},
|
|
495
|
-
};
|
|
496
|
+
});
|
|
496
497
|
await (session as any).bindExtensions({ uiContext });
|
|
497
498
|
|
|
498
499
|
// Stream the turn as EngineEvents — printed locally AND forwarded to the app
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// "Did this process already build its own permission gate in code?" — a process-level
|
|
2
|
+
// marker, set by every non-TUI entry that passes makePermissionGate() as an inline
|
|
3
|
+
// extension factory, and read by the shipped gate extension so it doesn't install a
|
|
4
|
+
// SECOND one on top.
|
|
5
|
+
//
|
|
6
|
+
// The problem it solves: ~/.privateer/agent/extensions holds discovery shims for the
|
|
7
|
+
// interactive TUI's extensions (installed by bin/privateer-launch.mjs). Pi discovers
|
|
8
|
+
// them into every session built against that agentDir, and merges them with inline
|
|
9
|
+
// factories — discovered FIRST, factories appended after (core/resource-loader.js).
|
|
10
|
+
// extensions/privateer-gate.ts is one of those shims and it installs its own gate,
|
|
11
|
+
// wired to the module-level RemoteBridge that only `/remote-access` ever attaches a
|
|
12
|
+
// relay to. In a harbor / channels process that bridge is permanently unattached, so
|
|
13
|
+
// the discovered copy believes every turn is LOCAL — and its cwd is process.cwd()
|
|
14
|
+
// (the daemon's, not the session's) with a module-level allowlist shared across
|
|
15
|
+
// concurrent sessions. What that costs depends on whether `session_start` fired,
|
|
16
|
+
// which in Pi only happens if something calls bindExtensions():
|
|
17
|
+
// • a live task spawn DOES bind (for the relayed select/confirm), so the copy sees
|
|
18
|
+
// ctx.mode "print", flips itself to bypass, and only intervenes on dangerous
|
|
19
|
+
// shell / destructive / secret-exfil actions — where it asks through ctx.ui,
|
|
20
|
+
// which the spawn relays to the app as a SECOND dialog on top of the session
|
|
21
|
+
// gate's own approval for the same call;
|
|
22
|
+
// • the harbor's headless runs (routines, workflows, submitted tasks) and the
|
|
23
|
+
// channels runner never bind, so session_start never fires, the copy stays in
|
|
24
|
+
// "default" mode with no UI, and defaultLocalAsk fails closed — DENYING every
|
|
25
|
+
// gated tool before the session's real approver is ever consulted.
|
|
26
|
+
// Neither is what these entries want: they each wire a gate that knows their cwd and
|
|
27
|
+
// their approver.
|
|
28
|
+
//
|
|
29
|
+
// INVARIANT: set this ONLY from a path that installs makePermissionGate() itself.
|
|
30
|
+
// Marking a process that doesn't would leave its sessions ungated.
|
|
31
|
+
//
|
|
32
|
+
// `privateer acp` solves the same collision differently — `noExtensions: true`, so
|
|
33
|
+
// nothing is discovered at all (see src/acp/run.ts) — and needs no marker.
|
|
34
|
+
//
|
|
35
|
+
// An env var rather than a module singleton on purpose: discovered extensions load
|
|
36
|
+
// through jiti with its module cache off, so they may hold a SEPARATE copy of our
|
|
37
|
+
// modules. process.env is the one piece of state both copies are guaranteed to share.
|
|
38
|
+
// It is inherited by child processes, which is why the shim pairs this check with
|
|
39
|
+
// isSubagentChild() — a subagent child loads the moat explicitly (`-e`) and must keep
|
|
40
|
+
// its own gate. See bin/privateer-subagent.mjs.
|
|
41
|
+
//
|
|
42
|
+
// IMPORT-SAFETY: no Pi imports, no node builtins — safe to load from anywhere.
|
|
43
|
+
|
|
44
|
+
const ENV = "PRIVATEER_INLINE_MOAT";
|
|
45
|
+
|
|
46
|
+
/** Called once by an entry that loads the moat as an in-code factory. Idempotent. */
|
|
47
|
+
export function markInlineMoat(): void {
|
|
48
|
+
process.env[ENV] = "1";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** True in a process whose sessions already carry their own in-code permission gate. */
|
|
52
|
+
export function inlineMoat(): boolean {
|
|
53
|
+
return process.env[ENV] === "1";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Should a DISCOVERED gate extension install its permission gate in this process?
|
|
58
|
+
*
|
|
59
|
+
* No when the session already has one from an inline factory — EXCEPT in a subagent
|
|
60
|
+
* child, which inherits the parent's environment but loads this extension explicitly
|
|
61
|
+
* (`-e`, bin/privateer-subagent.mjs) as its ONLY moat. Getting that carve-out wrong
|
|
62
|
+
* runs a harbor task's children with no gate at all, so it is a parameter rather than
|
|
63
|
+
* an env read here: the caller passes isSubagentChild() and the matrix is testable.
|
|
64
|
+
*/
|
|
65
|
+
export function discoveredGateApplies(isSubagentChild: boolean): boolean {
|
|
66
|
+
return !inlineMoat() || isSubagentChild;
|
|
67
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A COMPLETE ExtensionUIContext for surfaces that have no terminal UI.
|
|
3
|
+
*
|
|
4
|
+
* Pi decides `ctx.hasUI` by identity — `hasUI() { return this.uiContext !== noOpUIContext }`
|
|
5
|
+
* (pi-coding-agent core/extensions/runner.js). So binding ANY object flips hasUI to true,
|
|
6
|
+
* and every extension that guards on `ctx.hasUI` then calls the FULL ExtensionUIContext
|
|
7
|
+
* surface on it. Our callers only ever implemented the four dialog methods they cared
|
|
8
|
+
* about (select/confirm/input/notify), which is how a harbor session ended up throwing
|
|
9
|
+
*
|
|
10
|
+
* MCP initialization failed: ui.setStatus is not a function
|
|
11
|
+
*
|
|
12
|
+
* — pi-mcp-adapter saw hasUI true, called ctx.ui.setStatus() to draw its status bar, and
|
|
13
|
+
* the whole MCP init aborted, taking every connector's tools with it. The session still
|
|
14
|
+
* ran; it just silently had no MCP tools.
|
|
15
|
+
*
|
|
16
|
+
* The fix is to always hand Pi a complete context: no-op defaults for the presentational
|
|
17
|
+
* surface (status bars, widgets, footers, editor manipulation — none of which mean
|
|
18
|
+
* anything without a terminal), the caller's real implementations for the dialogs that
|
|
19
|
+
* relay to the app, and a Proxy backstop so a method added by a future Pi release
|
|
20
|
+
* degrades to a no-op instead of crashing extension init all over again.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately NOT a security boundary: hasUI stays true for these callers exactly as
|
|
23
|
+
* before. The permission gate (ext/permissionGate.ts) makes its own hasUI/ui check and
|
|
24
|
+
* fails closed on its own terms — nothing here loosens that.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// The presentational half of ExtensionUIContext, mirroring pi-coding-agent's private
|
|
28
|
+
// noOpUIContext. Kept as data (not a class) so `createUIContext` can spread it.
|
|
29
|
+
const NO_OP_UI = {
|
|
30
|
+
select: async () => undefined,
|
|
31
|
+
confirm: async () => false,
|
|
32
|
+
input: async () => undefined,
|
|
33
|
+
notify: () => {},
|
|
34
|
+
onTerminalInput: () => () => {},
|
|
35
|
+
setStatus: () => {},
|
|
36
|
+
setWorkingMessage: () => {},
|
|
37
|
+
setWorkingVisible: () => {},
|
|
38
|
+
setWorkingIndicator: () => {},
|
|
39
|
+
setHiddenThinkingLabel: () => {},
|
|
40
|
+
setWidget: () => {},
|
|
41
|
+
setFooter: () => {},
|
|
42
|
+
setHeader: () => {},
|
|
43
|
+
setTitle: () => {},
|
|
44
|
+
custom: async () => undefined,
|
|
45
|
+
pasteToEditor: () => {},
|
|
46
|
+
setEditorText: () => {},
|
|
47
|
+
getEditorText: () => "",
|
|
48
|
+
editor: async () => undefined,
|
|
49
|
+
addAutocompleteProvider: () => {},
|
|
50
|
+
setEditorComponent: () => {},
|
|
51
|
+
getEditorComponent: () => undefined,
|
|
52
|
+
getAllThemes: () => [],
|
|
53
|
+
getTheme: () => undefined,
|
|
54
|
+
setTheme: () => ({ success: false, error: "UI not available" }),
|
|
55
|
+
getToolsExpanded: () => false,
|
|
56
|
+
setToolsExpanded: () => {},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A passthrough stand-in for Pi's Theme singleton, which lives behind a deep path the
|
|
61
|
+
* package's `exports` map doesn't expose (and `initTheme()` returns void — it only
|
|
62
|
+
* initializes that private singleton). Extensions call `ui.theme.fg("accent", text)` to
|
|
63
|
+
* colorize, so the shape has to exist.
|
|
64
|
+
*
|
|
65
|
+
* Returning the text unstyled is the RIGHT answer here rather than a lossy stub: this
|
|
66
|
+
* context is used where output goes to a log file or across the relay to the app's feed,
|
|
67
|
+
* and injected ANSI escapes would be noise in the first case and are actively unwanted in
|
|
68
|
+
* the second (the CLI already redacts/escapes what crosses that wire).
|
|
69
|
+
*/
|
|
70
|
+
export const passthroughTheme = {
|
|
71
|
+
fg: (_color: string, text: string) => text,
|
|
72
|
+
bg: (_color: string, text: string) => text,
|
|
73
|
+
bold: (text: string) => text,
|
|
74
|
+
italic: (text: string) => text,
|
|
75
|
+
underline: (text: string) => text,
|
|
76
|
+
inverse: (text: string) => text,
|
|
77
|
+
strikethrough: (text: string) => text,
|
|
78
|
+
getFgAnsi: () => "",
|
|
79
|
+
getBgAnsi: () => "",
|
|
80
|
+
getColorMode: () => "256color" as const,
|
|
81
|
+
getThinkingBorderColor: () => (s: string) => s,
|
|
82
|
+
getBashModeBorderColor: () => (s: string) => s,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Any property we didn't anticipate resolves to a no-op function. Extensions call UI
|
|
86
|
+
// members as methods, so a callable is the shape that keeps them running; the cost of
|
|
87
|
+
// guessing wrong is a missing visual, versus a thrown TypeError that aborts init.
|
|
88
|
+
const unknownMember = () => () => undefined;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build a full ExtensionUIContext from the dialog methods a caller actually implements.
|
|
92
|
+
*
|
|
93
|
+
* `overrides` typically carries select/confirm/input/notify wired to the relay (so an
|
|
94
|
+
* extension asking a question reaches the app instead of silently cancelling). Everything
|
|
95
|
+
* else falls back to a no-op, and `theme` to `passthroughTheme`.
|
|
96
|
+
*
|
|
97
|
+
* Returns `any` because our overrides are structurally narrower than Pi's interface
|
|
98
|
+
* (its dialog signatures carry TUI-only option types); the call sites already pass this
|
|
99
|
+
* through `bindExtensions({ uiContext })` untyped.
|
|
100
|
+
*/
|
|
101
|
+
export function createUIContext(overrides: Record<string, unknown> = {}): any {
|
|
102
|
+
const target: Record<string, unknown> = { ...NO_OP_UI, theme: passthroughTheme, ...overrides };
|
|
103
|
+
return new Proxy(target, {
|
|
104
|
+
get(obj, prop, receiver) {
|
|
105
|
+
if (prop in obj) return Reflect.get(obj, prop, receiver);
|
|
106
|
+
// `then` must stay undefined or an await on this object would treat it as a
|
|
107
|
+
// thenable and hang; same for other well-known symbol-ish probes.
|
|
108
|
+
if (typeof prop === "symbol" || prop === "then") return undefined;
|
|
109
|
+
return unknownMember();
|
|
110
|
+
},
|
|
111
|
+
// Deliberately NO `has` trap. Trapping it to always-true would make `in` claim we
|
|
112
|
+
// implement things we don't, which silently defeats capability checks of the shape
|
|
113
|
+
// `if (!("x" in ui)) fallback()`. Leaving it honest means `in` reports what is really
|
|
114
|
+
// implemented while `get` still guarantees a call never throws — the safe pairing.
|
|
115
|
+
});
|
|
116
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Session CONSTRUCTION is serialized across the whole process. pi-mcp-adapter decides
|
|
2
|
+
// which MCP tools to register directly at extension-activation time, and the only knob
|
|
3
|
+
// for that is the MCP_DIRECT_TOOLS env var — process-global state we set per run to
|
|
4
|
+
// keep each unattended session to its own connector allow-list. Serializing the
|
|
5
|
+
// (short) build window is what stops two concurrent builds from reading each other's
|
|
6
|
+
// value. Prompting is NOT serialized — only the build.
|
|
7
|
+
//
|
|
8
|
+
// Lifted out of harbor/index.ts so the channels runtime can share the SAME lock once
|
|
9
|
+
// it runs inside the harbor: two owners each with their own lock would serialize
|
|
10
|
+
// against themselves and race against each other, which is exactly the bug the lock
|
|
11
|
+
// exists to prevent.
|
|
12
|
+
|
|
13
|
+
let buildLock: Promise<unknown> = Promise.resolve();
|
|
14
|
+
|
|
15
|
+
export function serializeBuild<T>(fn: () => Promise<T>): Promise<T> {
|
|
16
|
+
const run = buildLock.then(fn, fn);
|
|
17
|
+
buildLock = run.catch(() => {});
|
|
18
|
+
return run;
|
|
19
|
+
}
|
package/src/harbor/index.ts
CHANGED
|
@@ -59,8 +59,10 @@ import { deliver, type RelayPusher, type CloudPusher } from "../routines/deliver
|
|
|
59
59
|
import { sealJson, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
|
|
60
60
|
import { redactText, collectSecrets } from "../util/redact.ts";
|
|
61
61
|
import { startIpcServer, sendToHarbor, describeRelay, formatDuration, HarborAlreadyRunningError, type IpcRequest, type IpcResponse, type RelayStatus } from "./ipc.ts";
|
|
62
|
+
import { serializeBuild } from "./buildLock.ts";
|
|
62
63
|
import { isHosted, publishRelayPub, webEnabled } from "../config/hosted.ts";
|
|
63
64
|
import { markHarborDaemon } from "../config/harborDaemon.ts";
|
|
65
|
+
import { markInlineMoat } from "../config/inlineMoat.ts";
|
|
64
66
|
import { makeWebTools, WEB_TOOL_NAMES } from "../tools/web.ts";
|
|
65
67
|
|
|
66
68
|
// The safe, read-only toolset for unattended runs — Pi builtins with no
|
|
@@ -157,18 +159,8 @@ function formatResult(routine: Routine, body: string, status: "ok" | "error", er
|
|
|
157
159
|
return `${head}${body.trim() || "(no output)"}\n${formatNotes(notes)}`;
|
|
158
160
|
}
|
|
159
161
|
|
|
160
|
-
// Session
|
|
161
|
-
//
|
|
162
|
-
// that is the MCP_DIRECT_TOOLS env var — process-global state we set per run to keep
|
|
163
|
-
// each unattended session to its own connector allow-list. Serializing the (short)
|
|
164
|
-
// build window is what stops two concurrent routines from reading each other's value.
|
|
165
|
-
// Prompting is NOT serialized — only the build.
|
|
166
|
-
let buildLock: Promise<unknown> = Promise.resolve();
|
|
167
|
-
function serializeBuild<T>(fn: () => Promise<T>): Promise<T> {
|
|
168
|
-
const run = buildLock.then(fn, fn);
|
|
169
|
-
buildLock = run.catch(() => {});
|
|
170
|
-
return run;
|
|
171
|
-
}
|
|
162
|
+
// Session construction is serialized process-wide — see harbor/buildLock.ts for why.
|
|
163
|
+
// Imported rather than defined here so the channels runtime can share the same lock.
|
|
172
164
|
|
|
173
165
|
// A short human title for an ad-hoc task: the app's explicit title, else the first
|
|
174
166
|
// non-empty line of the prompt, clipped. Exported for the signed-args round-trip test.
|
|
@@ -289,6 +281,11 @@ export class Harbor {
|
|
|
289
281
|
// gate one has to stand its relay file tools down here so a live task's own pair
|
|
290
282
|
// (bound to that task's live relay) isn't shadowed. See config/harborDaemon.ts.
|
|
291
283
|
markHarborDaemon();
|
|
284
|
+
// Same reason, one step further: every session this process builds (routines,
|
|
285
|
+
// workflows, submitted tasks, live spawns) wires its own makePermissionGate, so
|
|
286
|
+
// the discovered gate must not install a second one on top of it. See
|
|
287
|
+
// config/inlineMoat.ts for what that collision costs.
|
|
288
|
+
markInlineMoat();
|
|
292
289
|
// Single-instance lock FIRST, before any other side effect: binding the IPC
|
|
293
290
|
// socket is the machine's mutex. If a live harbor already holds it this throws
|
|
294
291
|
// HarborAlreadyRunningError — two harbors under one ~/.privateer share a single
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// bech32 (BIP-173) — just enough of it for NIP-19's `npub` / `nsec` key encodings.
|
|
2
|
+
//
|
|
3
|
+
// WHY HAND-ROLLED: this repo's channel stack advertises zero new dependencies and
|
|
4
|
+
// means it. bech32 is a fully specified, ~70-line checksum with published test
|
|
5
|
+
// vectors, and we need exactly two human-readable parts and no TLV forms — none of
|
|
6
|
+
// the `nprofile`/`nevent` machinery a general NIP-19 library carries. The tests in
|
|
7
|
+
// tests/nostr.test.ts drive the published vectors both directions.
|
|
8
|
+
//
|
|
9
|
+
// This is bech32, NOT bech32m: NIP-19 predates bech32m and uses the original
|
|
10
|
+
// constant 1. The two differ only in that final XOR, and getting it wrong produces
|
|
11
|
+
// strings that look right and fail everywhere else.
|
|
12
|
+
//
|
|
13
|
+
// BIP-173's 90-character total length cap is deliberately NOT enforced — NIP-19
|
|
14
|
+
// explicitly drops it. `npub` is 63 characters so it makes no difference today, but
|
|
15
|
+
// enforcing it would be a landmine for any longer form added later.
|
|
16
|
+
|
|
17
|
+
const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
|
18
|
+
const GENERATOR = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
|
|
19
|
+
|
|
20
|
+
function polymod(values: number[]): number {
|
|
21
|
+
let chk = 1;
|
|
22
|
+
for (const v of values) {
|
|
23
|
+
const top = chk >> 25;
|
|
24
|
+
chk = ((chk & 0x1ffffff) << 5) ^ v;
|
|
25
|
+
for (let i = 0; i < 5; i++) if ((top >> i) & 1) chk ^= GENERATOR[i];
|
|
26
|
+
}
|
|
27
|
+
return chk;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The HRP contributes its high bits, then a separator zero, then its low bits.
|
|
31
|
+
function hrpExpand(hrp: string): number[] {
|
|
32
|
+
const out: number[] = [];
|
|
33
|
+
for (let i = 0; i < hrp.length; i++) out.push(hrp.charCodeAt(i) >> 5);
|
|
34
|
+
out.push(0);
|
|
35
|
+
for (let i = 0; i < hrp.length; i++) out.push(hrp.charCodeAt(i) & 31);
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Regroup a bit stream, e.g. 8-bit bytes → 5-bit symbols and back. When padding is
|
|
40
|
+
// allowed (encoding) a partial final group is zero-padded; when it isn't (decoding)
|
|
41
|
+
// a partial group must be zero or the input is malformed.
|
|
42
|
+
function convertBits(data: ArrayLike<number>, from: number, to: number, pad: boolean): number[] | null {
|
|
43
|
+
let acc = 0;
|
|
44
|
+
let bits = 0;
|
|
45
|
+
const out: number[] = [];
|
|
46
|
+
const maxv = (1 << to) - 1;
|
|
47
|
+
for (let i = 0; i < data.length; i++) {
|
|
48
|
+
const value = data[i];
|
|
49
|
+
if (value < 0 || value >> from !== 0) return null;
|
|
50
|
+
acc = (acc << from) | value;
|
|
51
|
+
bits += from;
|
|
52
|
+
while (bits >= to) {
|
|
53
|
+
bits -= to;
|
|
54
|
+
out.push((acc >> bits) & maxv);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (pad) {
|
|
58
|
+
if (bits > 0) out.push((acc << (to - bits)) & maxv);
|
|
59
|
+
} else if (bits >= from || ((acc << (to - bits)) & maxv) !== 0) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function bech32Encode(hrp: string, bytes: Uint8Array): string {
|
|
66
|
+
const words = convertBits(bytes, 8, 5, true);
|
|
67
|
+
if (!words) throw new Error("bech32: cannot convert payload to 5-bit words");
|
|
68
|
+
const chk = polymod([...hrpExpand(hrp), ...words, 0, 0, 0, 0, 0, 0]) ^ 1;
|
|
69
|
+
const checksum: number[] = [];
|
|
70
|
+
for (let i = 0; i < 6; i++) checksum.push((chk >> (5 * (5 - i))) & 31);
|
|
71
|
+
return `${hrp}1${[...words, ...checksum].map((w) => CHARSET[w]).join("")}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Decode, throwing on any malformed input. Never returns partial or truncated data. */
|
|
75
|
+
export function bech32Decode(s: string): { hrp: string; bytes: Uint8Array } {
|
|
76
|
+
// Mixed case is explicitly invalid — it would make the checksum ambiguous.
|
|
77
|
+
if (s !== s.toLowerCase() && s !== s.toUpperCase()) throw new Error("bech32: mixed case");
|
|
78
|
+
const lower = s.toLowerCase();
|
|
79
|
+
const sep = lower.lastIndexOf("1");
|
|
80
|
+
if (sep < 1 || sep + 7 > lower.length) throw new Error("bech32: malformed (no separator or too short)");
|
|
81
|
+
const hrp = lower.slice(0, sep);
|
|
82
|
+
const words: number[] = [];
|
|
83
|
+
for (const ch of lower.slice(sep + 1)) {
|
|
84
|
+
const v = CHARSET.indexOf(ch);
|
|
85
|
+
if (v === -1) throw new Error(`bech32: invalid character "${ch}"`);
|
|
86
|
+
words.push(v);
|
|
87
|
+
}
|
|
88
|
+
if (polymod([...hrpExpand(hrp), ...words]) !== 1) throw new Error("bech32: bad checksum");
|
|
89
|
+
const bytes = convertBits(words.slice(0, -6), 5, 8, false);
|
|
90
|
+
if (!bytes) throw new Error("bech32: bad padding");
|
|
91
|
+
return { hrp, bytes: Uint8Array.from(bytes) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── NIP-19: the two bare key forms ──────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
function decodeKey(s: string, expectHrp: string): Uint8Array {
|
|
97
|
+
const { hrp, bytes } = bech32Decode(s);
|
|
98
|
+
if (hrp !== expectHrp) throw new Error(`expected an ${expectHrp} key, got "${hrp}"`);
|
|
99
|
+
if (bytes.length !== 32) throw new Error(`expected 32 key bytes, got ${bytes.length}`);
|
|
100
|
+
return bytes;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function npubEncode(pubkeyBytes: Uint8Array): string {
|
|
104
|
+
return bech32Encode("npub", pubkeyBytes);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function npubDecode(npub: string): Uint8Array {
|
|
108
|
+
return decodeKey(npub, "npub");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function nsecEncode(secretBytes: Uint8Array): string {
|
|
112
|
+
return bech32Encode("nsec", secretBytes);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function nsecDecode(nsec: string): Uint8Array {
|
|
116
|
+
return decodeKey(nsec, "nsec");
|
|
117
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// NIP-01 event construction, identity and signatures — the whole cryptographic
|
|
2
|
+
// core of the Nostr wire format in one dependency-free file.
|
|
3
|
+
//
|
|
4
|
+
// Nothing here knows about relays, channels, or Buzz. It is pure: same inputs, same
|
|
5
|
+
// bytes out, no clock, no I/O. That's what makes the known-answer tests in
|
|
6
|
+
// tests/nostr.test.ts meaningful — if serializeEvent drifts by a single character,
|
|
7
|
+
// every id in the fixture set stops recomputing.
|
|
8
|
+
//
|
|
9
|
+
// The primitives come from @noble/curves, already a direct dependency: Nostr signs
|
|
10
|
+
// with BIP-340 Schnorr over secp256k1, which is exactly what `schnorr` provides.
|
|
11
|
+
|
|
12
|
+
import { schnorr } from "@noble/curves/secp256k1";
|
|
13
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
14
|
+
import { bytesToHex, hexToBytes, utf8ToBytes } from "@noble/hashes/utils";
|
|
15
|
+
|
|
16
|
+
// A tag is a positional string array: ["e", <id>, <relay?>, <marker?>].
|
|
17
|
+
export type Tag = string[];
|
|
18
|
+
|
|
19
|
+
// An event before it has an id or a signature. `pubkey` is the 32-byte x-only
|
|
20
|
+
// public key as lowercase hex (64 chars) — NOT the 33-byte compressed form.
|
|
21
|
+
export interface UnsignedEvent {
|
|
22
|
+
pubkey: string;
|
|
23
|
+
created_at: number; // unix SECONDS, not millis
|
|
24
|
+
kind: number;
|
|
25
|
+
tags: Tag[];
|
|
26
|
+
content: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface NostrEvent extends UnsignedEvent {
|
|
30
|
+
id: string; // 64-char hex — sha256 of serializeEvent()
|
|
31
|
+
sig: string; // 128-char hex — BIP-340 Schnorr over the id bytes
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The NIP-01 serialization an event's id is computed over: a POSITIONAL array, not
|
|
36
|
+
* an object. Field order is fixed by the spec and is not negotiable.
|
|
37
|
+
*
|
|
38
|
+
* NIP-01 mandates escaping only `\n \" \\ \r \t \b \f` and requires no other
|
|
39
|
+
* escaping — which is precisely what JSON.stringify does for ordinary text. The two
|
|
40
|
+
* diverge only on exotic control characters and lone surrogates, where every real
|
|
41
|
+
* implementation follows JSON.stringify anyway. We do the same, deliberately.
|
|
42
|
+
*/
|
|
43
|
+
export function serializeEvent(u: UnsignedEvent): string {
|
|
44
|
+
return JSON.stringify([0, u.pubkey, u.created_at, u.kind, u.tags, u.content]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The event id: sha256 of the canonical serialization, lowercase hex. */
|
|
48
|
+
export function eventId(u: UnsignedEvent): string {
|
|
49
|
+
return bytesToHex(sha256(utf8ToBytes(serializeEvent(u))));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Compute the id and sign it.
|
|
54
|
+
*
|
|
55
|
+
* `auxRand` exists ONLY so tests can reproduce the published BIP-340 vectors —
|
|
56
|
+
* schnorr.sign is randomized by default, so without it a signature is unpredictable
|
|
57
|
+
* (which is correct and desirable in production). Never pass it outside tests.
|
|
58
|
+
*/
|
|
59
|
+
export function signEvent(u: UnsignedEvent, secretKeyHex: string, auxRand?: Uint8Array): NostrEvent {
|
|
60
|
+
const id = eventId(u);
|
|
61
|
+
const sig = bytesToHex(schnorr.sign(hexToBytes(id), hexToBytes(secretKeyHex), auxRand));
|
|
62
|
+
return { ...u, id, sig };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Verify an event end to end, fail-closed on anything malformed.
|
|
67
|
+
*
|
|
68
|
+
* BOTH checks matter and the order is deliberate: recompute the id first, so a
|
|
69
|
+
* tampered `content` or `tags` is caught even when the signature over the ORIGINAL
|
|
70
|
+
* id is still perfectly valid. Verifying only the signature would happily accept an
|
|
71
|
+
* event whose body had been swapped out from under its id.
|
|
72
|
+
*/
|
|
73
|
+
export function verifyEvent(ev: NostrEvent): boolean {
|
|
74
|
+
try {
|
|
75
|
+
if (
|
|
76
|
+
typeof ev?.id !== "string" ||
|
|
77
|
+
typeof ev.sig !== "string" ||
|
|
78
|
+
typeof ev.pubkey !== "string" ||
|
|
79
|
+
typeof ev.content !== "string" ||
|
|
80
|
+
typeof ev.kind !== "number" ||
|
|
81
|
+
typeof ev.created_at !== "number" ||
|
|
82
|
+
!Array.isArray(ev.tags)
|
|
83
|
+
) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
if (ev.id.length !== 64 || ev.sig.length !== 128 || ev.pubkey.length !== 64) return false;
|
|
87
|
+
if (eventId(ev) !== ev.id) return false;
|
|
88
|
+
return schnorr.verify(hexToBytes(ev.sig), hexToBytes(ev.id), hexToBytes(ev.pubkey));
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|