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

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}