tinyrelaycommit

Commit 68dd278487c0

browse files at this commit | history

1diff --git a/internal/webui/style.css b/internal/webui/style.css
2index a449af8..99b15fe 100644
3--- a/internal/webui/style.css
4+++ b/internal/webui/style.css
5@@ -371,7 +371,7 @@ room-action{display:block}#panel room-action label{margin-top:6px}#panel room-ac
6 /* Phones keep conversation identity and navigation in a safe-area-aware header.
7 Native details controls expose the rail and contextual panel without scripts. */
8 @media(max-width:720px){
9-:root{--mobile-header-height:calc(64px + env(safe-area-inset-top,0px));--mobile-drawer-width:min(320px,100vw)}
10+:root{--mobile-header-height:calc(64px + env(safe-area-inset-top,0px));--mobile-drawer-width:min(320px,100vw);--mobile-drawer-duration:.15s}
11 html:has(signer-connection:not([hidden])){--mobile-header-height:calc(108px + env(safe-area-inset-top,0px))}
12 html,body{overflow:auto;scroll-padding-top:var(--mobile-header-height)}
13 #topbar{display:block;position:fixed;top:0;left:0;right:0;height:var(--mobile-header-height);background:var(--rail);border-bottom:1px solid var(--line);color:var(--ink);z-index:5;box-sizing:border-box}
14@@ -402,10 +402,12 @@ body:has(signer-connection:not([hidden])) #session-status{display:none}
15 #layout,#layout>tbody,#layout>tbody>tr,#layout>tbody>tr>td{display:block;width:auto;height:auto}
16 #layout{padding:var(--mobile-header-height) 0 env(safe-area-inset-bottom,0px)}
17 #railbox,#content,#panel{position:static;overflow:visible}
18-#layout>tbody>tr>td#rail{position:fixed;top:var(--mobile-header-height);bottom:calc(96px + env(safe-area-inset-bottom,0px));left:0;width:var(--mobile-drawer-width);height:auto;box-sizing:border-box;border-right:1px solid var(--line);transform:translateX(-102%);visibility:hidden;transition:transform .15s;z-index:4;overflow:hidden auto}
19+#layout>tbody>tr>td#rail{position:fixed;top:var(--mobile-header-height);bottom:calc(96px + env(safe-area-inset-bottom,0px));left:0;width:var(--mobile-drawer-width);height:auto;box-sizing:border-box;border-right:1px solid var(--line);z-index:4;overflow:hidden auto}
20+#layout>tbody>tr>td#rail,#footer{transform:translateX(-102%);visibility:hidden;transition:transform var(--mobile-drawer-duration) ease,visibility 0s linear var(--mobile-drawer-duration)}
21 #railbox{position:relative;min-height:100%}
22 body:has(#nav-menu[open]) #main::after{content:"";position:fixed;top:var(--mobile-header-height);bottom:0;left:0;right:0;background:rgba(0,0,0,.25);z-index:3}
23-#topbar:has(#nav-menu[open]) + #layout>tbody>tr>td#rail{transform:none;visibility:visible;box-shadow:8px 0 24px rgba(0,0,0,.15)}
24+body:has(#nav-menu[open]) #layout>tbody>tr>td#rail,body:has(#nav-menu[open]) #footer{transform:none;visibility:visible;transition-delay:0s}
25+body:has(#nav-menu[open]) #layout>tbody>tr>td#rail{box-shadow:8px 0 24px rgba(0,0,0,.15)}
26 #layout>tbody>tr>td#aside{display:none;width:auto;border-left:0;border-top:0}
27 body:has(#context-menu[open]) #layout>tbody>tr>td#aside{display:block;position:fixed;top:var(--mobile-header-height);bottom:0;left:0;right:0;height:auto;overflow:auto;z-index:4;background:var(--paper);padding-bottom:env(safe-area-inset-bottom,0px)}
28 #content>section{padding:14px 2.5ch 20px}
29@@ -417,13 +419,11 @@ body:has(#context-menu[open]) #layout>tbody>tr>td#aside{display:block;position:f
30 #mobile-actions page-link{display:block}
31 html[data-js=no] #mobile-actions page-link{display:none}
32 #prompt{display:none}
33-#footer{height:0;padding:0;border:0;background:none;z-index:4}
34-#footer>table,#footer #install{display:none}
35-body:has(#nav-menu[open]) #footer{width:var(--mobile-drawer-width);height:calc(96px + env(safe-area-inset-bottom,0px));padding:0 12px env(safe-area-inset-bottom,0px);border-top:1px solid var(--line);border-right:1px solid var(--line);background:var(--rail)}
36-body:has(#nav-menu[open]) #footer>table{display:table;height:56px;table-layout:fixed}
37+#footer{width:var(--mobile-drawer-width);height:calc(96px + env(safe-area-inset-bottom,0px));padding:0 12px env(safe-area-inset-bottom,0px);border-top:1px solid var(--line);border-right:1px solid var(--line);background:var(--rail);z-index:4}
38+#footer>table{display:table;height:56px;table-layout:fixed}
39 #footer td:first-child{display:none}#footer td:last-child{text-align:left;white-space:nowrap;overflow:hidden}
40 #who{font-size:11px}#who>a{display:inline-block;max-width:100px;vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}#who button{min-height:44px}
41-body:has(#nav-menu[open]) #install:not([hidden]){display:block;position:static;transform:none;width:100%;height:40px;text-align:left}
42+#install:not([hidden]){display:block;position:static;transform:none;width:100%;height:40px;text-align:left}
43 #navigation-status:not(:empty),#session-status:not(:empty){position:fixed;bottom:env(safe-area-inset-bottom,0px);left:0;right:0;padding:6px 12px;background:var(--rail);border-top:1px solid var(--line);white-space:normal}
44 #split,#split>tbody,#split>tbody>tr,#split>tbody>tr>td{display:block;width:auto}
45 #tree{border-right:0;border-bottom:1px solid var(--line)}
46@@ -450,6 +450,7 @@ room-message[data-own]{margin-left:12px;padding-right:10px}
47 #wiki-list #rows thead{display:none}#wiki-list #rows,#wiki-list #rows tbody,#wiki-list #rows tr{display:block;width:auto}#wiki-list #rows tr{padding:8px 0;border-bottom:1px solid var(--line)}#wiki-list #rows td{display:inline;padding:0;border:0;width:auto;height:auto;line-height:1.4;font:12px var(--mono);color:var(--muted)}#wiki-list #rows td:first-child{display:block;font:14px var(--sans);color:var(--ink);margin:0 0 2px}#wiki-list #rows td:first-child small{display:block;margin:2px 0 0}#wiki-list #rows td:nth-child(3){display:none}#wiki-list #rows td:nth-child(2)::after,#wiki-list #rows td:nth-child(4)::after{content:" | "}
48 room-compose{bottom:0;margin:8px -2.5ch 0;padding:8px 12px calc(8px + env(safe-area-inset-bottom,0px));z-index:1}
49 }
50+@media(max-width:720px) and (prefers-reduced-motion:reduce){:root{--mobile-drawer-duration:0s}}
51
52 /* Social: a calm reading stream that keeps short notes quick and gives
53 long-form posts a clear title, image and reading measure. */
54diff --git a/internal/webui/tiny.js b/internal/webui/tiny.js
55index bad624f..4e39cdb 100644
56--- a/internal/webui/tiny.js
57+++ b/internal/webui/tiny.js
58@@ -118,13 +118,30 @@
59 // WebMCP. Signers are treated as untrusted extensions: the returned event
60 // must preserve the requested unsigned fields and pass the verifier before
61 // it is sent to the relay.
62+ // Signers may reconstruct the event object while crossing an extension or
63+ // NIP-46 boundary. Compare the signed event's unsigned fields by value,
64+ // rather than by JSON property insertion order. Array order remains part of
65+ // the event and changed values are still rejected.
66+ const sameValue = (left, right) => {
67+ if (left === right) return true;
68+ if (typeof left !== typeof right || left === null || right === null) return false;
69+ if (Array.isArray(left) || Array.isArray(right)) {
70+ return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((value, index) => sameValue(value, right[index]));
71+ }
72+ if (typeof left === "object") {
73+ const leftKeys = Object.keys(left), rightKeys = Object.keys(right);
74+ return leftKeys.length === rightKeys.length && leftKeys.every(key => Object.prototype.hasOwnProperty.call(right, key) && sameValue(left[key], right[key]));
75+ }
76+ return false;
77+ };
78 const signEvent = async (unsigned, {signal} = {}) => {
79 if (!globalThis.nostr?.signEvent) throw Error("Connect a signer first.");
80 if (typeof globalThis.NostrSigner?.verifyEvent !== "function") throw Error("The signer verifier is still loading. Try again.");
81- const expected = JSON.stringify(unsigned);
82- const event = await globalThis.nostr.signEvent(JSON.parse(expected));
83- const actual = event && JSON.stringify({kind: event.kind, created_at: event.created_at, tags: event.tags, content: event.content});
84- if (actual !== expected || globalThis.NostrSigner.verifyEvent(event) !== true) throw Error("The signer returned an invalid or changed event.");
85+ const requested = JSON.parse(JSON.stringify(unsigned));
86+ const event = await globalThis.nostr.signEvent(JSON.parse(JSON.stringify(requested)));
87+ const actual = event && {kind: event.kind, created_at: event.created_at, tags: event.tags, content: event.content};
88+ if (actual && Object.prototype.hasOwnProperty.call(requested, "pubkey")) actual.pubkey = event.pubkey;
89+ if (!sameValue(actual, requested) || globalThis.NostrSigner.verifyEvent(event) !== true) throw Error("The signer returned an invalid or changed event.");
90 if (signal?.aborted) throw Error("Sending canceled.");
91 return event;
92 };
93diff --git a/scripts/tiny-signing.test.mjs b/scripts/tiny-signing.test.mjs
94new file mode 100644
95index 0000000..fd9bb30
96--- /dev/null
97+++ b/scripts/tiny-signing.test.mjs
98@@ -0,0 +1,62 @@
99+import assert from "node:assert/strict";
100+import test from "node:test";
101+import {finalizeEvent, generateSecretKey, getPublicKey, verifyEvent} from "nostr-tools/pure";
102+import {sharedSigning} from "./test-signing.mjs";
103+
104+const secret = generateSecretKey();
105+const reorder = event => ({content: event.content, tags: event.tags, created_at: event.created_at, kind: event.kind, pubkey: event.pubkey, id: event.id, sig: event.sig});
106+
107+function setup(signEvent) {
108+ const window = {nostr: {signEvent}, NostrSigner: {verifyEvent}};
109+ const tiny = {};
110+ return sharedSigning(window, tiny);
111+}
112+
113+test("signing accepts a valid event when a signer reorders object fields", async () => {
114+ const signing = setup(async unsigned => reorder(finalizeEvent(structuredClone(unsigned), secret)));
115+ const event = await signing.signEvent({kind: 10002, created_at: 1, content: "", tags: [["r", "wss://relay.example"]]});
116+ assert.equal(verifyEvent(event), true);
117+});
118+
119+test("signing still rejects a signer that changes an unsigned field", async () => {
120+ const signing = setup(async unsigned => {
121+ const event = structuredClone(unsigned);
122+ event.content = "changed";
123+ return finalizeEvent(event, secret);
124+ });
125+ await assert.rejects(signing.signEvent({kind: 10002, created_at: 1, content: "", tags: []}), /invalid or changed/);
126+});
127+
128+test("signing compares against a snapshot when the caller mutates its input", async () => {
129+ let received;
130+ const signing = setup(async unsigned => {
131+ received = unsigned;
132+ return finalizeEvent(structuredClone(unsigned), secret);
133+ });
134+ const unsigned = {kind: 10002, created_at: 1, content: "", tags: []};
135+ const pending = signing.signEvent(unsigned);
136+ unsigned.tags.push(["r", "wss://changed.example"]);
137+ const event = await pending;
138+ assert.equal(received.tags.length, 0);
139+ assert.equal(event.tags.length, 0);
140+});
141+
142+test("signing rejects an invalid signature even when every requested field matches", async () => {
143+ const signing = setup(async unsigned => ({...JSON.parse(JSON.stringify(finalizeEvent(structuredClone(unsigned), secret))), sig: "0".repeat(128)}));
144+ await assert.rejects(signing.signEvent({content: "", tags: [], created_at: 1, kind: 10050}), /invalid or changed/);
145+});
146+
147+test("signing preserves a requested author and rejects a different signer", async () => {
148+ const signing = setup(async unsigned => reorder(finalizeEvent(structuredClone(unsigned), secret)));
149+ const unsigned = {pubkey: getPublicKey(secret), content: "", tags: [], created_at: 1, kind: 10002};
150+ assert.equal((await signing.signEvent(unsigned)).pubkey, unsigned.pubkey);
151+ await assert.rejects(signing.signEvent({...unsigned, pubkey: getPublicKey(generateSecretKey())}), /invalid or changed/);
152+});
153+
154+test("signing rejects reordered tags and signer-side mutations", async () => {
155+ const signing = setup(async unsigned => {
156+ unsigned.tags.reverse();
157+ return finalizeEvent(structuredClone(unsigned), secret);
158+ });
159+ await assert.rejects(signing.signEvent({content: "", tags: [["r", "wss://first.example"], ["r", "wss://second.example"]], created_at: 1, kind: 10002}), /invalid or changed/);
160+});

About

ownerc01503e8035f
visibilitypublic
head68dd278487c0

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs