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.
Files changed (63) hide show
  1. package/AGENTS.md +20 -0
  2. package/README.md +129 -0
  3. package/base16/index.js +14 -0
  4. package/base64/index.js +29 -0
  5. package/content-key/event/index.js +85 -0
  6. package/content-key/index.js +1 -0
  7. package/content-key/services/iykc-proof.js +129 -0
  8. package/double-dh/index.js +166 -0
  9. package/ecdh/index.js +8 -0
  10. package/idb/index.js +64 -0
  11. package/idb-queue/index.js +793 -0
  12. package/index.js +21 -0
  13. package/key/index.js +139 -0
  14. package/network/index.js +68 -0
  15. package/nip44-v3/index.js +175 -0
  16. package/nip46/constants/index.js +5 -0
  17. package/nip46/helpers/frame.js +51 -0
  18. package/nip46/helpers/url.js +96 -0
  19. package/nip46/index.js +5 -0
  20. package/nip46/services/bunker-signer.js +49 -0
  21. package/nip46/services/client.js +252 -0
  22. package/nip46/services/server-session.js +225 -0
  23. package/nip46/services/transport.js +265 -0
  24. package/package.json +49 -0
  25. package/private-channel/constants/index.js +5 -0
  26. package/private-channel/helpers/chunk-size.js +61 -0
  27. package/private-channel/helpers/chunks.js +282 -0
  28. package/private-channel/helpers/event.js +68 -0
  29. package/private-channel/index.js +942 -0
  30. package/private-channel/services/received-chunks.js +398 -0
  31. package/private-message/index.js +672 -0
  32. package/private-messenger/constants/index.js +1 -0
  33. package/private-messenger/index.js +1431 -0
  34. package/private-messenger/recovery/index.js +316 -0
  35. package/relay/constants/index.js +18 -0
  36. package/relay/helpers/hll.js +62 -0
  37. package/relay/helpers/publish.js +100 -0
  38. package/relay/helpers/routing.js +36 -0
  39. package/relay/helpers/timer.js +4 -0
  40. package/relay/index.js +4 -0
  41. package/relay/services/query.js +224 -0
  42. package/relay/services/relay-connection.js +156 -0
  43. package/relay/services/relay-pool.js +908 -0
  44. package/temporary-storage/index.js +85 -0
  45. package/tests/base16.test.js +19 -0
  46. package/tests/base64.test.js +18 -0
  47. package/tests/content-key-event.test.js +48 -0
  48. package/tests/fixtures/nip44v3-vectors.json +1 -0
  49. package/tests/helpers/test-signer.js +185 -0
  50. package/tests/idb-queue.test.js +153 -0
  51. package/tests/idb.test.js +91 -0
  52. package/tests/nip44-v3.test.js +73 -0
  53. package/tests/nip46.test.js +668 -0
  54. package/tests/private-channel.test.js +1899 -0
  55. package/tests/private-message.test.js +460 -0
  56. package/tests/private-messenger.test.js +1715 -0
  57. package/tests/queries.test.js +101 -0
  58. package/tests/queue-parity.test.js +105 -0
  59. package/tests/relay-hll.test.js +32 -0
  60. package/tests/relay-pool.test.js +2067 -0
  61. package/tests/temporary-storage.test.js +89 -0
  62. package/tests/web-storage-queue.test.js +480 -0
  63. 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.
@@ -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
@@ -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
+ }