verifier.gno
10.85 Kb · 253 lines
1// Package names enforces namespace permissions for package deployment.
2//
3// Two namespace shapes grant deploy authority when enforcement is enabled:
4//
5// 1. PA (personal-address) namespaces — gno.land/{r,p}/<addr>/* — the
6// deployer's address string equals the namespace literal. Anyone can
7// deploy under their own address.
8//
9// 2. Registered-name namespaces — gno.land/{r,p}/<name>/* — r/sys/users
10// has a (name → addr) mapping where the resolved address equals the
11// deployer AND the name is the user's CURRENT name (not a historical
12// alias from a rename chain). This is the bridge that lets
13// r/sys/namereg/v0 (or any other DAO-whitelisted controller) grant
14// deploy authority via name registration.
15//
16// Authority is unscoped: a registered name owns BOTH r/<name>/* and
17// p/<name>/* paths. There is no sub-prefix isolation (e.g. r/u/<name>/*).
18//
19// The realm exposes an emergency-halt switch via SetPaused. When paused,
20// the verifier rejects EVERY namespace check — PA included — until
21// unpaused. This is the "true emergency" semantic; the narrow alternative
22// (pause registered-name only, preserve PA) was considered and rejected
23// because the threats most likely to justify pausing this realm
24// (verifier bug, compromised controller, signature-layer incident) do
25// not reliably exempt PA from the same blast radius.
26package names
27
28import (
29 "chain"
30
31 "gno.land/r/gov/dao"
32 govimpl "gno.land/r/gov/dao/impl/v0"
33 memberstore "gno.land/r/gov/dao/memberstore/v0"
34 susers "gno.land/r/sys/users"
35)
36
37var (
38 // admin is the GovDAO T1 multisig address, hardcoded at realm-source
39 // commit time. Its only capability is gating Enable() — a one-way,
40 // one-shot genesis activation of the namespace verifier. The address
41 // has no other authority on this realm; pause/unpause is gated on a
42 // separate GovDAO T1 proposal (see ProposeSetPaused), and there is
43 // no SetEnabled(false) or SetAdmin path.
44 //
45 // Hardcoding is acceptable because:
46 // - Enable() is called once, at chain genesis. After that the
47 // address is dead weight — no further capability flows through it.
48 // - The narrow blast radius of "stale admin" is "Enable() can never
49 // be called", which leaves the verifier in pre-Enable bypass mode
50 // (returns true for all checks) — degraded but not exploitable.
51 // - A rotation path would only matter if Enable() needed to be
52 // re-issued. It doesn't; the flag is sticky.
53 //
54 // If the genesis activation pattern ever needs to change (e.g. a
55 // SetEnabled(false) emergency disable is added later), this admin
56 // model should be replaced with a GovDAO T1 proposal flow that
57 // mirrors ProposeSetPaused. Until then, the hardcoded address is
58 // the smallest viable governance surface for the one-shot use case.
59 admin = address("g1skl80cuz8zq3lul9pgz5pc35l2pfzgxgfpsqkx")
60 enabled = false
61 paused = false
62)
63
64// nameLookupFn returns (addr, ok) for a given registered name. Allows
65// the verifier function to be unit-tested without wiring up r/sys/users
66// state — production binds resolveCurrentName as the lookup, tests pass
67// nil (PA-only) or a fake.
68type nameLookupFn func(name string) (addr address, ok bool)
69
70// IsAuthorizedAddressForNamespace checks if the given address can deploy
71// to the given namespace. See package doc for the two authorization paths
72// and the pause semantic.
73//
74// Pre-Enable, all checks pass (testing/dev convenience).
75func IsAuthorizedAddressForNamespace(address_XXX address, namespace string) bool {
76 return verifier(enabled, paused, address_XXX, namespace, resolveCurrentName)
77}
78
79// resolveCurrentName is the production nameLookupFn, backed by r/sys/users.
80// Returns ok=true only if the name resolves to a non-deleted user AND the
81// queried name is that user's CURRENT name (the most recent UpdateName).
82//
83// Restricting to the current name has two consequences worth knowing:
84//
85// 1. After a UpdateName from "alice" to "alice2", the user keeps deploy
86// authority over r/alice2/* but LOSES it for r/alice/*. Already-
87// deployed packages at r/alice/* keep working — deploy-time
88// authorization doesn't unwind the past — but no NEW deploys can
89// land there.
90//
91// 2. The old name "alice" is also unregisterable by anyone else: when
92// r/sys/users.UpdateName runs, it inserts the new name into nameStore
93// but does not remove the old one. r/sys/users.RegisterUser then
94// rejects re-registration of "alice" with ErrNameTaken. Net effect:
95// a rename permanently removes the old name from circulation.
96//
97// The alternative (allow historical aliases to retain authority) was
98// rejected because it lets a single user register one cheap name, then
99// rename N times to claim authority over N distinct namespaces — a
100// stealth namespace acquisition vector worse than the current
101// burn-on-rename behavior.
102func resolveCurrentName(name string) (address, bool) {
103 data, isCurrent := susers.ResolveName(name)
104 if data == nil || !isCurrent {
105 return "", false
106 }
107 return data.Addr(), true
108}
109
110// Enable enables the namespace check for this realm.
111// The namespace check is disabled initially to ease txtar and other testing contexts,
112// but this function is meant to be called in the genesis of a chain.
113func Enable(cur realm) {
114 if !cur.IsCurrent() {
115 panic("unauthorized: cur is not the caller's live realm")
116 }
117 if cur.Previous().Address() != admin {
118 panic("caller is not admin")
119 }
120 enabled = true
121}
122
123func IsEnabled() bool {
124 return enabled
125}
126
127// ProposeSetPaused returns a GovDAO proposal request that, when voted
128// through and executed, toggles the chain-wide deploy gate. When the
129// realm is paused, the verifier rejects EVERY namespace check — PA
130// (personal-address) included — until a subsequent ProposeSetPaused(false)
131// proposal executes.
132//
133// This is an emergency halt. A paused state means NO new MsgAddPackage
134// transactions land at any path on the chain. Existing realms continue
135// to receive MsgCall traffic normally — pause is scoped to addpkg, not
136// to all VM operations. Use cases:
137// - Bug discovered in this realm or r/sys/users that requires a
138// hotfix before further deploys can be trusted.
139// - Wallet/signature-layer incident under investigation.
140//
141// (A "compromised controller" use case was considered and removed: the
142// controller's RegisterUser path is direct into r/sys/users and does
143// NOT go through this verifier, so pause does not freeze new
144// registrations. To contain a compromised controller, the appropriate
145// flow is ProposeControllerRemoval in r/sys/users, not pause here.)
146//
147// The narrow alternative (pause registered-name path only, preserve
148// PA) was considered and rejected. See package doc for rationale.
149//
150// Gated on GovDAO proposal at T1 tier — not the hardcoded admin used
151// by Enable. Pause is consequential enough to warrant a tier-restricted
152// governance vote rather than a single-multisig click. T1 filter
153// prevents lower-tier members from spamming pause proposals to dilute
154// attention. Trade-off is response time: a T1 vote takes hours-to-days;
155// if a faster emergency-halt mechanism is needed, that belongs at a
156// different layer (e.g. an ante-handler-level chain pause), not here.
157//
158// Pause is orthogonal to the pre-Enable bypass: before Enable, the
159// verifier returns true regardless of paused state. So executing a
160// pause proposal before Enable has no effect on deploys, but the
161// value persists and applies the moment Enable runs. To avoid this
162// staging trap, operators should call Enable BEFORE any pause
163// proposals are voted in.
164//
165// Idempotency: calling ProposeSetPaused(v) when the realm's current
166// paused state already equals v panics at proposal-creation time so
167// voters never see a proposal whose execution would no-op.
168func ProposeSetPaused(cur realm, v bool) dao.ProposalRequest {
169 if paused == v {
170 panic("paused state already matches requested value; no-op proposal rejected")
171 }
172 cb := func(cur realm) error {
173 setPaused(0, cur, v)
174 return nil
175 }
176 title := "Unpause Namespace Verifier"
177 desc := "This proposal unpauses `r/sys/names`. After execution, the namespace verifier will resume normal authorization checks (PA + registered-name paths). MsgCall traffic to existing realms is unaffected (pause was scoped to MsgAddPackage); only NEW package deploys gate on the unpaused state."
178 if v {
179 title = "Pause Namespace Verifier"
180 desc = "This proposal pauses `r/sys/names`. After execution, the namespace verifier will reject EVERY new MsgAddPackage on the chain — PA (personal-address) and registered-name namespaces alike — until a subsequent unpause proposal executes. This is an emergency halt scoped to addpkg; MsgCall traffic to existing realms is unaffected."
181 }
182 return dao.NewProposalRequestWithFilter(
183 title,
184 desc,
185 dao.NewSimpleExecutor(0, cur, cb, ""),
186 govimpl.NewFilterByTier(memberstore.T1),
187 )
188}
189
190// setPaused is the private actuator behind ProposeSetPaused's executor
191// callback. It is unexported to ensure no path outside the proposal
192// flow can flip the flag — every state change goes through GovDAO.
193//
194// Emits NamespaceEnforcement{Paused,Unpaused} with the executor's
195// realm path for off-chain audit trails.
196func setPaused(_ int, rlm realm, v bool) {
197 paused = v
198 if v {
199 chain.Emit("NamespaceEnforcementPaused", "by", rlm.Previous().PkgPath())
200 } else {
201 chain.Emit("NamespaceEnforcementUnpaused", "by", rlm.Previous().PkgPath())
202 }
203}
204
205// IsPaused reports the current value of the pause flag. Note: when
206// the realm is pre-Enable, IsPaused may return true but the verifier
207// will still pass-through (pre-Enable bypass takes priority).
208func IsPaused() bool {
209 return paused
210}
211
212// verifier checks namespace deployment permissions.
213// lookup is the registered-name resolver — pass nil to disable that path
214// (used by tests that want to exercise PA-only behavior).
215//
216// Order of checks (top to bottom, first matching wins):
217// 1. !isEnabled → return true (pre-Enable: testing/dev bypass)
218// 2. isPaused → return false (emergency halt — INCLUDES PA)
219// 3. invalid input → return false
220// 4. PA match (addr.String() == namespace) → return true
221// 5. Registered-name lookup match → return true
222// 6. otherwise → return false
223//
224// The pause check is intentionally above the PA check. A SetPaused(true)
225// halts every deploy regardless of namespace shape.
226func verifier(isEnabled, isPaused bool, address_XXX address, namespace string, lookup nameLookupFn) bool {
227 if !isEnabled {
228 return true // pre-genesis / dev convenience: bypass everything
229 }
230
231 if isPaused {
232 return false // emergency halt: reject every deploy including PA
233 }
234
235 if namespace == "" || !address_XXX.IsValid() {
236 return false
237 }
238
239 // Path 1: PA (personal-address) namespace.
240 // gno.land/{p,r}/{ADDRESS}/**
241 if address_XXX.String() == namespace {
242 return true
243 }
244
245 // Path 2: registered-name namespace via r/sys/users.
246 if lookup != nil {
247 if owner, ok := lookup(namespace); ok && owner == address_XXX {
248 return true
249 }
250 }
251
252 return false
253}