clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

fix: align mobile drawer motion and validate signer fields by value

Dami committed 68dd278
Animate the account footer with the mobile navigation rail when opening and closing, and respect reduced-motion preferences.

Accept valid relay-list events regardless of JSON object key order while preserving requested fields, author checks, and signature verification. Cover field order, mutations, tag order, authors, and invalid signatures.
commit
68dd278487c04ba3a334ed1ff452efade191328c
parent
eb0ce7dcbb686dafb4f92f9a5895964506441499
author
Dami <dami.etoile@gmail.com>
3 files changed, +92 -12
Minternal/webui/style.css17++++----
Minternal/webui/tiny.js25++++++++++--
Ascripts/tiny-signing.test.mjs62++++++++++++++++++++++++++++++
M internal/webui/style.css +9 -8
@@ -371,7 +371,7 @@ room-action{display:block}#panel room-action label{margin-top:6px}#panel room-ac
371371 /* Phones keep conversation identity and navigation in a safe-area-aware header.372372 Native details controls expose the rail and contextual panel without scripts. */373373 @media(max-width:720px){374-:root{--mobile-header-height:calc(64px + env(safe-area-inset-top,0px));--mobile-drawer-width:min(320px,100vw)}374+:root{--mobile-header-height:calc(64px + env(safe-area-inset-top,0px));--mobile-drawer-width:min(320px,100vw);--mobile-drawer-duration:.15s}375375 html:has(signer-connection:not([hidden])){--mobile-header-height:calc(108px + env(safe-area-inset-top,0px))}376376 html,body{overflow:auto;scroll-padding-top:var(--mobile-header-height)}377377 #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}
@@ -402,10 +402,12 @@ body:has(signer-connection:not([hidden])) #session-status{display:none}
402402 #layout,#layout>tbody,#layout>tbody>tr,#layout>tbody>tr>td{display:block;width:auto;height:auto}403403 #layout{padding:var(--mobile-header-height) 0 env(safe-area-inset-bottom,0px)}404404 #railbox,#content,#panel{position:static;overflow:visible}405-#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}405+#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}406+#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)}406407 #railbox{position:relative;min-height:100%}407408 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}408-#topbar:has(#nav-menu[open]) + #layout>tbody>tr>td#rail{transform:none;visibility:visible;box-shadow:8px 0 24px rgba(0,0,0,.15)}409+body:has(#nav-menu[open]) #layout>tbody>tr>td#rail,body:has(#nav-menu[open]) #footer{transform:none;visibility:visible;transition-delay:0s}410+body:has(#nav-menu[open]) #layout>tbody>tr>td#rail{box-shadow:8px 0 24px rgba(0,0,0,.15)}409411 #layout>tbody>tr>td#aside{display:none;width:auto;border-left:0;border-top:0}410412 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)}411413 #content>section{padding:14px 2.5ch 20px}
@@ -417,13 +419,11 @@ body:has(#context-menu[open]) #layout>tbody>tr>td#aside{display:block;position:f
417419 #mobile-actions page-link{display:block}418420 html[data-js=no] #mobile-actions page-link{display:none}419421 #prompt{display:none}420-#footer{height:0;padding:0;border:0;background:none;z-index:4}421-#footer>table,#footer #install{display:none}422-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)}423-body:has(#nav-menu[open]) #footer>table{display:table;height:56px;table-layout:fixed}422+#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}423+#footer>table{display:table;height:56px;table-layout:fixed}424424 #footer td:first-child{display:none}#footer td:last-child{text-align:left;white-space:nowrap;overflow:hidden}425425 #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}426-body:has(#nav-menu[open]) #install:not([hidden]){display:block;position:static;transform:none;width:100%;height:40px;text-align:left}426+#install:not([hidden]){display:block;position:static;transform:none;width:100%;height:40px;text-align:left}427427 #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}428428 #split,#split>tbody,#split>tbody>tr,#split>tbody>tr>td{display:block;width:auto}429429 #tree{border-right:0;border-bottom:1px solid var(--line)}
@@ -450,6 +450,7 @@ room-message[data-own]{margin-left:12px;padding-right:10px}
450450 #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:" | "}451451 room-compose{bottom:0;margin:8px -2.5ch 0;padding:8px 12px calc(8px + env(safe-area-inset-bottom,0px));z-index:1}452452 }453+@media(max-width:720px) and (prefers-reduced-motion:reduce){:root{--mobile-drawer-duration:0s}}453454 454455 /* Social: a calm reading stream that keeps short notes quick and gives455456 long-form posts a clear title, image and reading measure. */
M internal/webui/tiny.js +21 -4
@@ -118,13 +118,30 @@
118118 // WebMCP. Signers are treated as untrusted extensions: the returned event119119 // must preserve the requested unsigned fields and pass the verifier before120120 // it is sent to the relay.121+ // Signers may reconstruct the event object while crossing an extension or122+ // NIP-46 boundary. Compare the signed event's unsigned fields by value,123+ // rather than by JSON property insertion order. Array order remains part of124+ // the event and changed values are still rejected.125+ const sameValue = (left, right) => {126+ if (left === right) return true;127+ if (typeof left !== typeof right || left === null || right === null) return false;128+ if (Array.isArray(left) || Array.isArray(right)) {129+ return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((value, index) => sameValue(value, right[index]));130+ }131+ if (typeof left === "object") {132+ const leftKeys = Object.keys(left), rightKeys = Object.keys(right);133+ return leftKeys.length === rightKeys.length && leftKeys.every(key => Object.prototype.hasOwnProperty.call(right, key) && sameValue(left[key], right[key]));134+ }135+ return false;136+ };121137 const signEvent = async (unsigned, {signal} = {}) => {122138 if (!globalThis.nostr?.signEvent) throw Error("Connect a signer first.");123139 if (typeof globalThis.NostrSigner?.verifyEvent !== "function") throw Error("The signer verifier is still loading. Try again.");124- const expected = JSON.stringify(unsigned);125- const event = await globalThis.nostr.signEvent(JSON.parse(expected));126- const actual = event && JSON.stringify({kind: event.kind, created_at: event.created_at, tags: event.tags, content: event.content});127- if (actual !== expected || globalThis.NostrSigner.verifyEvent(event) !== true) throw Error("The signer returned an invalid or changed event.");140+ const requested = JSON.parse(JSON.stringify(unsigned));141+ const event = await globalThis.nostr.signEvent(JSON.parse(JSON.stringify(requested)));142+ const actual = event && {kind: event.kind, created_at: event.created_at, tags: event.tags, content: event.content};143+ if (actual && Object.prototype.hasOwnProperty.call(requested, "pubkey")) actual.pubkey = event.pubkey;144+ if (!sameValue(actual, requested) || globalThis.NostrSigner.verifyEvent(event) !== true) throw Error("The signer returned an invalid or changed event.");128145 if (signal?.aborted) throw Error("Sending canceled.");129146 return event;130147 };
A scripts/tiny-signing.test.mjs +62 -0
@@ -0,0 +1,62 @@
1+import assert from "node:assert/strict";2+import test from "node:test";3+import {finalizeEvent, generateSecretKey, getPublicKey, verifyEvent} from "nostr-tools/pure";4+import {sharedSigning} from "./test-signing.mjs";5+6+const secret = generateSecretKey();7+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});8+9+function setup(signEvent) {10+ const window = {nostr: {signEvent}, NostrSigner: {verifyEvent}};11+ const tiny = {};12+ return sharedSigning(window, tiny);13+}14+15+test("signing accepts a valid event when a signer reorders object fields", async () => {16+ const signing = setup(async unsigned => reorder(finalizeEvent(structuredClone(unsigned), secret)));17+ const event = await signing.signEvent({kind: 10002, created_at: 1, content: "", tags: [["r", "wss://relay.example"]]});18+ assert.equal(verifyEvent(event), true);19+});20+21+test("signing still rejects a signer that changes an unsigned field", async () => {22+ const signing = setup(async unsigned => {23+ const event = structuredClone(unsigned);24+ event.content = "changed";25+ return finalizeEvent(event, secret);26+ });27+ await assert.rejects(signing.signEvent({kind: 10002, created_at: 1, content: "", tags: []}), /invalid or changed/);28+});29+30+test("signing compares against a snapshot when the caller mutates its input", async () => {31+ let received;32+ const signing = setup(async unsigned => {33+ received = unsigned;34+ return finalizeEvent(structuredClone(unsigned), secret);35+ });36+ const unsigned = {kind: 10002, created_at: 1, content: "", tags: []};37+ const pending = signing.signEvent(unsigned);38+ unsigned.tags.push(["r", "wss://changed.example"]);39+ const event = await pending;40+ assert.equal(received.tags.length, 0);41+ assert.equal(event.tags.length, 0);42+});43+44+test("signing rejects an invalid signature even when every requested field matches", async () => {45+ const signing = setup(async unsigned => ({...JSON.parse(JSON.stringify(finalizeEvent(structuredClone(unsigned), secret))), sig: "0".repeat(128)}));46+ await assert.rejects(signing.signEvent({content: "", tags: [], created_at: 1, kind: 10050}), /invalid or changed/);47+});48+49+test("signing preserves a requested author and rejects a different signer", async () => {50+ const signing = setup(async unsigned => reorder(finalizeEvent(structuredClone(unsigned), secret)));51+ const unsigned = {pubkey: getPublicKey(secret), content: "", tags: [], created_at: 1, kind: 10002};52+ assert.equal((await signing.signEvent(unsigned)).pubkey, unsigned.pubkey);53+ await assert.rejects(signing.signEvent({...unsigned, pubkey: getPublicKey(generateSecretKey())}), /invalid or changed/);54+});55+56+test("signing rejects reordered tags and signer-side mutations", async () => {57+ const signing = setup(async unsigned => {58+ unsigned.tags.reverse();59+ return finalizeEvent(structuredClone(unsigned), secret);60+ });61+ await assert.rejects(signing.signEvent({content: "", tags: [["r", "wss://first.example"], ["r", "wss://second.example"]], created_at: 1, kind: 10002}), /invalid or changed/);62+});