wesh.gno
16.08 Kb · 515 lines
1// Package wesh is an on-chain directory and device sigchain for Wesh protocol
2// (weshnet / Berty) identities.
3//
4// It exists to give weshnet the three things a peer-to-peer network cannot
5// give itself, and which a chain is uniquely good at:
6//
7// 1. Resolution. A Berty identity travels out of band as a QR code or a
8// https://berty.tech/id# link, and there is no way to look one up. Here a
9// name resolves to a contact, and Render emits the real, scannable link.
10// 2. Rotation with a paper trail. Resetting the public rendezvous seed
11// silently kills every link ever shared; there is no revocation channel.
12// Here every rotation is a numbered, timestamped, signed entry, and the
13// superseded seeds stay visible, so a stale link resolves to a redirect
14// instead of to nothing.
15// 3. Device revocation. The Wesh protocol documentation states plainly that a
16// device, once linked to an account, can never be revoked: the account
17// metadata log is append-only and no authority can void an entry. This
18// realm hosts the log that was missing: a hash-chained, account-signed
19// roster whose every entry the chain verifies with ed25519 before
20// accepting it.
21//
22// # What this realm does not do, and will not
23//
24// It never stores a secret. Group secrets, device chain keys, message keys and
25// ciphertexts stay off chain, permanently. The library this realm is built on,
26// [p/moul/x/wesh](/p/moul/x/wesh/v0), has no type that can hold one.
27//
28// It also does not pretend to enforce revocation inside weshnet. A revoked
29// device's ratchets are already distributed and forward secrecy is a local
30// property; no chain can reach into a group and forget them. What revocation
31// buys here is that it becomes public, ordered, and attributable to the
32// account key, which is strictly more than weshnet has today.
33//
34// # Publishing is a deliberate act
35//
36// A published rendezvous seed lets anyone derive the account's rotating
37// rendezvous point and watch the DHT for it. That is the same exposure as
38// printing your Berty QR code on a billboard, and it is the right trade only
39// for an identity that wants to be found: a support line, a shop, a public
40// channel.
41//
42// [RegisterCommitted] is the alternative. It publishes H(seed ‖ salt) instead,
43// so the chain attests that a seed handed over out of band really belongs to
44// the named account, without broadcasting where that account listens.
45//
46// # Authentication
47//
48// Every state-changing call carries an ed25519 signature made with the Wesh
49// account private key over a canonical statement that names this chain, the
50// caller's gno address, and a monotonic revision or sequence number. Without
51// it, anyone could publish anyone else's account key next to a seed of their
52// choosing and harvest the contact requests that followed.
53package wesh
54
55import (
56 "chain/runtime"
57 "encoding/hex"
58 "strings"
59 "time"
60
61 "gno.land/p/moul/x/wesh/v0"
62 "gno.land/p/nt/avl/v0"
63)
64
65const (
66 // MinNameLen and MaxNameLen bound a directory handle.
67 MinNameLen = 3
68 MaxNameLen = 32
69
70 // MaxDevices bounds one account's sigchain so iteration and Render stay
71 // affordable. weshnet accounts hold a handful of devices, not hundreds.
72 MaxDevices = 64
73
74 // MaxRotations bounds the retained rotation history. Older entries are
75 // dropped from the front: the point of the history is to let a stale link
76 // be recognised, not to keep a permanent archive.
77 MaxRotations = 32
78)
79
80// rotation is one seed (or commitment) an identity has published.
81type rotation struct {
82 revision int
83 payload []byte
84 height int64
85}
86
87// deviceEntry is one verified sigchain statement.
88type deviceEntry struct {
89 seq int
90 op string
91 devicePK []byte
92 digest []byte
93 height int64
94}
95
96// identity is a published Wesh identity bound to one gno address.
97//
98// payload holds the public rendezvous seed when committed is false, and
99// H(seed ‖ salt) when it is true. head is the digest of the last accepted
100// sigchain entry, which the next entry must chain to.
101type identity struct {
102 name string
103 owner address
104 accountPK []byte
105 payload []byte
106 committed bool
107 revision int
108 displayName string
109 createdAt int64
110 updatedAt int64
111 history []rotation
112 devices []deviceEntry
113 head []byte
114}
115
116var (
117 // byName is the directory, keyed by handle so avl iteration is alphabetical.
118 byName avl.Tree
119 // byOwner maps a gno address to its handle: one identity per address.
120 byOwner avl.Tree
121 // byAccount maps a hex account key to its handle, so two handles can never
122 // claim the same Wesh account.
123 byAccount avl.Tree
124)
125
126// Register publishes an identity whose rendezvous seed is public.
127//
128// sigHex must be an ed25519 signature by the account private key over
129// wesh.BindStatement(chainID, caller, accountPK, seed, 1). Revision 1 is fixed
130// at registration so a signature captured from a later rotation cannot be
131// replayed to re-register the name after it is released.
132func Register(cur realm, name, accountPKHex, seedHex, displayName, sigHex string) {
133 if !cur.IsCurrent() {
134 panic("spoofed realm")
135 }
136 register(cur.Previous().Address(), name, accountPKHex, seedHex, displayName, sigHex, false)
137}
138
139// RegisterCommitted publishes an identity that keeps its rendezvous point off
140// chain: commitmentHex is wesh.SeedCommitment(seed, salt), and the seed itself
141// is shared out of band.
142//
143// The chain still attests the binding (this account key really did claim this
144// commitment from this gno address), so a seed later disclosed privately can
145// be checked against it. What it does not do is tell the world where the
146// account listens.
147func RegisterCommitted(cur realm, name, accountPKHex, commitmentHex, displayName, sigHex string) {
148 if !cur.IsCurrent() {
149 panic("spoofed realm")
150 }
151 register(cur.Previous().Address(), name, accountPKHex, commitmentHex, displayName, sigHex, true)
152}
153
154// register is shared by Register and RegisterCommitted. It takes the already
155// resolved caller rather than a realm: a helper whose first parameter is
156// `realm` would be a second crossing hop, and Previous() inside it would
157// resolve to this realm rather than to the user who called in.
158func register(caller address, name, accountPKHex, payloadHex, displayName, sigHex string, committed bool) {
159 name = normalizeName(name)
160 assertNameAvailable(name)
161 if byOwner.Has(caller.String()) {
162 panic("wesh: this address already owns an identity; release it first")
163 }
164
165 accountPK := mustAccountPK(accountPKHex)
166 if byAccount.Has(accountPKHex) {
167 panic("wesh: this account key is already published under another name")
168 }
169 payload := mustPayload(payloadHex, committed)
170 assertDisplayName(displayName)
171
172 if err := wesh.VerifyBind(runtime.ChainID(), caller.String(), accountPK, payload, 1, mustSig(sigHex)); err != nil {
173 panic("wesh: " + err.Error())
174 }
175
176 now := time.Now().Unix()
177 id := &identity{
178 name: name,
179 owner: caller,
180 accountPK: accountPK,
181 payload: payload,
182 committed: committed,
183 revision: 1,
184 displayName: displayName,
185 createdAt: now,
186 updatedAt: now,
187 history: []rotation{{revision: 1, payload: payload, height: runtime.ChainHeight()}},
188 head: wesh.GenesisDigest(),
189 }
190 byName.Set(name, id)
191 byOwner.Set(caller.String(), name)
192 byAccount.Set(accountPKHex, name)
193}
194
195// Rotate publishes a new seed (or commitment) for the caller's identity.
196//
197// This is the operation weshnet's ContactRequestResetReference has no
198// counterpart for. There, resetting the seed silently invalidates every link
199// ever shared. Here the new value is numbered and the old one stays in the
200// history, so a holder of a stale link can see that it was superseded and when.
201//
202// The signature must be over revision+1, which is what stops a superseded
203// binding being replayed to roll a rotation back.
204func Rotate(cur realm, payloadHex, sigHex string) {
205 if !cur.IsCurrent() {
206 panic("spoofed realm")
207 }
208 id := mustOwnIdentity(cur.Previous().Address())
209
210 payload := mustPayload(payloadHex, id.committed)
211 if string(payload) == string(id.payload) {
212 panic("wesh: the new value is identical to the current one")
213 }
214
215 next := id.revision + 1
216 if err := wesh.VerifyBind(runtime.ChainID(), id.owner.String(), id.accountPK, payload, next, mustSig(sigHex)); err != nil {
217 panic("wesh: " + err.Error())
218 }
219
220 id.revision = next
221 id.payload = payload
222 id.updatedAt = time.Now().Unix()
223 id.history = append(id.history, rotation{revision: next, payload: payload, height: runtime.ChainHeight()})
224 if len(id.history) > MaxRotations {
225 id.history = id.history[len(id.history)-MaxRotations:]
226 }
227}
228
229// AppendDevice adds one entry to the caller's device sigchain.
230//
231// prevHex must be the digest of the current head (all zeros for the first
232// entry), and the signature must cover the whole statement including the
233// sequence number and that digest. Chaining every entry to its predecessor is
234// what stops the log being reordered or having an entry quietly dropped: any
235// gap changes every digest after it.
236//
237// op is "add" or "revoke". Adding a device that is already active, or revoking
238// one that is not, is refused: an append-only log is only useful if its entries
239// are meaningful.
240func AppendDevice(cur realm, prevHex, op, devicePKHex, sigHex string) {
241 if !cur.IsCurrent() {
242 panic("spoofed realm")
243 }
244 id := mustOwnIdentity(cur.Previous().Address())
245
246 if len(id.devices) >= MaxDevices {
247 panic("wesh: device sigchain is full")
248 }
249 prev, err := wesh.DecodeCommitment(prevHex)
250 if err != nil {
251 panic("wesh: previous digest: " + err.Error())
252 }
253 if string(prev) != string(id.head) {
254 panic("wesh: previous digest does not match the sigchain head " + hex.EncodeToString(id.head))
255 }
256 devicePK, err := wesh.DecodeDevicePK(devicePKHex)
257 if err != nil {
258 panic("wesh: device key: " + err.Error())
259 }
260
261 active := deviceIsActive(id, devicePK)
262 switch op {
263 case wesh.OpAdd:
264 if active {
265 panic("wesh: device is already active")
266 }
267 case wesh.OpRevoke:
268 if !active {
269 panic("wesh: device is not active, nothing to revoke")
270 }
271 default:
272 panic("wesh: operation must be add or revoke")
273 }
274
275 seq := len(id.devices)
276 digest, err := wesh.VerifyDevice(runtime.ChainID(), id.accountPK, seq, prev, op, devicePK, mustSig(sigHex))
277 if err != nil {
278 panic("wesh: " + err.Error())
279 }
280
281 id.devices = append(id.devices, deviceEntry{
282 seq: seq,
283 op: op,
284 devicePK: devicePK,
285 digest: digest,
286 height: runtime.ChainHeight(),
287 })
288 id.head = digest
289 id.updatedAt = time.Now().Unix()
290}
291
292// SetDisplayName updates the identity's free-form label.
293//
294// It carries no signature because it authenticates nothing: the display name
295// is app metadata, and berty's own link format keeps it outside the signed
296// payload for the same reason. Only the owning address may change it.
297func SetDisplayName(cur realm, displayName string) {
298 if !cur.IsCurrent() {
299 panic("spoofed realm")
300 }
301 assertDisplayName(displayName)
302 id := mustOwnIdentity(cur.Previous().Address())
303 id.displayName = displayName
304 id.updatedAt = time.Now().Unix()
305}
306
307// Release removes the caller's identity and frees its name and account key.
308//
309// The sigchain goes with it. That is the honest behaviour: a directory entry
310// is a live claim, not an archive, and keeping a dangling roster for a name
311// somebody else can now take would be worse than keeping nothing.
312func Release(cur realm) {
313 if !cur.IsCurrent() {
314 panic("spoofed realm")
315 }
316 caller := cur.Previous().Address()
317 id := mustOwnIdentity(caller)
318
319 byName.Remove(id.name)
320 byOwner.Remove(caller.String())
321 byAccount.Remove(hex.EncodeToString(id.accountPK))
322}
323
324// --- read-only API, for gnoweb and for other realms ---
325
326// Resolve returns the account key and current payload of a name, both hex, and
327// whether the payload is a commitment rather than a seed. ok is false when the
328// name is not registered.
329func Resolve(name string) (accountPKHex, payloadHex string, committed, ok bool) {
330 id := lookup(name)
331 if id == nil {
332 return "", "", false, false
333 }
334 return hex.EncodeToString(id.accountPK), hex.EncodeToString(id.payload), id.committed, true
335}
336
337// Link returns the shareable Berty web link for a name, or "" when the name is
338// unknown or its rendezvous point is committed rather than published.
339func Link(name string) string {
340 id := lookup(name)
341 if id == nil || id.committed {
342 return ""
343 }
344 link, err := contactOf(id).WebLink()
345 if err != nil {
346 return ""
347 }
348 return link
349}
350
351// RendezvousPointAt returns the hex rendezvous point the named account
352// announces on during the rotation period containing unixSec, or "" when the
353// name is unknown or committed.
354//
355// This is what makes a directory entry checkable rather than merely claimed:
356// anyone can compare it against the DHT without trusting this realm.
357func RendezvousPointAt(name string, unixSec int64) string {
358 id := lookup(name)
359 if id == nil || id.committed {
360 return ""
361 }
362 point, err := contactOf(id).RendezvousPointAt(unixSec, wesh.DefaultRotationInterval)
363 if err != nil {
364 return ""
365 }
366 return hex.EncodeToString(point)
367}
368
369// DeviceStatus reports "active", "revoked" or "unknown" for a device under a
370// name. "unknown" also covers an unregistered name: a caller must not be able
371// to tell those apart by status alone.
372func DeviceStatus(name, devicePKHex string) string {
373 id := lookup(name)
374 if id == nil {
375 return "unknown"
376 }
377 devicePK, err := wesh.DecodeDevicePK(devicePKHex)
378 if err != nil {
379 return "unknown"
380 }
381 if !deviceSeen(id, devicePK) {
382 return "unknown"
383 }
384 if deviceIsActive(id, devicePK) {
385 return "active"
386 }
387 return "revoked"
388}
389
390// SigchainHead returns the hex digest the next sigchain entry must chain to,
391// or "" when the name is unknown. A client builds its next statement from this.
392func SigchainHead(name string) string {
393 id := lookup(name)
394 if id == nil {
395 return ""
396 }
397 return hex.EncodeToString(id.head)
398}
399
400// NameOf returns the handle owned by an address, or "".
401func NameOf(owner address) string {
402 v := byOwner.Get(owner.String())
403 if v == nil {
404 return ""
405 }
406 return v.(string)
407}
408
409// Count returns the number of registered identities.
410func Count() int { return byName.Size() }
411
412// --- helpers ---
413
414func lookup(name string) *identity {
415 v := byName.Get(normalizeName(name))
416 if v == nil {
417 return nil
418 }
419 return v.(*identity)
420}
421
422func contactOf(id *identity) wesh.Contact {
423 return wesh.Contact{
424 AccountPK: id.accountPK,
425 Seed: id.payload,
426 DisplayName: id.displayName,
427 }
428}
429
430func deviceIsActive(id *identity, devicePK []byte) bool {
431 active := false
432 for _, e := range id.devices {
433 if string(e.devicePK) != string(devicePK) {
434 continue
435 }
436 active = e.op == wesh.OpAdd
437 }
438 return active
439}
440
441func deviceSeen(id *identity, devicePK []byte) bool {
442 for _, e := range id.devices {
443 if string(e.devicePK) == string(devicePK) {
444 return true
445 }
446 }
447 return false
448}
449
450func mustOwnIdentity(caller address) *identity {
451 v := byOwner.Get(caller.String())
452 if v == nil {
453 panic("wesh: no identity registered for this address")
454 }
455 return lookup(v.(string))
456}
457
458func mustAccountPK(s string) []byte {
459 pk, err := wesh.DecodeAccountPK(s)
460 if err != nil {
461 panic("wesh: account key: " + err.Error())
462 }
463 return pk
464}
465
466func mustPayload(s string, committed bool) []byte {
467 if committed {
468 c, err := wesh.DecodeCommitment(s)
469 if err != nil {
470 panic("wesh: commitment: " + err.Error())
471 }
472 return c
473 }
474 seed, err := wesh.DecodeSeed(s)
475 if err != nil {
476 panic("wesh: seed: " + err.Error())
477 }
478 return seed
479}
480
481func mustSig(s string) []byte {
482 sig, err := hex.DecodeString(s)
483 if err != nil || len(sig) != 64 {
484 panic("wesh: signature must be 64 hex-encoded bytes")
485 }
486 return sig
487}
488
489func assertDisplayName(s string) {
490 if len(s) > wesh.MaxDisplayNameLen {
491 panic("wesh: display name is too long")
492 }
493}
494
495func assertNameAvailable(name string) {
496 if len(name) < MinNameLen || len(name) > MaxNameLen {
497 panic("wesh: name must be between 3 and 32 characters")
498 }
499 for i := 0; i < len(name); i++ {
500 c := name[i]
501 ok := (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_'
502 if !ok {
503 panic("wesh: name may only contain a-z, 0-9, '-' and '_'")
504 }
505 }
506 if byName.Has(name) {
507 panic("wesh: name is already taken")
508 }
509}
510
511// normalizeName lowercases a handle so lookups are case-insensitive and two
512// handles cannot differ only by case.
513func normalizeName(name string) string {
514 return strings.ToLower(strings.TrimSpace(name))
515}