OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop commits / 221ccd8

use a non-executable contact invitation scheme
221ccd8d101e66a1fd0f2a08cfe54dc74cb65513
by bigdestiny2 <33146784+bigdestiny2@users.noreply.github.com> · 2026-07-22T23:32:45.000Z

Changed files (4) +7 -8

backend/contacts.js +2 -3
……@@ -175,7 +175,7 @@ class Contacts {
175175 }
176176
177177 /**
178 * Parse a `pear://contact?pk=<hex>&name=<url-encoded>&sig=<hex>` URL
178 * Parse a `p2p-contact://invite?pk=<hex>&name=<url-encoded>&sig=<hex>` URL
179179 * into a contact record. `sig` is optional (ed25519 signature over
180180 * `pear.contact:<pubkey>:<displayName>` by the contact's root key —
181181 * proves the QR wasn't tampered with). If sig is present + invalid
……@@ -184,8 +184,7 @@ class Contacts {
184184 static parseInviteURL (url) {
185185 try {
186186 const u = new URL(url)
187 if (u.protocol !== 'pear:') return null
188 if (u.hostname !== 'contact' && u.pathname.replace('//', '') !== 'contact') return null
187 if (u.protocol !== 'p2p-contact:' || u.hostname !== 'invite') return null
189188 const pk = u.searchParams.get('pk')
190189 const name = u.searchParams.get('name') || ''
191190 const sig = u.searchParams.get('sig')
backend/index.js +1 -1
……@@ -1951,7 +1951,7 @@ rpc.handle(C.CMD_CONTACTS_MY_INVITE, async () => {
19511951 const bindingKey = b4a.toString(id.getAppKeypair('lighthouse-binding').publicKey, 'hex')
19521952 // sign name AND binding key, so neither can be swapped in transit (add() binds both)
19531953 const sig = id.sign(`pear.contact:${rootHex}:${displayName}:${bindingKey}`).signature
1954 const url = `pear://contact?pk=${rootHex}&name=${encodeURIComponent(displayName)}&sig=${sig}&bk=${bindingKey}`
1954 const url = `p2p-contact://invite?pk=${rootHex}&name=${encodeURIComponent(displayName)}&sig=${sig}&bk=${bindingKey}`
19551955 return { url, pubkey: rootHex, displayName, bindingKey }
19561956})
19571957
test/contacts.test.js +3 -3
……@@ -201,7 +201,7 @@ test('a full signed invite URL (pk+name+sig+bk) round-trips through parse + add,
201201 const { pubHex, sk } = newKeypair()
202202 const bindingKey = 'ef'.repeat(32)
203203 const sig = signMsg(inviteMessageBk(pubHex, 'Carol', bindingKey), sk)
204 const url = `pear://contact?pk=${pubHex}&name=${encodeURIComponent('Carol')}&sig=${sig}&bk=${bindingKey}`
204 const url = `p2p-contact://invite?pk=${pubHex}&name=${encodeURIComponent('Carol')}&sig=${sig}&bk=${bindingKey}`
205205 const parsed = Contacts.parseInviteURL(url)
206206 assert.equal(parsed.bindingKey, bindingKey)
207207 const rec = await contacts.add(parsed)
……@@ -216,7 +216,7 @@ test('a signed invite with a tampered name is rejected at add (invite integrity)
216216 const { pubHex, sk } = newKeypair()
217217 const bindingKey = 'ef'.repeat(32)
218218 const sig = signMsg(inviteMessageBk(pubHex, 'Carol', bindingKey), sk) // signed for "Carol"
219 const url = `pear://contact?pk=${pubHex}&name=Mallory&sig=${sig}&bk=${bindingKey}`
219 const url = `p2p-contact://invite?pk=${pubHex}&name=Mallory&sig=${sig}&bk=${bindingKey}`
220220 await assert.rejects(() => contacts.add(Contacts.parseInviteURL(url)), /signature invalid/)
221221 })
222222})
……@@ -227,7 +227,7 @@ test('a swapped binding key is rejected at add (bk is bound by the signature)',
227227 const realBk = 'ef'.repeat(32)
228228 const attackerBk = 'ab'.repeat(32)
229229 const sig = signMsg(inviteMessageBk(pubHex, 'Carol', realBk), sk) // signed for realBk
230 const url = `pear://contact?pk=${pubHex}&name=Carol&sig=${sig}&bk=${attackerBk}` // bk swapped
230 const url = `p2p-contact://invite?pk=${pubHex}&name=Carol&sig=${sig}&bk=${attackerBk}` // bk swapped
231231 await assert.rejects(() => contacts.add(Contacts.parseInviteURL(url)), /signature invalid/)
232232 })
233233})
ui/shell.js +1 -1
……@@ -3722,7 +3722,7 @@ function TrustedPeers ({ rpc, C }) {
37223722 <div className="tp-field">
37233723 <label>Add a peer</label>
37243724 <div className="tp-row">
3725 <input className="profile-input" placeholder="Paste a pear://contact invite…" value=${addUrl}
3725 <input className="profile-input" placeholder="Paste a p2p-contact://invite…" value=${addUrl}
37263726 onInput=${(e) => setAddUrl(e.target.value)} onKeyDown=${(e) => e.key === 'Enter' && add()} />
37273727 <button className="btn small primary" onClick=${add} disabled=${!addUrl.trim()}>Add</button>
37283728 </div>