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
177
178
179
180
181
182
183
184
185
186
187
188
189
190 | (ns ird.p2p.db.crypto
"The key schedule and the sealed record of one database (README, \"Key
schedule and AAD\"). Everything derives from the database secret with the
same HKDF-SHA256 the sibling ird-p2p-cljs uses for rooms — ikm = the
secret bytes, salt = UTF8(appSalt), info = a label — so one secret under
two app salts yields two unrelated databases:
K_db = HKDF(\"db|<name>\") AES-256-GCM key for every record
identity seed = HKDF(\"db|<name>|identity\") see ird.p2p.db.identity
signing seed = HKDF(\"db|<name>|identity|sign\") see ird.p2p.db.identity
manifest name = b64url(HKDF(\"db|<name>|manifest\")) the name OrbitDB's manifest carries,
so the human name never reaches the network
A record is the sibling's at-rest envelope, `0x02 ‖ iv(12) ‖ ciphertext ‖ tag`
as standard base64, sealed under K_db with a fresh random 12-byte IV and
the AAD `db1|<name>|log`; the plaintext is the payload map's ordered-JSON
text (key order = wire order). This is this library's own local
convention: nothing here interoperates with the Swift SDK, which has no
database envelope.
Opening never throws and never distinguishes tampering from \"not for
me\": every failure is nil and the caller drops the record silently.
Values at this surface are strings and plain data: the secret and the key
as URL-safe base64, IVs and envelopes as standard base64. Derivations are
asynchronous (WebCrypto) and deliver on a promise-chan."
(:require [clojure.string :as str]
[cljs.core.async :as async :refer [go <!]]
[ird.p2p.async :as a :refer [go-try <?]]
[ird.p2p.crypto :as p2p-crypto]
[ird.p2p.db.errors :as err]
[ird.p2p.envelope :as envelope]
[ird.p2p.impl.bytes :as bytes]
[ird.p2p.impl.json :as json]))
;; ---- labels ------------------------------------------------------------------------
(defn key-info
"HKDF info of K_db: `db|<name>`."
[name]
(str "db|" name))
(defn identity-info
"HKDF info of the identity seed: `db|<name>|identity`."
[name]
(str "db|" name "|identity"))
(defn signing-info
"HKDF info of the entry-signing seed: `db|<name>|identity|sign`."
[name]
(str "db|" name "|identity|sign"))
(defn manifest-info
"HKDF info of the OrbitDB manifest name: `db|<name>|manifest`."
[name]
(str "db|" name "|manifest"))
(defn log-aad
"The AAD every record of the database is sealed with: `db1|<name>|log`.
It binds the ciphertext to this database name and to the log (as opposed
to any future kind of record), under version tag `db1`."
[name]
(str "db1|" name "|log"))
(def prefix
"First byte of a sealed record: the sibling's at-rest envelope prefix (0x02)."
envelope/prefix-at-rest)
(def max-payload-bytes
"Largest serialized payload (UTF-8 bytes of its JSON text) one record may
carry: 256 KiB. Records travel whole over gossipsub and bitswap; OrbitDB
entries are not a blob store."
(* 256 1024))
(def max-name-length 128)
;; ---- names and secrets ------------------------------------------------------------------
(defn valid-name?
"A database name: a non-blank string of at most 128 characters without
`|` (the separator of every label above, so names can never collide
with a label suffix)."
[name]
(and (string? name)
(not (str/blank? name))
(<= (count name) max-name-length)
(not (str/includes? name "|"))))
(defn check-name
"Return NAME, or throw a config error when it is not a valid database name."
[name]
(when-not (valid-name? name)
(throw (err/config-error "database name must be a non-blank string of at most 128 characters without '|'" {:name name})))
name)
(def valid-secret?
"`(valid-secret? secret)`: URL-safe base64 of at least 16 bytes, as for rooms."
p2p-crypto/valid-secret?)
(defn check-secret
"Return SECRET, or throw a config error when it is not a valid secret
(URL-safe base64 of at least 16 bytes, as for rooms)."
[secret]
(cond
(not (bytes/valid-b64url? secret))
(throw (err/config-error "database secret is not URL-safe base64" {:secret-length (count (str secret))}))
(< (p2p-crypto/secret-bytes secret) 16)
(throw (err/config-error "database secret too short" {:bytes (p2p-crypto/secret-bytes secret)}))
:else secret))
(def random-secret
"`(random-secret)`: 32 fresh random bytes as URL-safe base64."
p2p-crypto/random-secret)
;; ---- derivations -------------------------------------------------------------------------
(defn db-key
"K_db = HKDF(\"db|<name>\"): 32 bytes as URL-safe base64. Channel."
[secret app-salt name]
(p2p-crypto/hkdf secret app-salt (key-info name)))
(defn identity-seed
"HKDF(\"db|<name>|identity\"): the Ed25519 seed of the shared OrbitDB
identity key, URL-safe base64. Channel."
[secret app-salt name]
(p2p-crypto/hkdf secret app-salt (identity-info name)))
(defn signing-seed
"HKDF(\"db|<name>|identity|sign\"): the Ed25519 seed of the key that signs
entries, URL-safe base64. Channel."
[secret app-salt name]
(p2p-crypto/hkdf secret app-salt (signing-info name)))
(defn manifest-name
"`b64url(HKDF(\"db|<name>|manifest\"))`: the opaque name the OrbitDB
manifest carries (43 characters). Peers of the swarm can read manifests,
so the human name stays out of them. Channel."
[secret app-salt name]
(p2p-crypto/hkdf secret app-salt (manifest-info name)))
(defn db-crypto
"The crypto context of one database: {:secret :app-salt :name :key :aad
:manifest-name}. Channel of the context or a config error (bad secret,
salt or name)."
[{:keys [secret app-salt name]}]
(go-try
(check-secret secret)
(when-not (string? app-salt)
(throw (err/config-error "app salt must be a string" {:app-salt app-salt})))
(check-name name)
(let [k (<! (db-key secret app-salt name))
mn (<! (manifest-name secret app-salt name))]
(when-not (and k mn) (throw (err/config-error "key derivation failed" {})))
{:secret secret :app-salt app-salt :name name :key k :aad (log-aad name) :manifest-name mn})))
;; ---- records --------------------------------------------------------------------------------
(defn serialize
"The plaintext of PAYLOAD: its ordered-JSON text as standard base64."
[payload]
(bytes/utf8->b64 (json/stringify payload)))
(defn seal
"Seal PAYLOAD (a map) for the database: `0x02‖iv‖ct‖tag` as standard
base64 under K_db with the log AAD; the IV is random unless given (tests
only). Channel of the envelope, or an error value: payload-too-large, or a
config error when the payload is not a map."
([dc payload] (seal dc payload (p2p-crypto/random-iv)))
([{:keys [key aad]} payload iv-b64]
(go
(if-not (map? payload)
(err/config-error "payload must be a map" {:payload-type (type payload)})
(let [pt (serialize payload)
n (bytes/byte-length pt)]
(if (> n max-payload-bytes)
(err/payload-too-large n max-payload-bytes)
(if-let [ct (<! (p2p-crypto/aes-seal key iv-b64 aad pt))]
(bytes/concat-b64 (bytes/bytes->b64 [prefix]) iv-b64 ct)
(err/config-error "sealing failed (malformed key or IV)" {}))))))))
(defn open
"The payload map of a sealed record, or nil on any failure — wrong
prefix, wrong key, wrong AAD, tampering, or a plaintext that is not a JSON
object. Never throws. Channel."
[{:keys [key aad]} env-b64]
(go
(when-let [[iv ct] (envelope/split-binary env-b64 prefix)]
(when-let [pt (<! (p2p-crypto/aes-open key iv aad ct))]
(when-let [text (bytes/b64->utf8 pt)]
(let [v (json/parse text)]
(when (map? v) v)))))))
|