const OpAdd, OpRevoke
Device sigchain operations.
Package wesh implements the publishable, non-secret half of the Wesh protocol (weshnet, the network layer behind Bert...
gno.land/p/moul/x/wesh/v0The publishable half of the Wesh protocol (weshnet, the network behind
Berty) as a pure gno library: Contact, WebLink,
ParseWebLink, RendezvousPoint, SeedCommitment, BindStatement,
DeviceStatement, VerifyBind, VerifyDevice, HMACSHA256.
1import "gno.land/p/moul/x/wesh/v0"
2
3c := wesh.Contact{AccountPK: pk, Seed: seed, DisplayName: "Alice"}
4link, _ := c.WebLink() // https://berty.tech/id#contact/<base58>/name=Alice
5pt, _ := c.RendezvousPointAt(now, wesh.DefaultRotationInterval)
Wesh is off-grid, peer-to-peer and end-to-end encrypted. A public chain is the opposite in nearly every dimension, so this is deliberately not "weshnet on chain". It carries only the parts that need an authenticated, ordered, publicly auditable record, the one thing a peer-to-peer network cannot give itself, and which Wesh is missing in three specific places:
https://berty.tech/id# link, and there is no lookup.ContactRequestResetReference gives the account a
new rendezvous seed, which kills every link ever shared, with no channel to
announce it.| piece | what it is |
|---|---|
Contact |
weshnet's ShareableContact: account key + public rendezvous seed + display name, with weshnet's own CheckFormat length rules |
Blob, WebLink, ParseWebLink |
the Berty link codec, hand-rolled protobuf + base58 |
RendezvousPoint, RoundPeriod, NextPeriod |
weshnet's rotating DHT topic derivation |
SeedCommitment, OpenCommitment |
publish H(seed ‖ salt) instead of the seed |
BindStatement, DeviceStatement, VerifyBind, VerifyDevice |
the canonical texts an account signs, and their ed25519 verification |
HMACSHA256 |
RFC 2104, because gno has sha256.Sum256 but no crypto/hmac |
The link codec is conformance-tested, not guessed: TestBlobEncodingMatches BertyGoldenVector reproduces the exact base58 payload from berty's own
links_test.go, byte for byte. The rendezvous derivation is pinned against
vectors computed with weshnet's GenerateRendezvousPointForPeriod, and the HMAC
against RFC 4231, including case 6, the longer-than-block-size key.
There is no type here that can hold a secret. Group secrets, device chain keys, message keys and ciphertexts never touch a chain: publishing a group secret hands the group to everyone, and publishing ciphertext is permanent, expensive, and leaks the social graph through access patterns.
Publishing a rendezvous seed is equivalent to printing your Berty QR code on a
billboard: anyone can then derive today's rendezvous point and watch the DHT for
it. That is the right trade only for an identity that wants to be found.
SeedCommitment is the alternative: the chain attests the binding, the seed
travels out of band, and the rendezvous point stays private.
WebLink is base58 over a ~70-byte payload, which is big-integer work:
TestWebLink reports ~19M gas for one encode, TestWebLinkRoundTrip ~61M per
encode-plus-decode. That is fine in a Render (a query) and worth avoiding
per-row in a list. Signature verification is a native op and costs far less.
Live realm: r/moul/x/wesh
· render it at /r/moul/x/wesh/v0.
Part of moul/gno-contracts — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage.
Dependency graph:

🧪 Highly experimental — potentially vibe-coded. Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: DISCLAIMER.
Package wesh implements the publishable, non-secret half of the Wesh protocol (weshnet, the network layer behind Berty) as a pure gno library.
Wesh is an off-grid, peer-to-peer, end-to-end-encrypted messaging protocol. A public blockchain is the opposite of that in almost every dimension: globally replicated, permanent, publicly readable and totally ordered. So "put Wesh on chain" is the wrong goal, and this package does not pursue it.
What a chain *is* good at is the one thing Wesh has no answer for: an authenticated, ordered, publicly auditable record that no single party owns. Wesh has three gaps of exactly that shape:
This package carries the pieces needed to address those on chain, and nothing else. Everything here is public by construction:
Group secrets (weshnet's Group.secret, Group.link_key), device chain keys, message keys, ciphertexts. Publishing a group secret hands the group to everyone; publishing ciphertext is permanent, expensive, and leaks the social graph through access patterns. This package has no type that can hold one, which is the point: there is no struct field here for a secret to accidentally land in.
Publishing a rendezvous seed is equivalent to printing your Berty QR code on a billboard. Anyone can then derive today's rendezvous point with RendezvousPoint and watch the DHT for who shows up. That is a real deanonymization surface, and it is why publication must be an explicit, opt-in act for an identity that *wants* to be found: a support line, a shop, a DAO's public channel.
For an identity that does not want that, SeedCommitment publishes H(seed ‖ salt) instead. The chain then proves that a seed handed over out of band really does belong to the named account, without broadcasting the rendezvous point to the world.
Live realm using this library: r/moul/x/wesh(/r/moul/x/wesh/v0).
Device sigchain operations.
1const (
2 // SeedLen is the length of a public rendezvous seed. Matches weshnet's
3 // protocoltypes.RendezvousSeedLength.
4 SeedLen = 32
5
6 // AccountPKLen is the length of an account public key. weshnet validates
7 // it with libp2p's UnmarshalEd25519PublicKey, which accepts raw 32-byte
8 // ed25519 public keys only.
9 AccountPKLen = 32
10
11 // DevicePKLen is the length of a device public key: also a raw ed25519
12 // public key.
13 DevicePKLen = 32
14
15 // MaxDisplayNameLen bounds the display name so a link stays scannable and
16 // gas stays predictable. Not a protocol constant.
17 MaxDisplayNameLen = 64
18)DefaultRotationInterval is weshnet's rendezvous rotation period, in seconds (rendezvous.DefaultRotationInterval = 24h). The rendezvous point an account announces on changes once per interval, so an observer who learns one point does not learn every future one.
DigestLen is the length of a statement digest (SHA-256).
WebLinkPrefix is the prefix of a shareable Berty web link. The fragment marker is part of it on purpose: everything after '#' stays in the browser and is never sent to berty.tech, so the web host never learns the identity being shared.
1var (
2 ErrBadOp = errors.New("wesh: operation must be add or revoke")
3 ErrBadSignature = errors.New("wesh: signature does not verify under the account key")
4 ErrBadSeq = errors.New("wesh: sequence number must not be negative")
5 ErrBadPrevLen = errors.New("wesh: previous digest must be 32 bytes")
6)1var (
2 ErrBadSeedLen = errors.New("wesh: public rendezvous seed must be 32 bytes")
3 ErrBadAccountPKLen = errors.New("wesh: account public key must be a raw 32-byte ed25519 key")
4 ErrBadDevicePKLen = errors.New("wesh: device public key must be a raw 32-byte ed25519 key")
5 ErrDisplayNameTooLong = errors.New("wesh: display name exceeds MaxDisplayNameLen")
6 ErrBadCommitmentLen = errors.New("wesh: seed commitment must be 32 bytes")
7 ErrBadHex = errors.New("wesh: value is not valid hex")
8)BindStatement returns the canonical text an account signs, with its Wesh account private key, to claim a directory entry.
The signature is what makes a directory entry meaningful. Without it anyone could publish anyone else's account key next to a seed of their choosing and harvest the contact requests that followed. With it, a directory entry is a statement by the account itself, verifiable by anyone, replayable nowhere:
payload is the seed for a publicly published identity, or the seed commitment from SeedCommitment for one that keeps its rendezvous point off chain. The two are indistinguishable on the wire, which is deliberate: an observer cannot tell a committed identity from a published one without the realm's own mode flag.
Fields are newline-framed and hex-encoded, never concatenated raw: with plain concatenation a different split of the same bytes would produce the same statement.
DecodeAccountPK parses a hex-encoded account public key.
DecodeCommitment parses a hex-encoded seed commitment.
DecodeDevicePK parses a hex-encoded device public key.
DecodeSeed parses a hex-encoded public rendezvous seed.
1func DeviceStatement(chainID string, accountPK []byte, seq int, prev []byte, op string, devicePK []byte) stringDeviceStatement returns the canonical text an account signs to append one entry to its device sigchain.
This is the piece the Wesh protocol has no answer for. Its own documentation says a device, once linked, can never be revoked: the account metadata log is append-only and no authority can mark an entry void. A chain cannot undo that (the ratchets are already out there and forward secrecy is a local property), but it can host the record that was missing, so a revocation becomes publicly visible, ordered, and attributable to the account key.
prev is the digest of the preceding statement, or GenesisDigest for seq 0. Chaining each entry to its predecessor means the log cannot be reordered or have an entry quietly dropped: any gap changes every digest after it.
GenesisDigest is the all-zero digest that the first sigchain entry chains to.
HMACSHA256 computes HMAC-SHA256 as specified in RFC 2104.
gno's standard library exposes sha256.Sum256 but no streaming hash.Hash and no crypto/hmac, so the construction is spelled out here:
1HMAC(K, m) = H((K' ⊕ opad) ‖ H((K' ⊕ ipad) ‖ m))
with K' the key hashed down when longer than the block size, then zero-padded up to it. This is the primitive weshnet's rendezvous point derivation is built on, so it has to agree byte-for-byte; see RendezvousPoint.
NextPeriod returns the start of the period after the one containing unixSec. Mirrors weshnet's rendezvous.NextTimePeriod.
OpenCommitment reports whether commitment is H(seed ‖ salt).
The comparison is constant-time over the digest: a short-circuiting compare leaks, through gas, how many leading bytes of a guess were right.
RendezvousPoint derives the rotating DHT topic an account announces on during the period starting at periodStart.
It reproduces weshnet's rendezvous.GenerateRendezvousPointForPeriod exactly:
1HMAC-SHA256(key = topic ‖ seed, msg = big-endian uint64(periodStart))
For contact requests, weshnet passes the account public key as the topic and the public rendezvous seed as the seed (see swiper.WatchTopic in contact_request_manager.go), which is what Contact.RendezvousPointAt does.
Deriving this on chain is what makes a published identity checkable: anyone can confirm that the seed in the directory really is the one the account is announcing under, without trusting the directory.
RoundPeriod returns the start of the rotation period containing unixSec. Mirrors weshnet's rendezvous.RoundTimePeriod.
SeedCommitment returns H(seed ‖ salt), the value published instead of the seed itself when an account wants the chain to attest the binding without broadcasting its rendezvous point.
The salt is not optional: a seed is 32 bytes of entropy, but a commitment with no salt is a deterministic function of the seed, so two accounts that (impossibly, but still) shared a seed would be linkable, and a seed later disclosed out of band retroactively confirms every past commitment. The salt keeps disclosure a deliberate act.
StatementDigest returns SHA-256 of a canonical statement. It is the value the next sigchain entry chains to.
ValidOp reports whether op is a known sigchain operation.
1func VerifyBind(chainID, gnoAddr string, accountPK, payload []byte, revision int, sig []byte) errorVerifyBind checks a binding signature against the account key.
1func VerifyDevice(chainID string, accountPK []byte, seq int, prev []byte, op string, devicePK, sig []byte) ([]byte, error)VerifyDevice checks a sigchain entry's shape and its signature, and returns the digest the next entry must chain to.
ParseWebLink decodes a Berty contact web link back into a Contact.
It accepts links with or without a trailing query segment, and ignores query keys other than "name", matching berty's own UnmarshalLink leniency.
Contact is the publishable identity of a Wesh account: weshnet's ShareableContact. It is everything a stranger needs to send a contact request, and it contains no secret.
AccountPK is the raw ed25519 account public key. Seed is the public rendezvous seed; together they derive the rotating rendezvous point the account announces on, see RendezvousPoint. DisplayName is free-form app metadata and is not authenticated by anything.
Blob returns the protobuf-encoded machine payload of a contact invite link: a BertyLink carrying only a BertyID with the rendezvous seed and account key.
The kind and the display name are deliberately absent. berty's MarshalLink puts the kind in the human-readable path segment and the display name in the query string, so a blob that carried them would not match a link produced by a real Berty client. The encoding here is verified against berty's own golden test vector; see link_test.gno.
RendezvousPointAt returns the contact's rendezvous point for the rotation period containing unixSec.
Validate applies weshnet's own ShareableContact.CheckFormat rules: the seed must be exactly SeedLen bytes, and the account key must be a raw ed25519 public key. It deliberately does not verify that AccountPK is a valid curve point; that costs a scalar multiplication and the chain gets the same guarantee for free the first time it verifies a signature under the key.
WebLink returns the shareable https://berty.tech/id# link for the contact. Scanning or opening it in Berty starts a contact request.