| 1 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 2 | index 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. */ |
| 54 | diff --git a/internal/webui/tiny.js b/internal/webui/tiny.js |
| 55 | index 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 | }; |
| 93 | diff --git a/scripts/tiny-signing.test.mjs b/scripts/tiny-signing.test.mjs |
| 94 | new file mode 100644 |
| 95 | index 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 | +}); |