|
| 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