link.gno
5.51 Kb · 194 lines
1package wesh
2
3import (
4 "encoding/binary"
5 "errors"
6 "net/url"
7 "strings"
8
9 "gno.land/p/moul/x/daily/b58/v0"
10)
11
12// WebLinkPrefix is the prefix of a shareable Berty web link. The fragment
13// marker is part of it on purpose: everything after '#' stays in the browser
14// and is never sent to berty.tech, so the web host never learns the identity
15// being shared.
16const WebLinkPrefix = "https://berty.tech/id#"
17
18// contactKind is the human-readable link kind for a contact invite,
19// berty's BertyLink_ContactInviteV1Kind.
20const contactKind = "contact"
21
22// Protobuf field numbers, from berty's api/messengertypes/messengertypes.proto
23// and weshnet's api/protocol/protocoltypes.proto. They are wire-format
24// constants: changing one silently produces a link no Berty client can read.
25const (
26 fieldBertyLinkBertyID = 2 // BertyLink.berty_id
27 fieldBertyIDSeed = 1 // BertyID.public_rendezvous_seed
28 fieldBertyIDAccountPK = 2 // BertyID.account_pk
29)
30
31var (
32 ErrNotAWebLink = errors.New("wesh: not a berty web link")
33 ErrNotAContactLink = errors.New("wesh: link is not a contact invite")
34 ErrBadLinkPayload = errors.New("wesh: link payload is not a valid contact")
35)
36
37// Blob returns the protobuf-encoded machine payload of a contact invite link:
38// a BertyLink carrying only a BertyID with the rendezvous seed and account key.
39//
40// The kind and the display name are deliberately absent. berty's MarshalLink
41// puts the kind in the human-readable path segment and the display name in the
42// query string, so a blob that carried them would not match a link produced by
43// a real Berty client. The encoding here is verified against berty's own
44// golden test vector; see link_test.gno.
45func (c Contact) Blob() ([]byte, error) {
46 if err := c.Validate(); err != nil {
47 return nil, err
48 }
49 id := appendBytesField(nil, fieldBertyIDSeed, c.Seed)
50 id = appendBytesField(id, fieldBertyIDAccountPK, c.AccountPK)
51 return appendBytesField(nil, fieldBertyLinkBertyID, id), nil
52}
53
54// WebLink returns the shareable https://berty.tech/id# link for the contact.
55// Scanning or opening it in Berty starts a contact request.
56func (c Contact) WebLink() (string, error) {
57 blob, err := c.Blob()
58 if err != nil {
59 return "", err
60 }
61 link := WebLinkPrefix + contactKind + "/" + b58.Encode(blob)
62 if c.DisplayName != "" {
63 link += "/name=" + url.QueryEscape(c.DisplayName)
64 }
65 return link, nil
66}
67
68// ParseWebLink decodes a Berty contact web link back into a [Contact].
69//
70// It accepts links with or without a trailing query segment, and ignores query
71// keys other than "name", matching berty's own UnmarshalLink leniency.
72func ParseWebLink(link string) (Contact, error) {
73 var c Contact
74
75 if !strings.HasPrefix(link, WebLinkPrefix) {
76 return c, ErrNotAWebLink
77 }
78 parts := strings.Split(strings.TrimPrefix(link, WebLinkPrefix), "/")
79 if len(parts) < 2 {
80 return c, ErrNotAWebLink
81 }
82 if parts[0] != contactKind {
83 return c, ErrNotAContactLink
84 }
85 if !b58.IsValid(parts[1]) {
86 return c, ErrBadLinkPayload
87 }
88
89 inner, ok := lookupBytesField(b58.Decode(parts[1]), fieldBertyLinkBertyID)
90 if !ok {
91 return c, ErrBadLinkPayload
92 }
93 seed, ok := lookupBytesField(inner, fieldBertyIDSeed)
94 if !ok {
95 return c, ErrBadLinkPayload
96 }
97 pk, ok := lookupBytesField(inner, fieldBertyIDAccountPK)
98 if !ok {
99 return c, ErrBadLinkPayload
100 }
101 c.AccountPK = pk
102 c.Seed = seed
103
104 if len(parts) > 2 {
105 c.DisplayName = queryName(parts[2])
106 }
107 if err := c.Validate(); err != nil {
108 return Contact{}, err
109 }
110 return c, nil
111}
112
113// queryName pulls the "name" key out of an encoded query segment, returning ""
114// when it is absent or malformed. A bad display name must never fail a link
115// that is otherwise valid: the name is decoration, the keys are the payload.
116func queryName(q string) string {
117 for _, pair := range strings.Split(q, "&") {
118 if !strings.HasPrefix(pair, "name=") {
119 continue
120 }
121 name, err := url.QueryUnescape(strings.TrimPrefix(pair, "name="))
122 if err != nil || len(name) > MaxDisplayNameLen {
123 return ""
124 }
125 return name
126 }
127 return ""
128}
129
130// appendBytesField appends a length-delimited (wire type 2) protobuf field.
131// An empty value encodes to nothing, which is what proto3 does for a zero
132// bytes field, and is why an invalid contact can never produce a short-but-
133// plausible blob: Validate has already rejected it.
134func appendBytesField(dst []byte, num int, val []byte) []byte {
135 if len(val) == 0 {
136 return dst
137 }
138 dst = append(dst, byte(num<<3|2))
139 dst = binary.AppendUvarint(dst, uint64(len(val)))
140 return append(dst, val...)
141}
142
143// lookupBytesField scans a protobuf message for the first length-delimited
144// field with the given number, skipping every other field whatever its wire
145// type. Unknown fields are skipped rather than rejected so a link produced by
146// a newer Berty client still parses.
147func lookupBytesField(buf []byte, num int) ([]byte, bool) {
148 i := 0
149 for i < len(buf) {
150 tag, n := binary.Uvarint(buf[i:])
151 if n <= 0 {
152 return nil, false
153 }
154 i += n
155 fieldNum := int(tag >> 3)
156 wireType := int(tag & 0x7)
157
158 switch wireType {
159 case 0: // varint
160 _, n := binary.Uvarint(buf[i:])
161 if n <= 0 {
162 return nil, false
163 }
164 i += n
165 case 1: // 64-bit
166 if i+8 > len(buf) {
167 return nil, false
168 }
169 i += 8
170 case 2: // length-delimited
171 l, n := binary.Uvarint(buf[i:])
172 if n <= 0 {
173 return nil, false
174 }
175 i += n
176 end := i + int(l)
177 if end < i || end > len(buf) {
178 return nil, false
179 }
180 if fieldNum == num {
181 return buf[i:end], true
182 }
183 i = end
184 case 5: // 32-bit
185 if i+4 > len(buf) {
186 return nil, false
187 }
188 i += 4
189 default:
190 return nil, false
191 }
192 }
193 return nil, false
194}