Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

wesh.gno

16.10 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/nym-moultest123/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/nym-moultest123/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}