1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176 | ;; ported-from: src/pair.ts @ v1.2.0
;;
;; Pairwise + self channels: the private lanes of the social layer.
;;
;; Per friendship, both sides derive the same channel with static-static DH —
;; no messages needed:
;; ss = X25519(myXpriv, friendXpub) (suite X keys, id-kit)
;; channelIkm = HKDF(ss, SOCIAL_SALT, "friend-pair|v1|<minPub>|<maxPub>")
;; The self channel (my own devices) derives from the identity seed instead:
;; channelIkm = HKDF(seed, SOCIAL_SALT, "self-room|v1")
;;
;; From channelIkm, everything else:
;; topic gossipsub topic ("soc/1/…") for live beacons + envelopes
;; mailboxRoomId offline drop (its own derivation — the node can't correlate)
;; sender tags keyed aliases; the wire never carries an identity pub
;; per-sender AES-GCM keys with AAD binding channel + sender (random IVs:
;; one identity may publish from several devices at once, so counter IVs
;; are unsafe; beacon rates make the birthday bound comfortable)
;;
;; Outsiders can't link a topic to anyone (HKDF of a DH secret); unfriending
;; is "stop joining". Honest limit: an EX-friend keeps the DH secret forever —
;; blocking stops processing, not derivability.
;;
;; A channel is a JS object with function fields, not a Clojure record: it is
;; handed straight to app code and to the presence agent, and `seal`/`open`
;; are `Promise`-returning closures the .d.ts declares.
(ns ardegazu.social.pair
(:require ["@noble/curves/ed25519" :refer (x25519)]
[ardegazu.id.crypto :as id-crypto]
[ardegazu.social.canon :as canon]
[ardegazu.social.consts :as consts])
(:require-macros [ardegazu.social.macros :refer [awaits obj oget]]))
(def ^:private td (js/TextDecoder.))
(defn- derive-256
"HKDF-SHA256 -> 32 raw bytes under the social salt."
[ikm info]
(awaits [k (js/crypto.subtle.importKey "raw" ikm "HKDF" false #js ["deriveBits"])
bits (js/crypto.subtle.deriveBits
(obj "name" "HKDF"
"hash" "SHA-256"
"salt" (id-crypto/utf8 consts/SOCIAL-SALT)
"info" (id-crypto/utf8 info))
k 256)]
(js/Uint8Array. bits)))
(defn- wire-shape?
"The sealed channel wire: {v:1, s:<tag>, iv:<b64>, ct:<b64>}. Standard
base64, not b64url — pinned by the sealed pair vectors."
[msg]
(and (some? msg)
(identical? 1 (oget msg "v"))
(string? (oget msg "s"))
(string? (oget msg "iv"))
(string? (oget msg "ct"))))
(defn- open-as
"Decrypt one wire message as though `pub` sent it. nil when the bytes do not
authenticate: the right tag with the wrong bytes is a tampered message, and
the discipline is to drop it silently."
[msg pub chan-id key-for]
(-> (awaits [_ (js/Promise.resolve nil)
k (key-for pub)
pt (js/crypto.subtle.decrypt
(obj "name" "AES-GCM"
"iv" (id-crypto/from-b64 (oget msg "iv"))
"additionalData" (id-crypto/utf8 (str "soc|v1|" chan-id "|" pub)))
k
(id-crypto/from-b64 (oget msg "ct")))]
(obj "from" pub
"payload" (js/JSON.parse (.decode td (js/Uint8Array. pt)))))
(.catch (fn [_] nil))))
(defn- open-loop
"Walk the member list sequentially — first tag match decides, and a message
no member's tag matches is simply not for us."
[pubs i msg chan-id key-for tag-of]
(if (>= i (.-length ^js pubs))
(js/Promise.resolve nil)
(let [pub (aget pubs i)]
(awaits [tag (tag-of pub)]
(if (identical? tag (oget msg "s"))
(open-as msg pub chan-id key-for)
(open-loop pubs (inc i) msg chan-id key-for tag-of))))))
(defn- memoizing
"A promise cache keyed by member pub. The derivations are pure in the pub, so
one derivation per member per channel is the whole point — a beacon round
touches every friend."
[f]
(let [cache (js/Map.)]
(fn [pub]
(or (.get cache pub)
(let [v (f pub)]
(.set cache pub v)
v)))))
(defn- make-channel [ikm-raw kind]
;; one indirection so the DH secret / seed never doubles as key material
(awaits [ikm (derive-256 ikm-raw (str kind "|ikm"))
chan-id (canon/hkdf-id ikm consts/SOCIAL-SALT (str kind "|id"))
topic-id (canon/hkdf-id ikm consts/SOCIAL-SALT (str kind "|topic"))
mailbox-room-id (canon/hkdf-id ikm consts/SOCIAL-SALT (str kind "|mailbox"))]
(let [key-for (memoizing
(fn [pub]
(canon/hkdf-aes-key ikm consts/SOCIAL-SALT (str kind "|msg|" pub))))
tag-of (memoizing
(fn [pub]
(awaits [s (canon/hkdf-id ikm consts/SOCIAL-SALT (str kind "|tag|" pub))]
(.slice s 0 16))))
seal (fn [my-pub payload]
(awaits [_ (js/Promise.resolve nil)]
(let [iv (id-crypto/random-bytes 12)]
(awaits [k (key-for my-pub)
ct (js/crypto.subtle.encrypt
(obj "name" "AES-GCM"
"iv" iv
"additionalData" (id-crypto/utf8 (str "soc|v1|" chan-id "|" my-pub)))
k
(id-crypto/utf8 (js/JSON.stringify payload)))
tag (tag-of my-pub)]
(js/JSON.stringify
(obj "v" 1
"s" tag
"iv" (id-crypto/to-b64 iv)
"ct" (id-crypto/to-b64 (js/Uint8Array. ct))))))))
open (fn [wire member-pubs]
(awaits [_ (js/Promise.resolve nil)]
(let [msg (try (js/JSON.parse wire) (catch :default _ nil))]
(if (wire-shape? msg)
(open-loop member-pubs 0 msg chan-id key-for tag-of)
nil))))]
(obj "id" chan-id
"topic" (str "soc/1/" topic-id)
"mailboxRoomId" mailbox-room-id
"ctx" (str kind "|v1|" chan-id)
"tagOf" tag-of
"seal" seal
"open" open))))
(defn pair-channel
"The channel I share with one friend (order-independent)."
[my-x my-pub friend-pub friend-x-pub]
(awaits [_ (js/Promise.resolve nil)]
(let [ss (.getSharedSecret ^js x25519 (oget my-x "priv") (id-crypto/from-b64url friend-x-pub))
;; sorted so both sides feed HKDF the same info string
sorted (.sort #js [my-pub friend-pub])
info (str "friend-pair|v1|" (aget sorted 0) "|" (aget sorted 1))]
(awaits [ikm (derive-256 ss info)]
(make-channel ikm "pair")))))
(defn self-channel
"The channel all devices of MY identity share (from the seed)."
[seed-b64url]
(awaits [_ (js/Promise.resolve nil)
ikm (derive-256 (id-crypto/from-b64url seed-b64url) "self-room|v1")]
(make-channel ikm "self")))
(defn inbox-room-id
"The identity-addressed inbox: anyone can derive it from a public idPub and
deposit sealed envelopes; only the key holder can READ them (sealing), and
— honestly — anyone can also poll it and watch envelope count/timing. The
wrap ctx for inbox envelopes is envCtx(toIdPub)."
[id-pub]
(awaits [_ (js/Promise.resolve nil)]
(canon/hkdf-id (id-crypto/from-b64url id-pub) consts/SOCIAL-SALT "inbox|mailbox|v1")))
(defn env-ctx
"Envelope wrap context: RECIPIENT-bound, not transport-bound, so the same
envelope (same id) can ride the inbox, a pair drop and the live topic and
dedup by id stays uniform. Binding the recipient's identity prevents
cross-recipient replay; same-recipient replay is exactly what the seen-ring
absorbs."
[to-id-pub]
(str "to|v1|" to-id-pub))
|