verify.gno
10.51 Kb · 290 lines
1package catalog
2
3import (
4 "chain/runtime"
5 "crypto/ed25519"
6 "encoding/hex"
7 "strconv"
8
9 "gno.land/p/nym-alexiscolin000/gnoradio/role/v0"
10 "gno.land/p/nym-alexiscolin000/gnoradio/store/v0"
11 "gno.land/p/nym-alexiscolin000/gnoradio/text/v0"
12 "gno.land/r/nym-alexiscolin000/gnoradio/data"
13)
14
15// Automatic verification, paid by the artist. The artist puts a proof line in
16// a place only they control (their Audius bio, or /.well-known/gnoradio.txt on
17// their own website); the GnoRadio robot reads it and, off-chain and for free,
18// signs a certificate (Ed25519, ClaimMessage) valid for a short while. The
19// artist submits it with Claim from their own wallet; after claimDelay anyone
20// can finalize: the profile goes to that wallet and turns Verified, which
21// opens tips and paid tickets. The robot key signs nothing else; the admin can
22// revoke it, cancel a pending claim and undo a wrong one. Changing or revoking
23// the key voids every claim it started that is still pending, so a stolen key
24// is undone in one call; there is no daily cap that one person could use up
25// for everyone.
26
27const (
28 claimDelay = 72 * 3600 // seconds a claim stays public before it counts
29 certLife = 2 * 3600 // longest validity of a robot certificate, seconds
30)
31
32type claim struct {
33 To address
34 Proof string
35 At int64
36 Gen int // the robot key's generation when the claim was made
37}
38
39// ClaimMessage is what the robot signs: the chain, the GnoRadio deployment
40// (its data realm, so a staging deployment on the same chain cannot reuse a
41// certificate), the profile, the wallet, where the proof was found and until
42// when the certificate holds.
43func ClaimMessage(artistID int, to address, proof string, expires int64) string {
44 return "gnoradio-claim|" + runtime.ChainID() + "|" + dataPath + "|" + text.Itoa(artistID) + "|" + to.String() + "|" + proof + "|" + text.Itoa64(expires)
45}
46
47// botKey is the robot's Ed25519 public key (empty: verification paused) and
48// gen its generation, bumped by SetBot.
49func botKey(t *tx) (key []byte, gen int) {
50 key, _ = hex.DecodeString(t.val(cConfig, "bot"))
51 return key, text.Atoi(t.val(cConfig, "gen"))
52}
53
54func getClaim(t *tx, artistID int) *claim {
55 rec, ok := t.get(cClaims, store.Pad(artistID))
56 if !ok {
57 return nil
58 }
59 f := store.Fields(rec)
60 return &claim{To: address(f[0]), Proof: f[1], At: text.Atoi64(f[2]), Gen: text.Atoi(f[3])}
61}
62
63// liveClaim is the pending claim made with the current robot key, if any.
64func liveClaim(t *tx, artistID int) *claim {
65 c := getClaim(t, artistID)
66 if _, gen := botKey(t); c == nil || c.Gen != gen {
67 return nil
68 }
69 return c
70}
71
72// SetBot sets the robot's Ed25519 public key, hex ("" disables it). Every
73// pending claim started with the previous key is voided: its artist claims
74// again with a fresh certificate. Admin only.
75func SetBot(cur realm, pubKeyHex string) {
76 onlyAdmin(cur)
77 key, err := hex.DecodeString(pubKeyHex)
78 if err != nil || (len(key) != 0 && len(key) != 32) {
79 panic("catalog: the robot key is a 32-byte Ed25519 public key in hex")
80 }
81 t := rd()
82 _, gen := botKey(t)
83 t.set(cConfig, "bot", hex.EncodeToString(key))
84 t.set(cConfig, "gen", text.Itoa(gen+1))
85 save(cur, t)
86 emitEvent("BotSet", "key", pubKeyHex)
87}
88
89// Claim starts the verification of artistID for the caller with the robot's
90// certificate (ClaimMessage signed, hex). It replaces any pending claim for the
91// artist and restarts the delay. The artist pays its gas.
92func Claim(cur realm, artistID int, proof string, expires int64, sigHex string) {
93 caller := userCaller(cur)
94 noPayment()
95 ts := now()
96 t := rd()
97 key, gen := botKey(t)
98 if len(key) == 0 {
99 panic("catalog: verification is paused")
100 }
101 if expires < ts || expires > ts+certLife {
102 panic("catalog: this certificate has expired, check your page again")
103 }
104 // A plain host, so the host shown next to the ✓ is the one the proof
105 // was read from (no user info, port, or "[email protected]" trick).
106 if !text.HTTPS(proof, 300) || !validHost(hostOf(proof)) {
107 panic("catalog: proof must be an https link on a plain host")
108 }
109 sig, err := hex.DecodeString(sigHex)
110 if err != nil || len(sig) != 64 || !ed25519.Verify(key, []byte(ClaimMessage(artistID, caller, proof, expires)), sig) {
111 panic("catalog: this certificate is not valid for your wallet")
112 }
113 a := mustArtist(t, artistID)
114 mustClaimable(t, a, caller)
115 mustRobotVerifiable(a)
116 t.set(cClaims, store.Pad(artistID), store.Rec(caller.String(), proof, text.Itoa64(ts), text.Itoa(gen)))
117 save(cur, t)
118 emitEvent("ClaimProposed", "artist", text.Itoa(artistID), "to", caller.String(), "proof", proof)
119}
120
121// FinalizeClaim applies a pending claim once claimDelay has passed. Anyone
122// can call it, usually the artist from the app.
123func FinalizeClaim(cur realm, artistID int) {
124 noPayment()
125 t := rd()
126 c := getClaim(t, artistID)
127 if c == nil {
128 panic("catalog: no pending verification for this artist")
129 }
130 if _, gen := botKey(t); c.Gen != gen {
131 panic("catalog: this check was signed by a robot key since replaced, check your page again")
132 }
133 if now() < c.At+claimDelay {
134 panic("catalog: verification completes 72 hours after the check")
135 }
136 a := mustArtist(t, artistID)
137 mustClaimable(t, a, c.To)
138 t.del(cClaims, store.Pad(artistID))
139 if a.Owner == "" {
140 a.Owner = c.To
141 t.set(cOwners, c.To.String(), text.Itoa(a.ID))
142 t.activity("claim", c.To, "0", text.Itoa(a.ID), "0")
143 }
144 a.Verified = true
145 putArtist(t, a)
146 t.set(cProofs, store.Pad(artistID), c.Proof)
147 save(cur, t)
148 emitEvent("ArtistClaimed", "id", text.Itoa(a.ID), "owner", c.To.String(), "proof", c.Proof)
149}
150
151// CancelClaim drops a pending claim (e.g. the proof page was hijacked). Admin only.
152func CancelClaim(cur realm, artistID int) {
153 onlyAdmin(cur)
154 t := rd()
155 if _, ok := t.get(cClaims, store.Pad(artistID)); !ok {
156 panic("catalog: no pending verification for this artist")
157 }
158 t.del(cClaims, store.Pad(artistID))
159 save(cur, t)
160 emitEvent("ClaimCancelled", "artist", text.Itoa(artistID))
161}
162
163// ResetOwner undoes a wrong claim on an imported profile (e.g. a hijacked
164// proof page found too late): the profile is unclaimed again. Self-registered
165// profiles keep their owner. Admin only.
166func ResetOwner(cur realm, artistID int) {
167 onlyAdmin(cur)
168 t := rd()
169 a := mustArtist(t, artistID)
170 if a.Kind == OriginArtist || a.Owner == "" {
171 panic("catalog: only a claimed imported profile can be reset")
172 }
173 t.del(cOwners, a.Owner.String())
174 a.Owner, a.Verified = "", false
175 if isRef(a.Kind) && !a.Hidden { // a pointer has no name of its own: the claimant's name and bio go too
176 if old := skeleton(a.Name); old != "" {
177 if id, ok := t.get(cNames, old); ok && text.Atoi(id) == a.ID {
178 t.del(cNames, old)
179 }
180 }
181 a.Name, a.Bio = "", ""
182 }
183 putArtist(t, a)
184 t.del(cProofs, store.Pad(artistID))
185 t.del(cClaims, store.Pad(artistID))
186 save(cur, t)
187 emitEvent("OwnerReset", "artist", text.Itoa(artistID))
188}
189
190// mustRobotVerifiable refuses the robot's path for a curated profile: it
191// carries a real artist's name and tracks that GnoRadio imported, and no
192// domain proves who that artist is (neither the import site's operator nor
193// any domain an impersonator buys). The moderator assigns it (AssignArtist).
194func mustRobotVerifiable(a *Artist) {
195 if a.Kind == OriginCurated || a.Kind == OriginJamendo { // Jamendo has no bio a robot could read
196 panic("catalog: an imported profile is verified by the GnoRadio moderator, not with a proof page")
197 }
198}
199
200// mustClaimable: an unowned profile goes to a wallet without a profile; an
201// owned one can only be verified for its own wallet.
202func mustClaimable(t *tx, a *Artist, to address) {
203 if a.Hidden {
204 panic("catalog: this artist is hidden")
205 }
206 if a.Verified {
207 panic("catalog: this artist is already verified")
208 }
209 if a.Owner != "" {
210 if a.Owner != to {
211 panic("catalog: this profile belongs to another wallet")
212 }
213 return
214 }
215 if !role.Canonical(to) || artistOf(t, to) != 0 {
216 panic("catalog: this wallet is invalid or already has an artist profile")
217 }
218}
219
220// ArtistVerified reports whether an artist proved who they are (tips and
221// paid tickets need it).
222func ArtistVerified(artistID int) bool { return artistAt(artistID, aVerified) == "1" }
223
224// ClaimState is an artist's pending verification: the wallet it goes to and
225// when FinalizeClaim may apply it; pending is false when there is none.
226func ClaimState(artistID int) (pending bool, to address, readyAt int64) {
227 if c := liveClaim(rd(), artistID); c != nil {
228 return true, c.To, c.At + claimDelay
229 }
230 return false, "", 0
231}
232
233// PendingClaim is a verification on its way: the artist, the wallet it goes
234// to, the proof link and when FinalizeClaim may apply it.
235type PendingClaim struct {
236 Artist int
237 To address
238 Proof string
239 ReadyAt int64
240}
241
242// PendingClaims lists up to limit (1-100) verifications pending with the
243// current robot key, by artist id from offset, and how many claims are
244// stored (stale ones included, so offset+limit < total means more). A claim
245// stays public for 72 hours before it counts: this is where anyone watches
246// them.
247func PendingClaims(offset, limit int) (out []PendingClaim, total int) {
248 t := rd()
249 _, gen := botKey(t)
250 keys, recs := store.Rows(data.PageAt(cClaims, max0(offset), store.Limit(limit, 100), false))
251 for i, k := range keys {
252 if f := store.Fields(recs[i]); text.Atoi(f[3]) == gen {
253 out = append(out, PendingClaim{Artist: text.Atoi(k), To: address(f[0]), Proof: f[1], ReadyAt: text.Atoi64(f[2]) + claimDelay})
254 }
255 }
256 return out, data.Size(cClaims)
257}
258
259// ProofHost is where a verified artist's proof was found: a host they
260// control, not proof of who they are, so show it next to the ✓. It is ""
261// when the artist is not verified, or a moderator verified it (Verify,
262// AssignArtist).
263func ProofHost(artistID int) string {
264 t := rd()
265 return proofHost(t, artistID, artistField(t, artistID, aVerified))
266}
267
268func proofHost(t *tx, artistID int, verified string) string {
269 if verified == "" {
270 return ""
271 }
272 if h := hostOf(t.val(cProofs, store.Pad(artistID))); validHost(h) {
273 return h
274 }
275 return "" // a record a later release wrote: show no host rather than a wrong one
276}
277
278// ClaimJSON is the verification state of an artist for the app:
279// {"verified":b,"proof":"…","pending":b,"to":"…","pendingProof":"…","readyAt":n,"bot":"…"}.
280func ClaimJSON(artistID int) string {
281 t := rd()
282 out := `{"verified":` + strconv.FormatBool(artistField(t, artistID, aVerified) == "1") + `,"proof":"` + t.val(cProofs, store.Pad(artistID)) + `"`
283 if c := liveClaim(t, artistID); c != nil {
284 out += `,"pending":true,"to":"` + c.To.String() + `","pendingProof":"` + c.Proof + `","readyAt":` + text.Itoa64(c.At+claimDelay)
285 } else {
286 out += `,"pending":false`
287 }
288 key, _ := botKey(t)
289 return out + `,"bot":` + strconv.FormatBool(len(key) != 0) + `}`
290}