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

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}