Skip to content

Commit 60daaf6

Browse files
RFC-27: IP Ownership Verification Service for user connection (#2304)
Adds **RFC-27**, which specifies an IP Ownership Verification step for DoubleZero user creation: an external IP Verification Service issues a signed `IpOwnershipProof` that the serviceability program validates **onchain** before binding a `client_ip`. RFC: [`rfcs/rfc27-ip-verification.md`](rfcs/rfc27-ip-verification.md) ## Summary - **Targets a concrete gap:** for wildcard access passes (`0.0.0.0` / `allow_multiple_ip`, including the `EdgeSeat` passes issued by the shred-oracle) the program accepts any globally-routable `client_ip` without verifying control of it (`create_core.rs:186-192`; `is_global` only checks routability). The proof re-introduces the missing per-IP control, closing IP squatting/DoS (User PDA is keyed by `(client_ip, user_type)`) and traffic-misdirection risks. - **Control lives onchain, so it is not bypassable:** signature validated via the native Ed25519 precompile + Instructions sysvar introspection; an attacker cannot obtain a proof for an IP they can't originate traffic from. - **Complements, not replaces, AccessPass:** AccessPass still gates *who* connects; the proof governs *which IP* may be bound. - **Honest scope:** documents origin-vs-ownership limits (NAT/CGNAT/shared egress), source-IP consistency on multi-homed hosts, the centralized verifier trust root + rotation, and a legacy compatibility window. ## Changes vs. previous revision - Renumbered RFC 12 → **RFC-27** (file rename + CHANGELOG entry). 22 was claimed on main by `rfc22-client-pim-register-beacon.md`; 27 is the next free number after RFC-26. - Rewrote the design around an **onchain-validated** proof and reconciled it with the existing AccessPass / dynamic-IP / EdgeSeat mechanisms (previously unmentioned). - Specified the Solana Ed25519 verification mechanism (precompile + sysvar), fixed inconsistencies (POST vs GET, removed the unverified "payer signature", `client_ip: u32` → `Ipv4Addr`), and filled the empty template sections (New Terminology, Alternatives Considered, Impact). - Rebased onto main; the CHANGELOG entry now sits under `## Unreleased` (it had been left behind in an already-released section). ## Testing Verification - Documentation-only change. Verified the file follows `rfcs/rfc0-template.md` (all sections present, none empty), the mermaid diagram is well-formed, and no stale "RFC 11/12/22" references to this RFC remain in `rfcs/` or `CHANGELOG.md`. - Technical claims cross-checked against `create_core.rs` (wildcard/`allow_multiple_ip` acceptance path), `state/user.rs`, `processors/accesspass/set.rs`, the CLI `connect`/`check_accesspass` path, and the `shred-oracle` EdgeSeat flow in `doublezero-shreds`. --------- Co-authored-by: jolveira@gmail.com <jolveira@gmail.com>
1 parent df08f54 commit 60daaf6

2 files changed

Lines changed: 263 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ All notable changes to this project will be documented in this file.
88

99
### Changes
1010

11+
- RFCs
12+
- RFC-27: IP Ownership Verification Service for user connection
13+
1114
## [v0.36.0](https://github.com/malbeclabs/doublezero/compare/client/v0.35.0...client/v0.36.0) - 2026-08-14
1215

1316
### Breaking

rfcs/rfc27-ip-verification.md

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
# RFC 27: IP Ownership Verification Service for user connection
2+
3+
## Summary
4+
5+
**Status: Draft**
6+
7+
This RFC introduces an **IP Ownership Verification** step for DoubleZero user creation. When a user
8+
binds a public `client_ip` to a connection, an external **IP Verification Service** issues a
9+
cryptographically signed `IpOwnershipProof` attesting that the request originated from that IP. The
10+
serviceability program validates the proof **onchain** before accepting the `client_ip`.
11+
12+
The motivation is a concrete gap: for **wildcard access passes** — passes stored at the unspecified
13+
IP (`0.0.0.0`) or flagged `allow_multiple_ip`, including the `EdgeSeat` passes issued by the
14+
shred-oracle — the program intentionally accepts *any* globally‑routable `client_ip` without
15+
verifying that the connecting party controls it. The proof re‑introduces the missing per‑IP control
16+
for exactly those flows, without removing the flexibility of connecting from a changing IP.
17+
18+
## Motivation
19+
20+
Today the serviceability program gates user creation with an **AccessPass** keyed by
21+
`(client_ip, user_payer)`. For a pass bound to a specific IP, the program enforces that the user's
22+
`client_ip` matches the pass, so the issuing authority effectively chose the IP. But for **wildcard
23+
passes** the program skips that check entirely
24+
(`smartcontract/programs/doublezero-serviceability/src/processors/user/create_core.rs:173-183`):
25+
26+
```rust
27+
// A pass stored at the UNSPECIFIED PDA (0.0.0.0) is valid for any client IP by construction
28+
if accesspass.client_ip != Ipv4Addr::UNSPECIFIED
29+
&& accesspass.client_ip != client_ip
30+
&& !accesspass.allow_multiple_ip()
31+
{ /* reject */ }
32+
```
33+
34+
The only validation applied to the supplied IP is `is_global(client_ip)`
35+
(`.../state/user.rs:381`), which checks that the address is publicly routable — **not** that the
36+
caller owns it.
37+
38+
This is a real risk for wildcard passes:
39+
40+
- **IP squatting → denial of service.** The `User` PDA is derived from `(client_ip, user_type)`
41+
(`create_core.rs:125`). Registering an IP occupies that slot and can prevent the legitimate
42+
operator of that IP from creating their own user.
43+
- **Traffic misdirection.** The controller provisions the GRE tunnel and routes toward the declared
44+
`client_ip`; an IP the registrant does not control points device traffic at an unrelated third
45+
party.
46+
47+
The `EdgeSeat` flow in `doublezero-shreds` (`shred-oracle`) issues exactly such wildcard passes
48+
(`client_ip = 0.0.0.0`, `allow_multiple_ip = true`), so this gap is on the path the project is
49+
actively building toward.
50+
51+
### Goal
52+
53+
Guarantee that the `client_ip` a user binds is one the connecting party can demonstrably originate
54+
traffic from, enforced where it cannot be bypassed (onchain), while preserving the ability to
55+
connect from a non‑preregistered or changing IP.
56+
57+
## New Terminology
58+
59+
- **IP Verification Service** — A DoubleZero‑operated HTTP service that observes the source IP of an
60+
inbound request and returns a signed `IpOwnershipProof`.
61+
- **`IpOwnershipProof`** — A signed attestation `{ payer, client_ip, epoch, signature }` produced by
62+
the verifier keypair.
63+
- **Verifier keypair** — The Ed25519 keypair owned by DoubleZero whose public key is the trust root
64+
for proof validation; its pubkey is stored in onchain global state.
65+
- **Wildcard access pass** — An AccessPass stored at the unspecified IP (`0.0.0.0`, `IS_DYNAMIC`)
66+
and/or flagged `ALLOW_MULTIPLE_IP`, which the program accepts for any `client_ip`. Includes the
67+
`EdgeSeat` passes issued by the shred-oracle.
68+
- **Proof of control** — Evidence that the holder of `payer` can originate traffic from `client_ip`,
69+
established by issuing the verification request *from* that IP.
70+
71+
## Alternatives Considered
72+
73+
- **Do nothing.** Wildcard passes continue to accept any routable IP. Leaves the squatting and
74+
traffic‑misdirection risks open for those flows.
75+
- **Verify at access‑pass issuance.** The authority/service that issues the pass verifies IP control
76+
and issues a **specific‑IP** pass (which the program already binds). This works and adds no onchain
77+
crypto, but it does not cover the wildcard/`EdgeSeat` model whose whole purpose is to let a client
78+
connect from any IP without preregistration. Good complement, not a full substitute.
79+
- **Client‑side certification only (CLI checks the IP).** Rejected: the CLI is not a trust boundary.
80+
An attacker calling the program/SDK directly bypasses any check that lives only in `connect`.
81+
- **Signed proof validated onchain (this RFC).** The only option that is both non‑bypassable and
82+
applicable to wildcard passes: the program rejects a user creation that lacks a valid proof for the
83+
declared IP, regardless of how the instruction is submitted.
84+
85+
## Detailed Design
86+
87+
### Protocol Flow
88+
89+
```mermaid
90+
sequenceDiagram
91+
participant U as User
92+
participant C as DoubleZero CLI
93+
participant V as IP Verification Service
94+
participant S as Serviceability Program (onchain)
95+
96+
U->>C: doublezero connect ibrl
97+
Note right of C: client_ip not yet certified
98+
99+
C->>V: POST https://verify.doublezero.xyz { payer }
100+
Note right of V: observes the request's source IP
101+
V-->>C: IpOwnershipProof { payer, client_ip, epoch, signature }
102+
103+
Note over C: client_ip = the IP the service observed
104+
105+
C->>S: CreateUser(client_ip, ...) + Ed25519 verify ix + IpOwnershipProof
106+
S->>S: Confirm Ed25519 precompile verified (verifier_key, message, signature)
107+
S->>S: Check payer, client_ip, epoch freshness
108+
109+
alt Proof valid
110+
S-->>C: User created, IP bound
111+
else Proof invalid / missing / stale
112+
S-->>C: Error
113+
end
114+
```
115+
116+
### Steps
117+
118+
1. The CLI sends a request to the verification service. The service uses the **source IP of the
119+
request** as `client_ip`, the `payer` from the body, and the current DoubleZero epoch:
120+
121+
```
122+
POST https://verify.doublezero.xyz
123+
{ "payer": "<Pubkey>" }
124+
```
125+
126+
2. The service returns a signed proof:
127+
128+
```json
129+
{
130+
"payer": "<payer_pubkey>",
131+
"client_ip": "<a.b.c.d>",
132+
"epoch": <u64>,
133+
"signature": "<ed25519_signature>"
134+
}
135+
```
136+
137+
The signed message is the byte concatenation `payer || client_ip || epoch` (the proof fields in a
138+
fixed layout — see below), signed by the verifier keypair.
139+
140+
3. The CLI submits the user‑creation transaction carrying both:
141+
- the `IpOwnershipProof`, and
142+
- an **Ed25519 program instruction** (the native precompile) over `(verifier_pubkey, message,
143+
signature)`, placed in the same transaction.
144+
145+
4. The serviceability program validates and, only if valid, binds `client_ip`.
146+
147+
### Proof Specification
148+
149+
```rust
150+
pub struct IpOwnershipProof {
151+
pub payer: Pubkey, // 32
152+
pub client_ip: Ipv4Addr, // 4 (IPv4)
153+
pub epoch: u64, // 8
154+
pub signature: [u8; 64], // Ed25519
155+
}
156+
```
157+
158+
The signed message is the fixed‑layout serialization of `(payer, client_ip, epoch)`. `Ipv4Addr` is
159+
used to match the type used throughout the program; it is serialized as its 4 network‑order octets.
160+
161+
### Onchain Validation
162+
163+
Solana programs cannot verify an Ed25519 signature directly inside BPF cheaply. Verification uses
164+
the **native Ed25519 precompile**: the CLI includes an `Ed25519SigVerify` instruction in the same
165+
transaction, and the serviceability program **introspects the Instructions sysvar** to confirm that
166+
instruction is present and that its public key, message, and signature match the expected verifier
167+
key and the reconstructed `payer || client_ip || epoch` message.
168+
169+
Required checks:
170+
171+
1. Read `IpOwnershipProof` from instruction data; reconstruct `message = payer || client_ip || epoch`.
172+
2. Load the Ed25519 instruction from the Instructions sysvar and confirm it verifies `signature`
173+
over `message` with the **verifier public key from global state**.
174+
3. `proof.payer == user_payer` (the account paying / owning the user).
175+
4. `proof.client_ip == client_ip` being bound to the user.
176+
5. `proof.epoch` is within the allowed freshness window relative to `Clock::get()?.epoch`.
177+
178+
#### Rejection conditions
179+
180+
The program MUST reject when any of the following holds:
181+
182+
- the Ed25519 verify instruction is absent or does not match (`verifier_key`, `message`, `signature`);
183+
- `payer` mismatch;
184+
- `client_ip` mismatch with the value being bound;
185+
- the proof is stale (epoch outside the freshness window);
186+
- the proof is malformed.
187+
188+
### Trust Root and Key Management
189+
190+
The verifier public key is stored in onchain global state (alongside the other DoubleZero
191+
authorities) so it can be rotated by the existing authority‑management instruction without a program
192+
upgrade. Rotating the key invalidates outstanding proofs; clients re‑verify on next connect.
193+
194+
### Relationship to AccessPass
195+
196+
This proof does **not** replace AccessPass. AccessPass continues to gate *who* may connect and *what*
197+
they may do (epoch validity, multicast allowlists, seat caps). The proof governs *which IP* a user
198+
may bind:
199+
200+
- **Specific‑IP passes** already bind the IP onchain; the proof is redundant there (it MAY still be
201+
required uniformly for simplicity).
202+
- **Wildcard / `EdgeSeat` passes** accept any IP today; the proof is the per‑IP control that closes
203+
the squatting/misdirection gap for them.
204+
205+
## Impact
206+
207+
- **Onchain (serviceability):** new proof validation in the user‑creation path (Instructions‑sysvar
208+
introspection), a verifier public key in global state, and new instruction arguments/accounts
209+
(the proof and the Instructions sysvar). This is the first onchain Ed25519 verification in the
210+
program.
211+
- **CLI (`doublezero`):** `connect` calls the verification service and attaches the proof plus the
212+
Ed25519 instruction to the transaction. This extends the existing public‑IP autodetection
213+
(`look_for_ip` via ifconfig.me), which becomes a UX convenience rather than the source of truth.
214+
Separately, `check_accesspass` should also probe the dynamic (`0.0.0.0`) AccessPass PDA, which it
215+
does not today (`smartcontract/cli/src/requirements.rs`).
216+
- **New off‑chain component:** the IP Verification Service (stateless signer that echoes the observed
217+
source IP).
218+
- **Operational:** the service must observe the real client source IP. Behind a proxy/CDN it must use
219+
a trusted forwarded‑for header; otherwise it would sign the proxy's IP.
220+
221+
## Security Considerations
222+
223+
- **Non‑bypassable.** The control lives onchain. An attacker submitting the instruction directly
224+
still needs a valid proof; the service only signs the IP it actually observed, and a TCP/TLS
225+
handshake source cannot be spoofed off‑path, so a proof for an IP the attacker cannot originate
226+
from is unobtainable.
227+
- **Origin, not exclusive ownership.** A proof attests that the holder of `payer` originated a
228+
request from `client_ip` around `epoch`. Behind NAT/CGNAT or a shared egress IP, multiple
229+
co‑located parties could each obtain a proof for the same IP. This bounds remote squatting but does
230+
not arbitrate between parties sharing one egress IP. DoubleZero validators with dedicated public
231+
IPs are unaffected.
232+
- **Source‑IP consistency.** The IP the CLI uses to reach the verification service must be the same
233+
IP it binds as the tunnel `client_ip`. On multi‑homed hosts the client must ensure the verification
234+
request egresses from the intended IP (e.g., source binding).
235+
- **Centralized trust root.** The verifier keypair is a DoubleZero‑operated authority. The benefit
236+
delivered is automated, non‑bypassable per‑IP verification — not decentralization. Key rotation is
237+
supported via global state.
238+
- **Replay.** The epoch window bounds proof reuse; because the proof binds `payer` and `client_ip`,
239+
reuse only re‑asserts the same binding. A nonce or binding to the specific user account can further
240+
constrain reuse if needed (see Open Questions).
241+
242+
## Backward Compatibility
243+
244+
To allow a smooth transition, the serviceability program can support both flows for a limited number
245+
of versions:
246+
247+
1. the legacy flow, where the CLI supplies `client_ip` without a proof, and
248+
2. the new flow, where the IP is bound only after a valid `IpOwnershipProof`.
249+
250+
This maintains a compatibility window until clients upgrade. Enforcement can be tightened (legacy
251+
flow removed) once adoption is sufficient, consistent with RFC‑10 version‑compatibility windows.
252+
253+
## Open Questions
254+
255+
- Should proofs be persisted onchain for auditing, or is the bound `client_ip` sufficient?
256+
- Should the proof bind to the specific user account (or a nonce) to further constrain replay within
257+
an epoch?
258+
- Should IP re‑verification be periodic (re‑prove on a schedule), or only at user creation?
259+
- Should IPv6 be supported?
260+
- On what cadence should the verifier key rotate?

0 commit comments

Comments
 (0)