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>
| M | internal/ | 17 | ++++---- |
| M | internal/ | 25 | ++++++++++-- |
| A | scripts/ | 62 | ++++++++++++++++++++++++++++++ |
@@ -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. */@@ -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 };@@ -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+});