libp2r2p 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +20 -0
- package/README.md +129 -0
- package/base16/index.js +14 -0
- package/base64/index.js +29 -0
- package/content-key/event/index.js +85 -0
- package/content-key/index.js +1 -0
- package/content-key/services/iykc-proof.js +129 -0
- package/double-dh/index.js +166 -0
- package/ecdh/index.js +8 -0
- package/idb/index.js +64 -0
- package/idb-queue/index.js +793 -0
- package/index.js +21 -0
- package/key/index.js +139 -0
- package/network/index.js +68 -0
- package/nip44-v3/index.js +175 -0
- package/nip46/constants/index.js +5 -0
- package/nip46/helpers/frame.js +51 -0
- package/nip46/helpers/url.js +96 -0
- package/nip46/index.js +5 -0
- package/nip46/services/bunker-signer.js +49 -0
- package/nip46/services/client.js +252 -0
- package/nip46/services/server-session.js +225 -0
- package/nip46/services/transport.js +265 -0
- package/package.json +49 -0
- package/private-channel/constants/index.js +5 -0
- package/private-channel/helpers/chunk-size.js +61 -0
- package/private-channel/helpers/chunks.js +282 -0
- package/private-channel/helpers/event.js +68 -0
- package/private-channel/index.js +942 -0
- package/private-channel/services/received-chunks.js +398 -0
- package/private-message/index.js +672 -0
- package/private-messenger/constants/index.js +1 -0
- package/private-messenger/index.js +1431 -0
- package/private-messenger/recovery/index.js +316 -0
- package/relay/constants/index.js +18 -0
- package/relay/helpers/hll.js +62 -0
- package/relay/helpers/publish.js +100 -0
- package/relay/helpers/routing.js +36 -0
- package/relay/helpers/timer.js +4 -0
- package/relay/index.js +4 -0
- package/relay/services/query.js +224 -0
- package/relay/services/relay-connection.js +156 -0
- package/relay/services/relay-pool.js +908 -0
- package/temporary-storage/index.js +85 -0
- package/tests/base16.test.js +19 -0
- package/tests/base64.test.js +18 -0
- package/tests/content-key-event.test.js +48 -0
- package/tests/fixtures/nip44v3-vectors.json +1 -0
- package/tests/helpers/test-signer.js +185 -0
- package/tests/idb-queue.test.js +153 -0
- package/tests/idb.test.js +91 -0
- package/tests/nip44-v3.test.js +73 -0
- package/tests/nip46.test.js +668 -0
- package/tests/private-channel.test.js +1899 -0
- package/tests/private-message.test.js +460 -0
- package/tests/private-messenger.test.js +1715 -0
- package/tests/queries.test.js +101 -0
- package/tests/queue-parity.test.js +105 -0
- package/tests/relay-hll.test.js +32 -0
- package/tests/relay-pool.test.js +2067 -0
- package/tests/temporary-storage.test.js +89 -0
- package/tests/web-storage-queue.test.js +480 -0
- package/web-storage-queue/index.js +652 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
## Project Shape
|
|
4
|
+
|
|
5
|
+
libp2r2p is an ESM package with no build step. Public package exports should
|
|
6
|
+
come from their own folders, each with an `index.js` entry point. Prefer
|
|
7
|
+
singular public subpaths, such as `libp2r2p/key` and `libp2r2p/relay`.
|
|
8
|
+
|
|
9
|
+
Public export folders may organize implementation details with `constants/`,
|
|
10
|
+
`helpers/`, and `services/` subfolders. Additional custom public subfolders
|
|
11
|
+
are fine when they are part of the API, such as `private-messenger/recovery`
|
|
12
|
+
or `content-key/event`, and those should also expose an `index.js`.
|
|
13
|
+
|
|
14
|
+
Root-level `constants/`, `helpers/`, and `services/` folders are reserved
|
|
15
|
+
for shared internal code that is not itself a public export. Do not expose
|
|
16
|
+
internal files directly through `package.json`; add or adjust an export-folder
|
|
17
|
+
`index.js` instead.
|
|
18
|
+
|
|
19
|
+
Keep the `exports` object in `package.json` sorted, with `.` first.
|
|
20
|
+
Do not keep backwards-compatible alias exports unless explicitly requested.
|
package/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# libp2r2p
|
|
2
|
+
|
|
3
|
+
Peer-to-relay-to-peer utilities for Nostr apps.
|
|
4
|
+
|
|
5
|
+
libp2r2p focuses on flows where one peer talks to another peer with Nostr
|
|
6
|
+
relays in the middle. It is not pure peer-to-peer networking; relays provide
|
|
7
|
+
the transport and discovery surface. The package was born to distribute the
|
|
8
|
+
private messenger reference implementation, and it also carries a few Nostr
|
|
9
|
+
power-ups used by that messenger.
|
|
10
|
+
|
|
11
|
+
## Private Messenger
|
|
12
|
+
|
|
13
|
+
The main API is `createPrivateMessenger` from `libp2r2p/private-messenger`.
|
|
14
|
+
It coordinates private-channel wrapping, relay watching, recovery state, and
|
|
15
|
+
content-key lookup for direct or group-style private app messages.
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
import { createPrivateMessenger } from 'libp2r2p/private-messenger'
|
|
19
|
+
|
|
20
|
+
const messenger = await createPrivateMessenger({
|
|
21
|
+
userSigner,
|
|
22
|
+
contentKeySigner,
|
|
23
|
+
channels: [{
|
|
24
|
+
signer: privateChannelSigner,
|
|
25
|
+
relays: ['wss://relay.example'],
|
|
26
|
+
mode: 'leecher'
|
|
27
|
+
}],
|
|
28
|
+
onError: err => console.warn('private messenger failed', err)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
async function logMessages () {
|
|
32
|
+
for await (const message of messenger.messages()) {
|
|
33
|
+
console.log(message.type, message.payload)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
logMessages().catch(err => console.warn('private messenger messages failed', err))
|
|
38
|
+
|
|
39
|
+
await messenger.tell({
|
|
40
|
+
receiverPubkey,
|
|
41
|
+
payload: { text: 'hello' }
|
|
42
|
+
})
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Deleting Private Broadcasts
|
|
46
|
+
|
|
47
|
+
High-level private-message sends create one deletion capability for the whole
|
|
48
|
+
logical message. The returned object keeps its normal result fields and adds
|
|
49
|
+
`deletionPubkey` plus, when the library generated the key, `deletionSeckey`.
|
|
50
|
+
All outer kind `3560` chunks for that send carry the public
|
|
51
|
+
`['s', deletionPubkey]` tag.
|
|
52
|
+
|
|
53
|
+
Pass a pre-generated `deletionPubkey` when the application needs to own and
|
|
54
|
+
persist the secret before sending. In that case the result deliberately omits a
|
|
55
|
+
secret it was not given:
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
import { generateKeypair } from 'libp2r2p/key'
|
|
59
|
+
|
|
60
|
+
const deletionKey = generateKeypair()
|
|
61
|
+
const sent = await messenger.tell({
|
|
62
|
+
receiverPubkey,
|
|
63
|
+
payload: { text: 'remove this later' },
|
|
64
|
+
deletionPubkey: deletionKey.pubkey
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// Persist deletionKey.seckey with the application's copy of the message.
|
|
68
|
+
console.log(sent.deletionPubkey)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The `s` tag is public metadata, so it makes the outer chunks for one message
|
|
72
|
+
linkable. It is a deletion capability, not the channel key or the sender's
|
|
73
|
+
identity. libp2r2p does not automatically delete anything: an application can
|
|
74
|
+
later find candidate outer events with a filter such as
|
|
75
|
+
`{ kinds: [3560], authors: [channelPubkey], '#s': [deletionPubkey] }`, then
|
|
76
|
+
sign a kind `5` deletion event with the matching secret key. Relay support for
|
|
77
|
+
this capability is not universal. A relay that supports it SHOULD recognize
|
|
78
|
+
only the explicit `k=3560` deletion form; a regular NIP-09 kind `5` event
|
|
79
|
+
signed by the outer event's private-channel key MUST NOT delete a kind `3560`
|
|
80
|
+
event, whether or not that event carries an `s` tag.
|
|
81
|
+
|
|
82
|
+
### Temporary Send Storage
|
|
83
|
+
|
|
84
|
+
While an outgoing private message is being assembled, the messenger keeps
|
|
85
|
+
encrypted envelope rows and router chunks in `sessionStorage`. They are
|
|
86
|
+
removed when the send finishes, but an interrupted browser operation can leave
|
|
87
|
+
them behind until cleanup runs.
|
|
88
|
+
|
|
89
|
+
`PrivateMessenger.init()` performs cleanup automatically. Call
|
|
90
|
+
`PrivateMessenger.cleanupTemporaryStorage()` once during app startup when
|
|
91
|
+
messenger initialization may be delayed, such as while an account is locked:
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
import { PrivateMessenger } from 'libp2r2p/private-messenger'
|
|
95
|
+
|
|
96
|
+
PrivateMessenger.cleanupTemporaryStorage()
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Call it before any private-message send using that storage area starts. It
|
|
100
|
+
does not clear persisted messages, recovery material, or channel state. Pass
|
|
101
|
+
`temporaryStorageArea: localStorage` when constructing a messenger to opt into
|
|
102
|
+
a different Storage area.
|
|
103
|
+
|
|
104
|
+
Signers are expected to expose the Nostr-style methods used by the messenger,
|
|
105
|
+
including `getPublicKey()`, `signEvent(event)`, and the NIP-44 v3 methods
|
|
106
|
+
needed by private channels. For double-DH content-key use, pass a
|
|
107
|
+
`contentKeySigner` or a signer implementation that handles content keys
|
|
108
|
+
internally.
|
|
109
|
+
|
|
110
|
+
Messages are stored in a bounded, durable IndexedDB queue until consumed:
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
async function handleMessages () {
|
|
114
|
+
for await (const message of messenger.messages()) {
|
|
115
|
+
if (message.type === 'message') {
|
|
116
|
+
console.log(message.payload)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
handleMessages().catch(err => console.warn('private messenger messages failed', err))
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
For one-at-a-time consumption, use `await messenger.nextMessage()`. Queue
|
|
125
|
+
clearing is asynchronous too: `await messenger.clearChannel(channelPubkey)`.
|
|
126
|
+
|
|
127
|
+
Use explicit subpath imports for bundle size. The package root re-exports the
|
|
128
|
+
main messenger API for convenience, but applications that only need one piece
|
|
129
|
+
should import that subpath directly.
|
package/base16/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function bytesToBase16 (bytes) {
|
|
2
|
+
let s = ''
|
|
3
|
+
for (const b of bytes) s += b.toString(16).padStart(2, '0')
|
|
4
|
+
return s
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function base16ToBytes (base16) {
|
|
8
|
+
const out = new Uint8Array(base16.length / 2)
|
|
9
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(base16.slice(i * 2, i * 2 + 2), 16)
|
|
10
|
+
return out
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const bytesToHex = bytesToBase16
|
|
14
|
+
export const hexToBytes = base16ToBytes
|
package/base64/index.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Plain and URL-safe base64 codecs for binary <-> string conversion.
|
|
2
|
+
// Plain base64 is used where a protocol expects standard base64. URL-safe
|
|
3
|
+
// base64 is useful for WebAuthn credential IDs and URL/query-string material.
|
|
4
|
+
|
|
5
|
+
export function bytesToBase64 (bytes) {
|
|
6
|
+
let s = ''
|
|
7
|
+
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i])
|
|
8
|
+
return btoa(s)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function base64ToBytes (b64) {
|
|
12
|
+
const bin = atob(b64)
|
|
13
|
+
const out = new Uint8Array(bin.length)
|
|
14
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
|
15
|
+
return out
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function bytesToBase64Url (bytes) {
|
|
19
|
+
return bytesToBase64(bytes)
|
|
20
|
+
.replace(/\+/g, '-')
|
|
21
|
+
.replace(/\//g, '_')
|
|
22
|
+
.replace(/=+$/g, '')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function base64UrlToBytes (base64url) {
|
|
26
|
+
const value = String(base64url)
|
|
27
|
+
const pad = value.length % 4 === 0 ? '' : '='.repeat(4 - (value.length % 4))
|
|
28
|
+
return base64ToBytes(value.replace(/-/g, '+').replace(/_/g, '/') + pad)
|
|
29
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { getEventHash, verifyEvent } from 'nostr-tools'
|
|
2
|
+
|
|
3
|
+
export const CONTENT_KEY_KIND = 18716
|
|
4
|
+
|
|
5
|
+
const HEX_PUBKEY = /^[0-9a-f]{64}$/i
|
|
6
|
+
const HEX_SIG = /^[0-9a-f]{128}$/i
|
|
7
|
+
|
|
8
|
+
function nowSeconds () {
|
|
9
|
+
return Math.floor(Date.now() / 1000)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function makeContentKeyEventForPubkey ({ userSigner, contentPubkey, createdAt = nowSeconds() }) {
|
|
13
|
+
if (!userSigner?.getPublicKey || !userSigner?.signEvent) throw new Error('USER_SIGNER_REQUIRED')
|
|
14
|
+
if (!HEX_PUBKEY.test(contentPubkey || '')) throw new Error('CONTENT_PUBKEY_REQUIRED')
|
|
15
|
+
|
|
16
|
+
return userSigner.signEvent({
|
|
17
|
+
kind: CONTENT_KEY_KIND,
|
|
18
|
+
created_at: createdAt,
|
|
19
|
+
tags: [['cp', contentPubkey]],
|
|
20
|
+
content: ''
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function makeContentKeyEvent ({ userSigner, contentKeySigner, createdAt = nowSeconds() }) {
|
|
25
|
+
if (!contentKeySigner?.getPublicKey) throw new Error('CONTENT_KEY_SIGNER_REQUIRED')
|
|
26
|
+
return makeContentKeyEventForPubkey({
|
|
27
|
+
userSigner,
|
|
28
|
+
contentPubkey: await contentKeySigner.getPublicKey(),
|
|
29
|
+
createdAt
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseContentKeyEvent (event) {
|
|
34
|
+
if (!event || event.kind !== CONTENT_KEY_KIND || event.content !== '') return null
|
|
35
|
+
if (!HEX_PUBKEY.test(event.pubkey) || !Number.isSafeInteger(event.created_at)) return null
|
|
36
|
+
if (!Array.isArray(event.tags) || event.tags.length !== 1) return null
|
|
37
|
+
if (event.id !== getEventHash(event) || !verifyEvent(event)) return null
|
|
38
|
+
|
|
39
|
+
const [name, contentPubkey, ...rest] = event.tags[0] || []
|
|
40
|
+
if (name !== 'cp' || rest.length || !HEX_PUBKEY.test(contentPubkey || '')) return null
|
|
41
|
+
return { iykcPubkey: contentPubkey, iykcProof: makeContentKeyProof(event) }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function makeContentKeyProof (contentKeyEvent) {
|
|
45
|
+
if (!Number.isSafeInteger(contentKeyEvent?.created_at) || !HEX_SIG.test(contentKeyEvent?.sig || '')) return ''
|
|
46
|
+
return `${contentKeyEvent.created_at}:${contentKeyEvent.sig}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const makeIykcProof = makeContentKeyProof
|
|
50
|
+
|
|
51
|
+
function parseContentKeyProof (proof) {
|
|
52
|
+
if (typeof proof !== 'string') return null
|
|
53
|
+
const [createdAtString, sig, extra] = proof.split(':')
|
|
54
|
+
if (extra != null || !/^\d+$/.test(createdAtString || '') || !HEX_SIG.test(sig || '')) return null
|
|
55
|
+
// eslint-disable-next-line camelcase
|
|
56
|
+
const created_at = Number(createdAtString)
|
|
57
|
+
if (!Number.isSafeInteger(created_at)) return null
|
|
58
|
+
// eslint-disable-next-line camelcase
|
|
59
|
+
return { created_at, sig }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function verifyContentKeyProof ({ ownerPubkey, contentPubkey, proof }) {
|
|
63
|
+
if (!HEX_PUBKEY.test(ownerPubkey || '') || !HEX_PUBKEY.test(contentPubkey || '')) return false
|
|
64
|
+
const parsed = parseContentKeyProof(proof)
|
|
65
|
+
if (!parsed) return false
|
|
66
|
+
|
|
67
|
+
const event = {
|
|
68
|
+
kind: CONTENT_KEY_KIND,
|
|
69
|
+
pubkey: ownerPubkey,
|
|
70
|
+
created_at: parsed.created_at,
|
|
71
|
+
tags: [['cp', contentPubkey]],
|
|
72
|
+
content: '',
|
|
73
|
+
sig: parsed.sig
|
|
74
|
+
}
|
|
75
|
+
event.id = getEventHash(event)
|
|
76
|
+
return verifyEvent(event)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function verifyIykcProof ({ receiverPubkey, iykcPubkey, iykcProof }) {
|
|
80
|
+
return verifyContentKeyProof({
|
|
81
|
+
ownerPubkey: receiverPubkey,
|
|
82
|
+
contentPubkey: iykcPubkey,
|
|
83
|
+
proof: iykcProof
|
|
84
|
+
})
|
|
85
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { getIykcProofs } from './services/iykc-proof.js'
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { CONTENT_KEY_KIND, parseContentKeyEvent } from '../event/index.js'
|
|
2
|
+
import { pickRelaysForPubkeys, getRelaysByPubkey, relayPool } from '../../relay/index.js'
|
|
3
|
+
|
|
4
|
+
const QUERY_CACHE_MS = 40 * 60 * 1000
|
|
5
|
+
const IYKC_CACHE_MAX_ITEMS = 10000
|
|
6
|
+
const HEX_PUBKEY = /^[0-9a-f]{64}$/i
|
|
7
|
+
const contentKeysByPubkey = Object.create(null)
|
|
8
|
+
const iykcCacheTimersByPubkey = Object.create(null)
|
|
9
|
+
const iykcCacheAddedAtByPubkey = Object.create(null)
|
|
10
|
+
|
|
11
|
+
const getEvents = (...args) => relayPool.getEvents(...args)
|
|
12
|
+
|
|
13
|
+
function hasCachedKey (cache, key) {
|
|
14
|
+
return Object.prototype.hasOwnProperty.call(cache, key)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function maybeUnref (timer) {
|
|
18
|
+
timer?.unref?.()
|
|
19
|
+
return timer
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function uniquePubkeys (pubkeys, { requireHex = false } = {}) {
|
|
23
|
+
const values = [...new Set(pubkeys || [])].filter(Boolean)
|
|
24
|
+
return requireHex ? values.filter(pubkey => HEX_PUBKEY.test(pubkey)) : values
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function cloneContentKey (contentKey) {
|
|
28
|
+
return contentKey
|
|
29
|
+
? {
|
|
30
|
+
iykcPubkey: contentKey.iykcPubkey,
|
|
31
|
+
iykcProof: contentKey.iykcProof
|
|
32
|
+
}
|
|
33
|
+
: null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function deleteCachedValue (cache, timers, addedAt, key) {
|
|
37
|
+
clearTimeout(timers[key])
|
|
38
|
+
delete cache[key]
|
|
39
|
+
delete timers[key]
|
|
40
|
+
delete addedAt[key]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function pruneCache (cache, timers, addedAt, maxItems) {
|
|
44
|
+
const keys = Object.keys(cache)
|
|
45
|
+
if (keys.length <= maxItems) return
|
|
46
|
+
|
|
47
|
+
keys
|
|
48
|
+
.sort((a, b) => (addedAt[a] || 0) - (addedAt[b] || 0))
|
|
49
|
+
.slice(0, keys.length - maxItems)
|
|
50
|
+
.forEach(key => deleteCachedValue(cache, timers, addedAt, key))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function setCachedValue (cache, timers, addedAt, key, value, cacheMs) {
|
|
54
|
+
cache[key] = value
|
|
55
|
+
addedAt[key] = Date.now()
|
|
56
|
+
clearTimeout(timers[key])
|
|
57
|
+
if (cacheMs > 0) {
|
|
58
|
+
timers[key] = maybeUnref(setTimeout(() => {
|
|
59
|
+
deleteCachedValue(cache, timers, addedAt, key)
|
|
60
|
+
}, cacheMs))
|
|
61
|
+
} else {
|
|
62
|
+
delete timers[key]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function clearContentKeyCache () {
|
|
67
|
+
for (const timer of Object.values(iykcCacheTimersByPubkey)) clearTimeout(timer)
|
|
68
|
+
for (const key of Object.keys(contentKeysByPubkey)) delete contentKeysByPubkey[key]
|
|
69
|
+
for (const key of Object.keys(iykcCacheTimersByPubkey)) delete iykcCacheTimersByPubkey[key]
|
|
70
|
+
for (const key of Object.keys(iykcCacheAddedAtByPubkey)) delete iykcCacheAddedAtByPubkey[key]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function getIykcProofs (pubkeys, {
|
|
74
|
+
_getEvents = getEvents,
|
|
75
|
+
_getRelaysByPubkey = getRelaysByPubkey,
|
|
76
|
+
cacheMs = QUERY_CACHE_MS
|
|
77
|
+
} = {}) {
|
|
78
|
+
const pubkeyList = uniquePubkeys(pubkeys, { requireHex: _getEvents === getEvents })
|
|
79
|
+
if (!pubkeyList.length) return {}
|
|
80
|
+
|
|
81
|
+
const out = {}
|
|
82
|
+
const missingPubkeys = []
|
|
83
|
+
for (const pubkey of pubkeyList) {
|
|
84
|
+
if (!hasCachedKey(contentKeysByPubkey, pubkey)) {
|
|
85
|
+
missingPubkeys.push(pubkey)
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
const cached = cloneContentKey(contentKeysByPubkey[pubkey])
|
|
89
|
+
if (cached) out[pubkey] = cached
|
|
90
|
+
}
|
|
91
|
+
if (!missingPubkeys.length) return out
|
|
92
|
+
|
|
93
|
+
const relaysByPubkey = await _getRelaysByPubkey(missingPubkeys, { _getEvents, cacheMs })
|
|
94
|
+
const relayToAuthors = pickRelaysForPubkeys(missingPubkeys, relaysByPubkey)
|
|
95
|
+
const eventGroups = await Promise.all(
|
|
96
|
+
[...relayToAuthors.entries()]
|
|
97
|
+
.map(async ([relay, authors]) => {
|
|
98
|
+
const { result } = await _getEvents({
|
|
99
|
+
kinds: [CONTENT_KEY_KIND],
|
|
100
|
+
authors,
|
|
101
|
+
limit: authors.length
|
|
102
|
+
}, [relay], {
|
|
103
|
+
timeout: 5000,
|
|
104
|
+
timeoutAfterFirstEose: null
|
|
105
|
+
})
|
|
106
|
+
return result
|
|
107
|
+
})
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
const latestByPubkey = {}
|
|
111
|
+
for (const event of eventGroups.flat()) {
|
|
112
|
+
const parsed = parseContentKeyEvent(event)
|
|
113
|
+
if (!parsed) continue
|
|
114
|
+
if (!latestByPubkey[event.pubkey] || event.created_at > latestByPubkey[event.pubkey].created_at) {
|
|
115
|
+
latestByPubkey[event.pubkey] = { created_at: event.created_at, ...parsed }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const pubkey of missingPubkeys) {
|
|
120
|
+
const entry = latestByPubkey[pubkey]
|
|
121
|
+
const proof = entry
|
|
122
|
+
? { iykcPubkey: entry.iykcPubkey, iykcProof: entry.iykcProof }
|
|
123
|
+
: null
|
|
124
|
+
setCachedValue(contentKeysByPubkey, iykcCacheTimersByPubkey, iykcCacheAddedAtByPubkey, pubkey, cloneContentKey(proof), cacheMs)
|
|
125
|
+
if (proof) out[pubkey] = proof
|
|
126
|
+
}
|
|
127
|
+
pruneCache(contentKeysByPubkey, iykcCacheTimersByPubkey, iykcCacheAddedAtByPubkey, IYKC_CACHE_MAX_ITEMS)
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { extract, expand } from '@noble/hashes/hkdf.js'
|
|
2
|
+
import { sha256 } from '@noble/hashes/sha2.js'
|
|
3
|
+
import { concatBytes } from '@noble/hashes/utils.js'
|
|
4
|
+
import { sharedXOnlySecret } from '../ecdh/index.js'
|
|
5
|
+
|
|
6
|
+
const textEncoder = new TextEncoder()
|
|
7
|
+
|
|
8
|
+
// HKDF extract salt naming this generic double-DH key schedule. It has no NUL
|
|
9
|
+
// suffix because this fixed label is not concatenated with variable bytes
|
|
10
|
+
// (unlike NIP-44 v3's "nip44-v3\x00" || nonce salt).
|
|
11
|
+
const DOUBLE_DH_SALT = textEncoder.encode('nostr-double-dh-v1')
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
Considering the following nomenclature, where a/b are lexicographically
|
|
15
|
+
ordered identity pubkeys, not sender/receiver roles:
|
|
16
|
+
II = DH(aIdentitySecret, bIdentityPubkey)
|
|
17
|
+
IC = DH(aIdentitySecret, bContentPubkey)
|
|
18
|
+
CI = DH(aContentSecret, bIdentityPubkey)
|
|
19
|
+
CC = DH(aContentSecret, bContentPubkey)
|
|
20
|
+
Double-DH uses a minimal, fixed-order transcript of raw 32-byte DH outputs:
|
|
21
|
+
- identity mode uses no Double-DH transcript; callers fall back to identity NIP-44 v3.
|
|
22
|
+
- one content key uses identity/identity || identity/content, i.e. II || CI
|
|
23
|
+
when a has the content key, or II || IC when b has the content key.
|
|
24
|
+
- two content keys use identity/identity || content/content, i.e. II || CC.
|
|
25
|
+
Review of some DH combinations:
|
|
26
|
+
1) Four DHs (II || IC || CI || CC):
|
|
27
|
+
- useful when two participants have content keys
|
|
28
|
+
- requires both secrets from the same participant (a identity + a content,
|
|
29
|
+
or b identity + b content)
|
|
30
|
+
2) Three DHs (II || CC || IC or II || CC || CI):
|
|
31
|
+
- useful when two participants have content keys
|
|
32
|
+
- requires both secrets from one participant, or the cross-participant pair
|
|
33
|
+
matched by the mixed term (a identity + b content for IC, or
|
|
34
|
+
a content + b identity for CI)
|
|
35
|
+
3) Three DHs (CC || IC || CI):
|
|
36
|
+
- useful when two participants have content keys
|
|
37
|
+
- requires both content secrets, or both secrets from one participant
|
|
38
|
+
- prevents identity-only compromise, but both content keys are enough
|
|
39
|
+
- interesting that anyone can use two content keys and pretend that
|
|
40
|
+
any two participants are talking to each other.
|
|
41
|
+
4) [picked] Two DHs (II || CC):
|
|
42
|
+
- useful when two participants have content keys
|
|
43
|
+
- requires one identity secret and one content secret, from any participant pairing
|
|
44
|
+
- prevents identity-only and content-only compromise; weaker than 1) because
|
|
45
|
+
cross-participant identity+content leaks are enough
|
|
46
|
+
5) [picked] Two DHs (II || IC or II || CI):
|
|
47
|
+
- useful when exactly one participant has a content key
|
|
48
|
+
- requires either the identity-only participant's identity secret, or both
|
|
49
|
+
secrets from the content-key participant
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
function u32be (n) {
|
|
53
|
+
const b = new Uint8Array(4)
|
|
54
|
+
new DataView(b.buffer).setUint32(0, n >>> 0, false)
|
|
55
|
+
return b
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeKind (kind) {
|
|
59
|
+
const n = typeof kind === 'string' && kind.trim() !== '' ? Number(kind) : kind
|
|
60
|
+
if (!Number.isInteger(n) || n < 0 || n > 0xffffffff) throw new Error('INVALID_KIND')
|
|
61
|
+
return n
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sharedX (secretKey, pubkey) {
|
|
65
|
+
return sharedXOnlySecret(secretKey, pubkey)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function modeFor ({ senderContentPubkey, receiverContentPubkey }) {
|
|
69
|
+
if (senderContentPubkey && receiverContentPubkey) return 'both-content'
|
|
70
|
+
if (senderContentPubkey) return 'sender-content'
|
|
71
|
+
if (receiverContentPubkey) return 'receiver-content'
|
|
72
|
+
return 'identity'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function orderedPair ({ identityPubkey, contentPubkey, peerIdentityPubkey, peerContentPubkey }) {
|
|
76
|
+
const self = { identityPubkey, contentPubkey: contentPubkey || '', isSelf: true }
|
|
77
|
+
const peer = { identityPubkey: peerIdentityPubkey, contentPubkey: peerContentPubkey || '', isSelf: false }
|
|
78
|
+
return identityPubkey <= peerIdentityPubkey ? [self, peer] : [peer, self]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function hkdfInfo ({ kind, scope = '' }) {
|
|
82
|
+
const scopeBytes = textEncoder.encode(scope || '')
|
|
83
|
+
// By adding NIP-44 v3's public kind/scope context to the Double-DH conversation key
|
|
84
|
+
// derivation, we ensure that a leaked conversation key only decrypts messages with
|
|
85
|
+
// the same context.
|
|
86
|
+
return concatBytes(u32be(normalizeKind(kind)), u32be(scopeBytes.length), scopeBytes)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function identityIdentity ({ a, b, identitySecretKey }) {
|
|
90
|
+
return sharedX(identitySecretKey, b.isSelf ? a.identityPubkey : b.identityPubkey)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function aContentBIdentity ({ a, b, identitySecretKey, contentSecretKey }) {
|
|
94
|
+
return a.isSelf
|
|
95
|
+
? sharedX(contentSecretKey, b.identityPubkey)
|
|
96
|
+
: sharedX(identitySecretKey, a.contentPubkey)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function aIdentityBContent ({ a, b, identitySecretKey, contentSecretKey }) {
|
|
100
|
+
return a.isSelf
|
|
101
|
+
? sharedX(identitySecretKey, b.contentPubkey)
|
|
102
|
+
: sharedX(contentSecretKey, a.identityPubkey)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function contentContent ({ a, b, contentSecretKey }) {
|
|
106
|
+
return a.isSelf
|
|
107
|
+
? sharedX(contentSecretKey, b.contentPubkey)
|
|
108
|
+
: sharedX(contentSecretKey, a.contentPubkey)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function deriveDoubleDhConversationKey ({
|
|
112
|
+
role,
|
|
113
|
+
identitySecretKey,
|
|
114
|
+
identityPubkey,
|
|
115
|
+
contentSecretKey,
|
|
116
|
+
contentPubkey = '',
|
|
117
|
+
peerIdentityPubkey,
|
|
118
|
+
peerContentPubkey = '',
|
|
119
|
+
kind,
|
|
120
|
+
scope = ''
|
|
121
|
+
}) {
|
|
122
|
+
if (role !== 'sender' && role !== 'receiver') throw new Error('INVALID_DOUBLE_DH_ROLE')
|
|
123
|
+
if (!identitySecretKey || !identityPubkey || !peerIdentityPubkey) throw new Error('DOUBLE_DH_IDENTITY_REQUIRED')
|
|
124
|
+
|
|
125
|
+
const isSender = role === 'sender'
|
|
126
|
+
const senderContentPubkey = isSender ? contentPubkey : peerContentPubkey
|
|
127
|
+
const receiverContentPubkey = isSender ? peerContentPubkey : contentPubkey
|
|
128
|
+
const mode = modeFor({ senderContentPubkey, receiverContentPubkey })
|
|
129
|
+
|
|
130
|
+
if (mode === 'identity') return { mode, conversationKey: null }
|
|
131
|
+
if (isSender && senderContentPubkey && !contentSecretKey) throw new Error('SENDER_CONTENT_KEY_REQUIRED')
|
|
132
|
+
if (!isSender && receiverContentPubkey && !contentSecretKey) throw new Error('RECEIVER_CONTENT_KEY_REQUIRED')
|
|
133
|
+
|
|
134
|
+
const [a, b] = orderedPair({ identityPubkey, contentPubkey, peerIdentityPubkey, peerContentPubkey })
|
|
135
|
+
const steps = []
|
|
136
|
+
|
|
137
|
+
if (identityPubkey === peerIdentityPubkey) {
|
|
138
|
+
const ownContentPubkey = contentPubkey || peerContentPubkey
|
|
139
|
+
if (ownContentPubkey && !contentSecretKey) {
|
|
140
|
+
throw new Error(isSender ? 'SENDER_CONTENT_KEY_REQUIRED' : 'RECEIVER_CONTENT_KEY_REQUIRED')
|
|
141
|
+
}
|
|
142
|
+
// In self-encryption, DH(identitySecret, contentPubkey) could be computed
|
|
143
|
+
// with either single secret plus the other public key. The two self-DH
|
|
144
|
+
// steps below are the minimal transcript that requires both secrets.
|
|
145
|
+
steps.push(sharedX(identitySecretKey, identityPubkey))
|
|
146
|
+
if (ownContentPubkey) {
|
|
147
|
+
steps.push(sharedX(contentSecretKey, ownContentPubkey))
|
|
148
|
+
}
|
|
149
|
+
} else {
|
|
150
|
+
steps.push(identityIdentity({ a, b, identitySecretKey }))
|
|
151
|
+
if (a.contentPubkey && b.contentPubkey) {
|
|
152
|
+
steps.push(contentContent({ a, b, contentSecretKey }))
|
|
153
|
+
} else if (a.contentPubkey) {
|
|
154
|
+
steps.push(aContentBIdentity({ a, b, identitySecretKey, contentSecretKey }))
|
|
155
|
+
} else if (b.contentPubkey) {
|
|
156
|
+
steps.push(aIdentityBContent({ a, b, identitySecretKey, contentSecretKey }))
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const ikm = concatBytes(...steps)
|
|
161
|
+
const prk = extract(sha256, ikm, DOUBLE_DH_SALT)
|
|
162
|
+
return {
|
|
163
|
+
mode,
|
|
164
|
+
conversationKey: expand(sha256, prk, hkdfInfo({ kind, scope }), 32)
|
|
165
|
+
}
|
|
166
|
+
}
|
package/ecdh/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { secp256k1 } from '@noble/curves/secp256k1.js'
|
|
2
|
+
import { hexToBytes } from '../base16/index.js'
|
|
3
|
+
|
|
4
|
+
export function sharedXOnlySecret (seckey, pubkey) {
|
|
5
|
+
// Nostr pubkeys are x-only. secp256k1 ECDH expects a compressed point, so
|
|
6
|
+
// use the even-y prefix and drop the returned parity byte.
|
|
7
|
+
return secp256k1.getSharedSecret(seckey, hexToBytes(`02${pubkey}`)).subarray(1, 33)
|
|
8
|
+
}
|
package/idb/index.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const READ_METHODS = new Set([
|
|
2
|
+
'count',
|
|
3
|
+
'get',
|
|
4
|
+
'getAll',
|
|
5
|
+
'getAllKeys',
|
|
6
|
+
'getKey',
|
|
7
|
+
'openCursor',
|
|
8
|
+
'openKeyCursor'
|
|
9
|
+
])
|
|
10
|
+
|
|
11
|
+
function deferred () {
|
|
12
|
+
let resolve
|
|
13
|
+
let reject
|
|
14
|
+
const promise = new Promise((nextResolve, nextReject) => {
|
|
15
|
+
resolve = nextResolve
|
|
16
|
+
reject = nextReject
|
|
17
|
+
})
|
|
18
|
+
return { promise, resolve, reject }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A small wrapper around one IndexedDB request.
|
|
22
|
+
// It leaves database creation and schema ownership to the caller.
|
|
23
|
+
// Reuse `p` when advancing a cursor with `cursor.continue()`.
|
|
24
|
+
export async function run (method, args = [], storeName, indexName, {
|
|
25
|
+
db,
|
|
26
|
+
p = deferred(),
|
|
27
|
+
tx,
|
|
28
|
+
txMode = tx?.mode,
|
|
29
|
+
storeOrIndex
|
|
30
|
+
} = {}) {
|
|
31
|
+
if (!tx) {
|
|
32
|
+
if (!db) throw new Error('IDB_DATABASE_REQUIRED')
|
|
33
|
+
if (!storeName) throw new Error('IDB_STORE_REQUIRED')
|
|
34
|
+
// Caller may pre-select it if it wants to use many different methods in a row
|
|
35
|
+
txMode ??= READ_METHODS.has(method) ? 'readonly' : 'readwrite'
|
|
36
|
+
tx = db.transaction([storeName], txMode)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!storeOrIndex) {
|
|
40
|
+
if (!storeName) throw new Error('IDB_STORE_REQUIRED')
|
|
41
|
+
const store = tx.objectStore(storeName)
|
|
42
|
+
storeOrIndex = indexName ? store.index(indexName) : store
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let request
|
|
46
|
+
try {
|
|
47
|
+
request = storeOrIndex[method](...args)
|
|
48
|
+
} catch (err) {
|
|
49
|
+
p.reject(err)
|
|
50
|
+
try { tx.abort() } catch {}
|
|
51
|
+
return p.promise
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
request.onsuccess = () => {
|
|
55
|
+
// don't add p to this object
|
|
56
|
+
p.resolve({ result: request.result, tx, storeOrIndex })
|
|
57
|
+
}
|
|
58
|
+
request.onerror = () => {
|
|
59
|
+
p.reject(request.error || new Error('IDB_REQUEST_FAILED'))
|
|
60
|
+
try { tx.abort() } catch {}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return p.promise
|
|
64
|
+
}
|