diff --git a/CHANGELOG.md b/CHANGELOG.md index 062aa652e..c1a55e122 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,8 +38,12 @@ All notable changes to this project will be documented in this file. - `CreateUser` and `CreateSubscribeUser` validate an optional RFC-27 `IpOwnershipProof`, verified through the native Ed25519 precompile and signed by `globalstate.ip_verifier_authority_pk`, so a caller can no longer bind a `client_ip` it cannot originate traffic from. Enforcement is gated on the new `require-ip-ownership-proof` feature flag: while it is clear a missing proof is accepted, and a supplied proof is validated in full either way. The sentinel authority may omit the proof, because the shred-oracle provisions users owned by validators and has no proof it could obtain; a proof it does supply is still validated (#4215). (#4197) - IP verifier - New `doublezero-ip-verifier` service signs the source address it observes as an RFC-27 `IpOwnershipProof`, over `POST /v1/proof`. Forwarded headers count only for connections from a `--trusted-proxy` CIDR, and only the `--forwarded-header` the proxy actually writes is read; the chain is walked from the right so a client-prepended hop is ignored. With no trusted proxies configured the connection peer address is the only address it will sign. Non-routable and IPv6 sources are refused, as is a request the cached ledger epoch is too old to answer. The verifier key is checked against `GlobalState.ip_verifier_authority_pk` at startup and periodically after, so a rotation this service was not redeployed for takes it out of rotation instead of silently failing every user creation onchain. Built on axum, the first HTTP server framework in the Rust workspace. (#4198) +- Rust SDK + - `CreateUserCommand` and `CreateSubscribeUserCommand` take an optional RFC-27 `ip_proof`. Supplying one attaches the native `Ed25519SigVerify` instruction the program looks for and sends both as one transaction; the verifier key comes from `GlobalState.ip_verifier_authority_pk`, the same place the program reads it, so a caller cannot pair a proof with the wrong key. A proof naming a different owner, address, or user type is refused before the transaction is paid for. On the `--owner` override path the proof must name that owner, because the program binds it to the user's effective owner. Omitting it produces the pre-RFC-27 transaction unchanged. Nothing sets it yet; the CLI is #4201. (#4200) + - `DoubleZeroClient` gains `send_instructions`, for a transaction that needs more than one instruction. `send_transaction` is unchanged. (#4200) - Utility crates - New `doublezero-ip-proof` crate defines the RFC-27 `IpOwnershipProof` and the exact bytes the verifier signs, in one place the serviceability program, the CLI, and the verification service all share. Nothing consumes it yet. (#4195, #4206) + - `doublezero-serviceability-instruction` gains `ip_proof::ed25519_verification_instruction`, which lays out the Ed25519 precompile instruction for a proof using the runtime's own writer rather than a hand-rolled offset header, and `ip_proof::with_ed25519_verification`, which pairs it with a user-creation instruction. With a proof attached a `CreateUser` transaction still fits 10 `dz_prefix_block` accounts and `CreateSubscribeUser` 8, against the 21 and 19 they fit without one; devices carry one or two. (#4200) ## [v0.36.0](https://github.com/malbeclabs/doublezero/compare/client/v0.35.0...client/v0.36.0) - 2026-08-14 diff --git a/Cargo.lock b/Cargo.lock index 95c6086be..bc68bf865 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2019,6 +2019,7 @@ dependencies = [ "doublezero-ip-proof", "doublezero-serviceability", "solana-compute-budget-interface", + "solana-ed25519-program", "solana-program", "solana-sdk", "solana-system-interface 3.2.0", @@ -2062,6 +2063,7 @@ dependencies = [ "doublezero-cli-core", "doublezero-config", "doublezero-geolocation", + "doublezero-ip-proof", "doublezero-program-common", "doublezero-record", "doublezero-serviceability", diff --git a/crates/doublezero-daemon-cli/src/connect.rs b/crates/doublezero-daemon-cli/src/connect.rs index d6e70ee1d..3807fabcf 100644 --- a/crates/doublezero-daemon-cli/src/connect.rs +++ b/crates/doublezero-daemon-cli/src/connect.rs @@ -1084,6 +1084,7 @@ impl Connect { client_ip, tunnel_endpoint, tenant_pk: None, + ip_proof: None, })?; spinner.set_message("Multicast user created"); user_pk @@ -1364,6 +1365,7 @@ impl Connect { client_ip: *client_ip, tunnel_endpoint, tenant_pk, + ip_proof: None, }); match res { @@ -1483,6 +1485,7 @@ impl Connect { tunnel_endpoint, owner: None, feed_pk: None, + ip_proof: None, }); let user_pk = match res { @@ -1621,6 +1624,7 @@ impl Connect { tunnel_endpoint, owner: None, feed_pk: None, + ip_proof: None, }); let user_pk = match res { @@ -2791,6 +2795,7 @@ mod tests { client_ip: user.client_ip, tunnel_endpoint: user.tunnel_endpoint, tenant_pk, + ip_proof: None, }; let users = self.users.clone(); @@ -2828,6 +2833,7 @@ mod tests { tunnel_endpoint: user.tunnel_endpoint, owner: None, feed_pk: None, + ip_proof: None, }; let users = self.users.clone(); diff --git a/crates/doublezero-serviceability-instruction/Cargo.toml b/crates/doublezero-serviceability-instruction/Cargo.toml index cd8f127a6..1f6c62ceb 100644 --- a/crates/doublezero-serviceability-instruction/Cargo.toml +++ b/crates/doublezero-serviceability-instruction/Cargo.toml @@ -18,12 +18,16 @@ name = "doublezero_serviceability_instruction" # RPC because it never depends on the RPC tree. [dependencies] doublezero-serviceability.workspace = true +# The RFC-27 proof struct, read by the `ip_proof` builders. Default features only — +# no `signer`, so no crypto crate enters the build; this crate only lays out bytes the +# verification service already signed. +doublezero-ip-proof.workspace = true solana-program.workspace = true solana-system-interface.workspace = true solana-compute-budget-interface.workspace = true +# The upstream writer of the Ed25519 precompile offset layout. Default features are +# bytemuck + solana-instruction + solana-sdk-ids; no userspace Ed25519 implementation. +solana-ed25519-program.workspace = true [dev-dependencies] -# The RFC-27 proof struct, to assert the Instructions sysvar append. Default features -# only — no signer, so no crypto crate enters the build. -doublezero-ip-proof.workspace = true solana-sdk.workspace = true diff --git a/crates/doublezero-serviceability-instruction/src/ip_proof.rs b/crates/doublezero-serviceability-instruction/src/ip_proof.rs new file mode 100644 index 000000000..48d87c6ae --- /dev/null +++ b/crates/doublezero-serviceability-instruction/src/ip_proof.rs @@ -0,0 +1,166 @@ +//! RFC-27 IP ownership proof: the Ed25519 side of a user-creation transaction. +//! +//! A proof carried in `UserCreateArgs::ip_proof` / `UserCreateSubscribeArgs::ip_proof` is not +//! self-validating. BPF cannot verify Ed25519 cheaply, so the program checks the signature by +//! introspecting the Instructions sysvar for a native `Ed25519SigVerify` instruction that covers +//! the message the creation implies, signed by `globalstate.ip_verifier_authority_pk` +//! (`doublezero_serviceability::ip_proof`). A transaction that carries the proof but not that +//! instruction is rejected with `IpProofEd25519InstructionMissing`. +//! +//! The two builders here are the whole client-side contract: put the proof in the args (the +//! `create_user` / `create_subscribe_user` builders then append the Instructions sysvar account +//! themselves), and put [`ed25519_verification_instruction`] in the same transaction. +//! [`with_ed25519_verification`] does both halves for a caller that just wants a working +//! transaction. + +use doublezero_ip_proof::IpOwnershipProof; +use solana_program::{instruction::Instruction, pubkey::Pubkey}; + +/// The native `Ed25519SigVerify` instruction that verifies `proof.signature` over +/// [`IpOwnershipProof::signed_message`] with `verifier`. +/// +/// `verifier` is `globalstate.ip_verifier_authority_pk` — the proof does not carry the key it was +/// signed with, and the verification service deliberately does not return it, so a caller reads it +/// from GlobalState, the same place the program reads it. A mismatch is rejected onchain with +/// `IpProofVerifierKeyMismatch`. +/// +/// The offset layout comes from `solana_ed25519_program`, the same code the runtime's precompile +/// parses, rather than being written out here: the program rejects any instruction whose offsets +/// name a different instruction or run past the end of its data, so a hand-rolled header is a +/// silent way to build a transaction that can never land. +pub fn ed25519_verification_instruction( + verifier: &Pubkey, + proof: &IpOwnershipProof, +) -> Instruction { + solana_ed25519_program::new_ed25519_instruction_with_signature( + &proof.signed_message(), + &proof.signature, + &verifier.to_bytes(), + ) +} + +/// `[ed25519_verification_instruction(..), create_instruction]` — ready to send as one transaction +/// (after the caller's [`crate::compute_budget_prelude`]). +/// +/// The program *scans* the Instructions sysvar rather than reading a fixed index, so it accepts the +/// Ed25519 instruction at any position and tolerates interleaved compute-budget instructions. +/// Ordering is therefore a convention, not a requirement — this helper pins it so a caller never +/// has to reason about it, and so the verification is visibly a precondition of the creation. +/// +/// `create_instruction` must be a `create_user` / `create_subscribe_user` instruction whose args +/// carry the *same* proof: the args are what the program reconstructs the signed message from, and +/// those builders derive the Instructions sysvar account from `args.ip_proof.is_some()`. +pub fn with_ed25519_verification( + verifier: &Pubkey, + proof: &IpOwnershipProof, + create_instruction: Instruction, +) -> [Instruction; 2] { + [ + ed25519_verification_instruction(verifier, proof), + create_instruction, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use doublezero_ip_proof::SIGNED_MESSAGE_LEN; + use std::net::Ipv4Addr; + + // The precompile header the program re-derives in `doublezero_serviceability::ip_proof`: + // `[num_signatures, padding, 7 x u16 offsets]`, then key, signature, message. + const HEADER: usize = 16; + const PUBLIC_KEY_OFFSET: usize = HEADER; + const SIGNATURE_OFFSET: usize = PUBLIC_KEY_OFFSET + 32; + const MESSAGE_OFFSET: usize = SIGNATURE_OFFSET + 64; + + fn proof() -> IpOwnershipProof { + IpOwnershipProof { + version: 1, + payer: Pubkey::new_unique(), + client_ip: Ipv4Addr::new(203, 0, 113, 7), + epoch: 931, + user_type: 3, + signature: [7u8; 64], + } + } + + fn u16_at(data: &[u8], offset: usize) -> u16 { + u16::from_le_bytes([data[offset], data[offset + 1]]) + } + + #[test] + fn test_ed25519_verification_instruction_layout() { + let verifier = Pubkey::new_unique(); + let proof = proof(); + let ix = ed25519_verification_instruction(&verifier, &proof); + + assert_eq!(ix.program_id, solana_program::ed25519_program::ID); + // The precompile reads everything out of its own instruction data; it takes no accounts, + // and an account here would silently change the transaction's key list. + assert!(ix.accounts.is_empty()); + + // Exactly one signature. The program rejects any other count with + // `IpProofSignatureCountInvalid` — more than one would let an attacker pair the signature + // the program checks with a second one it does not. + assert_eq!(ix.data[0], 1); + assert_eq!(ix.data[1], 0, "padding byte"); + + // All three instruction indices must be the "this instruction" sentinel. An index naming + // another instruction means the precompile verified bytes the program never reads, which + // the program rejects with `IpProofEd25519OffsetsInvalid`. + assert_eq!(u16_at(&ix.data, 2), SIGNATURE_OFFSET as u16); + assert_eq!(u16_at(&ix.data, 4), u16::MAX, "signature_instruction_index"); + assert_eq!(u16_at(&ix.data, 6), PUBLIC_KEY_OFFSET as u16); + assert_eq!( + u16_at(&ix.data, 8), + u16::MAX, + "public_key_instruction_index" + ); + assert_eq!(u16_at(&ix.data, 10), MESSAGE_OFFSET as u16); + assert_eq!(u16_at(&ix.data, 12), SIGNED_MESSAGE_LEN as u16); + assert_eq!(u16_at(&ix.data, 14), u16::MAX, "message_instruction_index"); + + assert_eq!( + &ix.data[PUBLIC_KEY_OFFSET..SIGNATURE_OFFSET], + verifier.as_ref() + ); + assert_eq!(&ix.data[SIGNATURE_OFFSET..MESSAGE_OFFSET], &proof.signature); + assert_eq!( + &ix.data[MESSAGE_OFFSET..MESSAGE_OFFSET + SIGNED_MESSAGE_LEN], + proof.signed_message().as_slice() + ); + assert_eq!(ix.data.len(), MESSAGE_OFFSET + SIGNED_MESSAGE_LEN); + } + + #[test] + fn test_ed25519_verification_instruction_covers_the_proofs_own_message() { + // The signed bytes come from the proof, never from separately passed-in fields: a proof + // whose message disagrees with what the program reconstructs is rejected onchain, and this + // builder must not be the thing that introduces the disagreement. + let verifier = Pubkey::new_unique(); + let mut proof = proof(); + let baseline = ed25519_verification_instruction(&verifier, &proof); + + proof.epoch += 1; + let shifted = ed25519_verification_instruction(&verifier, &proof); + + assert_ne!(baseline.data, shifted.data); + assert_eq!( + &shifted.data[MESSAGE_OFFSET..MESSAGE_OFFSET + SIGNED_MESSAGE_LEN], + proof.signed_message().as_slice() + ); + } + + #[test] + fn test_with_ed25519_verification_puts_the_ed25519_instruction_first() { + let verifier = Pubkey::new_unique(); + let proof = proof(); + let create = Instruction::new_with_bytes(Pubkey::new_unique(), &[36], vec![]); + + let pair = with_ed25519_verification(&verifier, &proof, create.clone()); + + assert_eq!(pair[0], ed25519_verification_instruction(&verifier, &proof)); + assert_eq!(pair[1], create); + } +} diff --git a/crates/doublezero-serviceability-instruction/src/lib.rs b/crates/doublezero-serviceability-instruction/src/lib.rs index 18e0b4bcc..d02fd089f 100644 --- a/crates/doublezero-serviceability-instruction/src/lib.rs +++ b/crates/doublezero-serviceability-instruction/src/lib.rs @@ -49,6 +49,7 @@ pub mod feed; pub mod globalconfig; pub mod globalstate; pub mod index; +pub mod ip_proof; pub mod link; pub mod location; pub mod migrate; diff --git a/crates/doublezero-serviceability-instruction/src/user.rs b/crates/doublezero-serviceability-instruction/src/user.rs index b904d5948..7fcfe1ac8 100644 --- a/crates/doublezero-serviceability-instruction/src/user.rs +++ b/crates/doublezero-serviceability-instruction/src/user.rs @@ -769,6 +769,120 @@ mod tests { assert!(!ix.accounts[11].is_writable); } + /// Legacy-transaction packet limit. A serviceability transaction has no address lookup + /// tables, so this is the hard ceiling on `dz_prefix_count`. + const MAX_TRANSACTION_SIZE: usize = 1232; + + /// Serialized size of the transaction the SDK actually sends: the compute-budget prelude, + /// then these instructions, signed by the payer alone. + fn transaction_size(payer: &Pubkey, instructions: &[Instruction]) -> usize { + let mut all = common::compute_budget_prelude().to_vec(); + all.extend_from_slice(instructions); + let message = solana_sdk::message::Message::new(&all, Some(payer)); + // One byte of signature count plus one 64-byte signature plus the message. + 1 + 64 + message.serialize().len() + } + + /// The largest `dz_prefix_count` for which `build` still fits one transaction. + fn max_dz_prefix_count(build: impl Fn(u8) -> (Pubkey, Vec)) -> u8 { + (1..=u8::MAX) + .take_while(|&count| { + let (payer, instructions) = build(count); + transaction_size(&payer, &instructions) <= MAX_TRANSACTION_SIZE + }) + .last() + .expect("a single dz_prefix must always fit") + } + + /// RFC-27 costs ~300 bytes of packet: the 111-byte `Option` in the args, a + /// 169-byte Ed25519 instruction, and two more account keys (the Instructions sysvar and the + /// Ed25519 program). That is worth about eleven `dz_prefix_block` slots, and these numbers pin + /// the remaining headroom so a future field cannot quietly eat the rest of it. Real devices + /// carry one or two prefixes, so the margin is large either way. + #[test] + fn test_create_user_with_a_proof_leaves_dz_prefix_headroom() { + let client_ip = Ipv4Addr::new(192, 168, 1, 10); + let build = |ip_proof: Option| { + move |dz_prefix_count: u8| { + let pid = Pubkey::new_unique(); + let payer = Pubkey::new_unique(); + let ix = create_user( + &pid, + &payer, + &Pubkey::new_unique(), + &Pubkey::new_unique(), + dz_prefix_count, + Some(Pubkey::new_unique()), + UserCreateArgs { + ip_proof, + ..create_args(client_ip) + }, + ); + let instructions = match ip_proof { + Some(proof) => crate::ip_proof::with_ed25519_verification( + &Pubkey::new_unique(), + &proof, + ix, + ) + .to_vec(), + None => vec![ix], + }; + (payer, instructions) + } + }; + + assert_eq!(max_dz_prefix_count(build(None)), 21); + assert_eq!( + max_dz_prefix_count(build(Some(dummy_proof(client_ip)))), + 10, + "RFC-27 must leave room for a realistic dz_prefix_count" + ); + } + + #[test] + fn test_create_subscribe_user_with_a_proof_leaves_dz_prefix_headroom() { + let client_ip = Ipv4Addr::new(192, 168, 1, 10); + let build = |ip_proof: Option| { + move |dz_prefix_count: u8| { + let pid = Pubkey::new_unique(); + let payer = Pubkey::new_unique(); + let feed = Pubkey::new_unique(); + let ix = create_subscribe_user( + &pid, + &payer, + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + dz_prefix_count, + &[], + Some(&feed), + UserCreateSubscribeArgs { + ip_proof, + owner: Pubkey::new_unique(), + ..base_args(client_ip) + }, + ); + let instructions = match ip_proof { + Some(proof) => crate::ip_proof::with_ed25519_verification( + &Pubkey::new_unique(), + &proof, + ix, + ) + .to_vec(), + None => vec![ix], + }; + (payer, instructions) + } + }; + + assert_eq!(max_dz_prefix_count(build(None)), 19); + assert_eq!( + max_dz_prefix_count(build(Some(dummy_proof(client_ip)))), + 8, + "RFC-27 must leave room for a realistic dz_prefix_count" + ); + } + fn create_args(client_ip: Ipv4Addr) -> UserCreateArgs { UserCreateArgs { user_type: UserType::IBRLWithAllocatedIP, diff --git a/sdk/serviceability/testdata/fixtures/generate-fixtures/Cargo.lock b/sdk/serviceability/testdata/fixtures/generate-fixtures/Cargo.lock index d28082c62..e620a2546 100644 --- a/sdk/serviceability/testdata/fixtures/generate-fixtures/Cargo.lock +++ b/sdk/serviceability/testdata/fixtures/generate-fixtures/Cargo.lock @@ -361,8 +361,10 @@ dependencies = [ name = "doublezero-serviceability-instruction" version = "0.37.0" dependencies = [ + "doublezero-ip-proof", "doublezero-serviceability", "solana-compute-budget-interface", + "solana-ed25519-program", "solana-program", "solana-system-interface 3.2.0", ] @@ -1018,6 +1020,18 @@ version = "5.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21e14a4f604117f379840956a8fc8695e4c84f5b0ebed192f31f60d9b85d581d" +[[package]] +name = "solana-ed25519-program" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1419197f1c06abf760043f6d64ba9d79a03ad5a43f18c7586471937122094da" +dependencies = [ + "bytemuck", + "bytemuck_derive", + "solana-instruction", + "solana-sdk-ids", +] + [[package]] name = "solana-epoch-rewards" version = "3.0.2" diff --git a/smartcontract/cli/src/user/create.rs b/smartcontract/cli/src/user/create.rs index cc1101430..253567b1a 100644 --- a/smartcontract/cli/src/user/create.rs +++ b/smartcontract/cli/src/user/create.rs @@ -108,6 +108,7 @@ impl CreateUserCliCommand { client_ip: self.client_ip, tunnel_endpoint: Ipv4Addr::UNSPECIFIED, tenant_pk, + ip_proof: None, })?; writeln!(out, "Signature: {signature}",)?; @@ -242,6 +243,7 @@ mod tests { client_ip: [100, 0, 0, 1].into(), tenant_pk: None, tunnel_endpoint: Ipv4Addr::UNSPECIFIED, + ip_proof: None, })) .times(1) .returning(move |_| Ok((signature, pda_pubkey))); diff --git a/smartcontract/cli/src/user/create_subscribe.rs b/smartcontract/cli/src/user/create_subscribe.rs index e6877dd9a..ba7c8f965 100644 --- a/smartcontract/cli/src/user/create_subscribe.rs +++ b/smartcontract/cli/src/user/create_subscribe.rs @@ -132,6 +132,7 @@ impl CreateSubscribeUserCliCommand { tunnel_endpoint: Ipv4Addr::UNSPECIFIED, owner: owner_pk, feed_pk, + ip_proof: None, })?; writeln!(out, "Signature: {signature}",)?; @@ -264,6 +265,7 @@ mod tests { tunnel_endpoint: Ipv4Addr::UNSPECIFIED, owner: None, feed_pk: None, + ip_proof: None, })) .times(1) .returning(move |_| Ok((signature, pda_pubkey))); @@ -387,6 +389,7 @@ mod tests { tunnel_endpoint: Ipv4Addr::UNSPECIFIED, owner: None, feed_pk: Some(feed_pubkey), + ip_proof: None, })) .times(1) .returning(move |_| Ok((signature, pda_pubkey))); @@ -523,6 +526,7 @@ mod tests { tunnel_endpoint: Ipv4Addr::UNSPECIFIED, owner: None, feed_pk: Some(resolved_feed_pubkey), + ip_proof: None, })) .times(1) .returning(move |_| Ok((signature, pda_pubkey))); diff --git a/smartcontract/programs/doublezero-serviceability/tests/rfc26_builders_test.rs b/smartcontract/programs/doublezero-serviceability/tests/rfc26_builders_test.rs index 56609e31e..a2e540718 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/rfc26_builders_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/rfc26_builders_test.rs @@ -11,8 +11,11 @@ //! `DoubleZeroInstruction` path; only the builder under test goes through `dzi::`. //! //! The builders own the trailing `[payer, system]` accounts; this test only -//! prepends the compute-budget prelude, signs with the payer, and submits. +//! prepends the compute-budget prelude, signs with the payer, and submits — with +//! one exception, the RFC-27 user creations, which also carry the Ed25519 +//! instruction their `ip_proof` needs (`dzi::ip_proof`). +use doublezero_ip_proof::sign; use doublezero_serviceability::{ instructions::DoubleZeroInstruction, pda::{ @@ -25,6 +28,7 @@ use doublezero_serviceability::{ create::DeviceCreateArgs, interface::create::DeviceInterfaceCreateArgs, update::DeviceUpdateArgs, }, + globalstate::{setauthority::SetAuthorityArgs, setfeatureflags::SetFeatureFlagsArgs}, link::create::LinkCreateArgs, location::{create::LocationCreateArgs, suspend::LocationSuspendArgs}, multicastgroup::{ @@ -32,12 +36,13 @@ use doublezero_serviceability::{ create::MulticastGroupCreateArgs, }, topology::{clear::TopologyClearArgs, create::TopologyCreateArgs}, - user::create_subscribe::UserCreateSubscribeArgs, + user::{create::UserCreateArgs, create_subscribe::UserCreateSubscribeArgs}, }, resource::ResourceType, state::{ accesspass::AccessPassType, device::{DeviceDesiredStatus, DeviceStatus, DeviceType}, + feature_flags::FeatureFlag, interface::{InterfaceCYOA, InterfaceDIA, LoopbackType, RoutingMode}, link::{LinkDesiredStatus, LinkLinkType, LinkStatus}, topology::TopologyConstraint, @@ -64,9 +69,20 @@ async fn submit( payer: &Keypair, ix: Instruction, ) -> Result<(), BanksClientError> { - let mut ixs = dzi::compute_budget_prelude().to_vec(); - ixs.push(ix); - let mut tx = Transaction::new_with_payer(&ixs, Some(&payer.pubkey())); + submit_all(banks_client, payer, &[ix]).await +} + +/// The same, for a builder whose instruction only means something alongside another one — RFC-27 +/// user creation, which rides with the native `Ed25519SigVerify` instruction the program +/// introspects the Instructions sysvar to find. +async fn submit_all( + banks_client: &mut BanksClient, + payer: &Keypair, + ixs: &[Instruction], +) -> Result<(), BanksClientError> { + let mut all = dzi::compute_budget_prelude().to_vec(); + all.extend_from_slice(ixs); + let mut tx = Transaction::new_with_payer(&all, Some(&payer.pubkey())); let blockhash = banks_client.get_latest_blockhash().await.unwrap(); tx.sign(&[payer], blockhash); banks_client.process_transaction(tx).await @@ -666,6 +682,275 @@ async fn test_builder_create_subscribe_user() { assert_eq!(user.subscribers, vec![mgroup_pubkey]); } +/// RFC-27 end to end through the builders (issue #4200): both user-creation builders carrying a +/// real `IpOwnershipProof`, paired with the Ed25519 instruction by +/// `dzi::ip_proof::with_ed25519_verification`, against a program with +/// `require-ip-ownership-proof` set. +/// +/// `user_ip_proof_test.rs` covers the validation rules with hand-assembled transactions. What is +/// only testable here is that the *builders* produce a transaction the program accepts: the +/// Instructions sysvar in the account slot the processor peels it from, and a precompile +/// instruction whose offsets the program's scan approves. Both instructions run in one fixture +/// because they share every prerequisite. +#[tokio::test] +async fn test_builder_user_creation_with_ip_proof() { + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + let blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + let (location_pubkey, exchange_pubkey, contributor_pubkey) = setup_device_prerequisites( + &mut banks_client, + blockhash, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + let device_pubkey = create_activated_device( + &mut banks_client, + blockhash, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + contributor_pubkey, + location_pubkey, + exchange_pubkey, + &payer, + "pdev", + [100, 0, 0, 1], + "110.2.0.0/24", + ) + .await; + + // Raise device max_users so both users can attach (UpdateDevice passes the current location + // as both old and new location). + execute_transaction( + &mut banks_client, + blockhash, + program_id, + DoubleZeroInstruction::UpdateDevice(DeviceUpdateArgs { + max_users: Some(128), + ..DeviceUpdateArgs::default() + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(location_pubkey, false), + AccountMeta::new(location_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Activated multicast group, for the CreateSubscribeUser half. + let mgroup_index = get_globalstate(&mut banks_client, globalstate_pubkey) + .await + .account_index + + 1; + let (mgroup_pubkey, _) = get_multicastgroup_pda(&program_id, mgroup_index); + execute_transaction( + &mut banks_client, + blockhash, + program_id, + DoubleZeroInstruction::CreateMulticastGroup(MulticastGroupCreateArgs { + code: "group27".to_string(), + max_bandwidth: 1000, + owner: payer.pubkey(), + use_onchain_allocation: true, + }), + vec![ + AccountMeta::new(mgroup_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new( + get_resource_extension_pda(&program_id, ResourceType::MulticastGroupBlock).0, + false, + ), + ], + &payer, + ) + .await; + + // One AccessPass per address: the pass PDA is keyed on (client_ip, payer), and the two users + // deliberately sit on different addresses so neither creation can absorb the other. + let unicast_ip: Ipv4Addr = [100, 0, 0, 5].into(); + let multicast_ip: Ipv4Addr = [100, 0, 0, 6].into(); + let mut accesspasses = Vec::new(); + for client_ip in [unicast_ip, multicast_ip] { + let (accesspass_pubkey, _) = get_accesspass_pda(&program_id, &client_ip, &payer.pubkey()); + execute_transaction( + &mut banks_client, + blockhash, + program_id, + DoubleZeroInstruction::SetAccessPass(SetAccessPassArgs { + accesspass_type: AccessPassType::Prepaid, + client_ip, + last_access_epoch: 9999, + allow_multiple_ip: false, + max_unicast_users: 1, + max_multicast_users: 1, + }), + vec![ + AccountMeta::new(accesspass_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(payer.pubkey(), false), + ], + &payer, + ) + .await; + accesspasses.push(accesspass_pubkey); + } + let (unicast_accesspass, multicast_accesspass) = (accesspasses[0], accesspasses[1]); + + execute_transaction( + &mut banks_client, + blockhash, + program_id, + DoubleZeroInstruction::AddMulticastGroupSubAllowlist(AddMulticastGroupSubAllowlistArgs { + client_ip: multicast_ip, + user_payer: payer.pubkey(), + }), + vec![ + AccountMeta::new(mgroup_pubkey, false), + AccountMeta::new(multicast_accesspass, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(payer.pubkey(), false), + ], + &payer, + ) + .await; + + // Trust root, then enforcement. With the flag set a creation without a valid proof is + // rejected, so both submissions below only pass if the builders assembled a working pair. + let verifier = Keypair::new(); + execute_transaction( + &mut banks_client, + blockhash, + program_id, + DoubleZeroInstruction::SetAuthority(SetAuthorityArgs { + ip_verifier_authority_pk: Some(verifier.pubkey()), + ..Default::default() + }), + vec![AccountMeta::new(globalstate_pubkey, false)], + &payer, + ) + .await; + execute_transaction( + &mut banks_client, + blockhash, + program_id, + DoubleZeroInstruction::SetFeatureFlags(SetFeatureFlagsArgs { + feature_flags: FeatureFlag::RequireIpOwnershipProof.to_mask(), + }), + vec![AccountMeta::new(globalstate_pubkey, false)], + &payer, + ) + .await; + + let epoch = banks_client + .get_sysvar::() + .await + .expect("clock sysvar") + .epoch; + + // CreateUser via the builder, with a proof. + let unicast_proof = sign( + &verifier, + &payer.pubkey(), + &unicast_ip, + epoch, + UserType::IBRL as u8, + ); + let create_user_ix = dzi::user::create_user( + &program_id, + &payer.pubkey(), + &device_pubkey, + &unicast_accesspass, + 1, + None, + UserCreateArgs { + user_type: UserType::IBRL, + cyoa_type: UserCYOA::GREOverDIA, + client_ip: unicast_ip, + tunnel_endpoint: Ipv4Addr::UNSPECIFIED, + dz_prefix_count: 1, + ip_proof: Some(unicast_proof), + }, + ); + submit_all( + &mut banks_client, + &payer, + &dzi::ip_proof::with_ed25519_verification( + &verifier.pubkey(), + &unicast_proof, + create_user_ix, + ), + ) + .await + .expect("create_user with a proof should be accepted by the program"); + + let (unicast_user_pubkey, _) = get_user_pda(&program_id, &unicast_ip, UserType::IBRL); + let unicast_user = get_account_data(&mut banks_client, unicast_user_pubkey) + .await + .expect("unicast user should exist") + .get_user() + .unwrap(); + assert_eq!(unicast_user.status, UserStatus::Activated); + + // CreateSubscribeUser via the builder, with a proof bound to its own user_type. + let multicast_proof = sign( + &verifier, + &payer.pubkey(), + &multicast_ip, + epoch, + UserType::Multicast as u8, + ); + let create_subscribe_ix = dzi::user::create_subscribe_user( + &program_id, + &payer.pubkey(), + &device_pubkey, + &mgroup_pubkey, + &multicast_accesspass, + 1, + &[], + None, + UserCreateSubscribeArgs { + user_type: UserType::Multicast, + cyoa_type: UserCYOA::GREOverDIA, + client_ip: multicast_ip, + publisher: false, + subscriber: true, + tunnel_endpoint: Ipv4Addr::UNSPECIFIED, + dz_prefix_count: 1, + owner: Pubkey::default(), + ip_proof: Some(multicast_proof), + extra_group_count: 0, + }, + ); + submit_all( + &mut banks_client, + &payer, + &dzi::ip_proof::with_ed25519_verification( + &verifier.pubkey(), + &multicast_proof, + create_subscribe_ix, + ), + ) + .await + .expect("create_subscribe_user with a proof should be accepted by the program"); + + let (multicast_user_pubkey, _) = get_user_pda(&program_id, &multicast_ip, UserType::Multicast); + let multicast_user = get_account_data(&mut banks_client, multicast_user_pubkey) + .await + .expect("multicast user should exist") + .get_user() + .unwrap(); + assert_eq!(multicast_user.status, UserStatus::Activated); + assert_eq!(multicast_user.subscribers, vec![mgroup_pubkey]); +} + /// Composed builder with variable onchain-read owners (`delete_device`, atomic /// path): closes an activated device together with its resource accounts. The /// owners are read from chain in processor order and passed to the builder. diff --git a/smartcontract/sdk/rs/Cargo.toml b/smartcontract/sdk/rs/Cargo.toml index 8ff18bf4f..ed32f8d5a 100644 --- a/smartcontract/sdk/rs/Cargo.toml +++ b/smartcontract/sdk/rs/Cargo.toml @@ -25,6 +25,11 @@ dirs-next.workspace = true doublezero-cli-core = { workspace = true, optional = true } doublezero-config.workspace = true doublezero-geolocation = { workspace = true, features = ["no-entrypoint"] } +# RFC-27 proof struct carried by the user-creation commands. `signer` for the verification +# half only: the SDK never issues a proof, but it checks the one it was handed against the +# onchain verifier key before paying for a transaction the precompile would reject. Safe for +# the BPF build — `cargo build-sbf` does not pull this crate into the program's graph. +doublezero-ip-proof = { workspace = true, features = ["signer"] } doublezero-program-common.workspace = true doublezero-record.workspace = true doublezero-serviceability.workspace = true diff --git a/smartcontract/sdk/rs/src/client.rs b/smartcontract/sdk/rs/src/client.rs index 6d2da042c..3a18789e6 100644 --- a/smartcontract/sdk/rs/src/client.rs +++ b/smartcontract/sdk/rs/src/client.rs @@ -193,22 +193,28 @@ impl DZClient { } } - /// Send a pre-built serviceability [`Instruction`] (RFC-26): prepend the - /// compute-budget prelude, sign with the payer, and send. The builder owns the + /// Send pre-built serviceability [`Instruction`]s (RFC-26): prepend the + /// compute-budget prelude, sign with the payer, and send. The builders own the /// account layout (including the trailing `[payer, system]`), so this path does /// no account assembly and no permission resolution. Single send attempt. - fn send_transaction_inner(&self, ix: Instruction) -> eyre::Result { + /// + /// Almost every caller passes one instruction. RFC-27 user creation passes two: + /// the native `Ed25519SigVerify` instruction and the creation it authorizes, + /// which have to land together or not at all. + fn send_transaction_inner(&self, ixs: Vec) -> eyre::Result { let payer = self .payer .as_ref() .ok_or_eyre("No default signer found, run \"doublezero keygen\" to create a new one")?; - self.maybe_invalidate_permission_cache(&ix); + for ix in &ixs { + self.maybe_invalidate_permission_cache(ix); + } // Prepend the shared RFC-26 compute-budget prelude (protocol-max compute - // and heap) over the built instruction — same helper the builders document. + // and heap) over the built instructions — same helper the builders document. let mut instructions = compute_budget_prelude().to_vec(); - instructions.push(ix); + instructions.extend(ixs); let mut transaction = Transaction::new_with_payer(&instructions, Some(&payer.pubkey())); let blockhash = self.client.get_latest_blockhash().map_err(|e| eyre!(e))?; @@ -261,8 +267,15 @@ impl DZClient { eprintln!("{log}"); } - if let TransactionError::InstructionError(_index, InstructionError::Custom(number)) = err { - return Err(eyre!(DoubleZeroError::from(number))); + // `Custom` numbers are defined by whichever program raised them. An RFC-27 transaction + // also carries the native Ed25519 instruction, whose `PrecompileError` shares that space, + // so mapping every `Custom` through `DoubleZeroError` would print an unrelated + // serviceability error for a precompile failure. Only serviceability's own instructions + // are mapped; anything else is reported as the runtime described it. + if let TransactionError::InstructionError(index, InstructionError::Custom(number)) = err { + if transaction.message.program_id(index as usize) == Some(&self.program_id) { + return Err(eyre!(DoubleZeroError::from(number))); + } } Err(eyre!(err)) } @@ -568,7 +581,11 @@ impl DoubleZeroClient for DZClient { } fn send_transaction(&self, instruction: Instruction) -> eyre::Result { - self.send_transaction_inner(instruction) + self.send_transaction_inner(vec![instruction]) + } + + fn send_instructions(&self, instructions: Vec) -> eyre::Result { + self.send_transaction_inner(instructions) } fn gets(&self, account_type: AccountType) -> eyre::Result> { diff --git a/smartcontract/sdk/rs/src/commands/user/create.rs b/smartcontract/sdk/rs/src/commands/user/create.rs index bbabcb0b8..ae2ba7415 100644 --- a/smartcontract/sdk/rs/src/commands/user/create.rs +++ b/smartcontract/sdk/rs/src/commands/user/create.rs @@ -1,7 +1,11 @@ use crate::{ - commands::{accesspass::get::GetAccessPassCommand, device::get::GetDeviceCommand}, + commands::{ + accesspass::get::GetAccessPassCommand, device::get::GetDeviceCommand, + user::instructions_with_ip_proof, + }, DoubleZeroClient, }; +use doublezero_ip_proof::IpOwnershipProof; use doublezero_serviceability::{ pda::get_user_pda, processors::user::create::UserCreateArgs, @@ -19,6 +23,12 @@ pub struct CreateUserCommand { pub client_ip: Ipv4Addr, pub tunnel_endpoint: Ipv4Addr, pub tenant_pk: Option, + /// RFC-27 proof that the payer originated a request from `client_ip`, obtained from the + /// DoubleZero IP verification service. Supplying it also pulls the Instructions sysvar into + /// the account list and a native `Ed25519SigVerify` instruction into the transaction; leaving + /// it `None` produces the pre-RFC-27 shape, which the program accepts until + /// `require-ip-ownership-proof` is set for the environment. + pub ip_proof: Option, } impl CreateUserCommand { @@ -68,20 +78,34 @@ impl CreateUserCommand { client_ip: self.client_ip, tunnel_endpoint: self.tunnel_endpoint, dz_prefix_count: dz_prefix_count_u8, - ip_proof: None, + ip_proof: self.ip_proof, }, ); - client.send_transaction(ix).map(|sig| (sig, pda_pubkey)) + let signature = match &self.ip_proof { + Some(proof) => client.send_instructions(instructions_with_ip_proof( + client, + proof, + &client.get_payer(), + &self.client_ip, + self.user_type as u8, + ix, + )?)?, + None => client.send_transaction(ix)?, + }; + + Ok((signature, pda_pubkey)) } } #[cfg(test)] mod tests { use crate::{ - commands::user::create::CreateUserCommand, tests::utils::create_test_client, + commands::user::create::CreateUserCommand, + tests::utils::{create_test_client, create_test_client_with_ip_verifier}, DoubleZeroClient, }; + use doublezero_ip_proof::IpOwnershipProof; use doublezero_serviceability::{ pda::get_accesspass_pda, processors::user::create::UserCreateArgs, @@ -93,9 +117,15 @@ mod tests { user::{UserCYOA, UserType}, }, }; - use doublezero_serviceability_instruction::user::create_user; + use doublezero_serviceability_instruction::{ + ip_proof::with_ed25519_verification, user::create_user, + }; use mockall::predicate; - use solana_sdk::{pubkey::Pubkey, signature::Signature}; + use solana_sdk::{ + pubkey::Pubkey, + signature::{Keypair, Signature}, + signer::Signer, + }; use std::net::Ipv4Addr; #[test] @@ -179,9 +209,264 @@ mod tests { client_ip, tunnel_endpoint: Ipv4Addr::UNSPECIFIED, tenant_pk: None, + ip_proof: None, } .execute(&client); assert!(res.is_ok()); } + + /// The RFC-27 path. The proof rides in the args (which is what makes the builder append the + /// Instructions sysvar) and the transaction gains the Ed25519 instruction the program looks + /// for, with the verifier key read from GlobalState rather than supplied by the caller. + #[test] + fn test_commands_user_create_with_ip_proof() { + let verifier = Keypair::new(); + let mut client = create_test_client_with_ip_verifier(verifier.pubkey()); + + let program_id = client.get_program_id(); + let payer = client.get_payer(); + let device_pk = Pubkey::new_unique(); + let client_ip = Ipv4Addr::new(192, 168, 1, 10); + + seed_accesspass_and_device(&mut client, program_id, payer, device_pk, client_ip); + + let proof = proof_for(&verifier, payer, client_ip); + let expected_create = create_user( + &program_id, + &payer, + &device_pk, + &get_accesspass_pda(&program_id, &client_ip, &payer).0, + 1, + None, + UserCreateArgs { + user_type: UserType::IBRLWithAllocatedIP, + cyoa_type: UserCYOA::GREOverDIA, + client_ip, + tunnel_endpoint: Ipv4Addr::UNSPECIFIED, + dz_prefix_count: 1, + ip_proof: Some(proof), + }, + ); + let expected = + with_ed25519_verification(&verifier.pubkey(), &proof, expected_create.clone()).to_vec(); + client + .expect_send_instructions() + .with(predicate::eq(expected)) + .returning(|_| Ok(Signature::new_unique())); + + let res = command(client_ip, device_pk, Some(proof)).execute(&client); + assert!(res.is_ok(), "{res:?}"); + } + + /// An unconfigured verifier key is a local failure, not a paid-for transaction that the + /// program rejects with `IpVerifierNotConfigured`. + #[test] + fn test_commands_user_create_with_ip_proof_rejects_unset_verifier() { + let mut client = create_test_client(); + + let program_id = client.get_program_id(); + let payer = client.get_payer(); + let device_pk = Pubkey::new_unique(); + let client_ip = Ipv4Addr::new(192, 168, 1, 10); + + seed_accesspass_and_device(&mut client, program_id, payer, device_pk, client_ip); + + let err = command( + client_ip, + device_pk, + Some(proof_for(&Keypair::new(), payer, client_ip)), + ) + .execute(&client) + .expect_err("an unset verifier key must not produce a transaction"); + assert!( + err.to_string().contains("No IP verifier authority"), + "{err}" + ); + } + + /// A proof issued for someone else can never validate, so it must not reach the chain. + #[test] + fn test_commands_user_create_with_ip_proof_rejects_foreign_payer() { + let mut client = create_test_client_with_ip_verifier(Pubkey::new_unique()); + + let program_id = client.get_program_id(); + let payer = client.get_payer(); + let device_pk = Pubkey::new_unique(); + let client_ip = Ipv4Addr::new(192, 168, 1, 10); + + seed_accesspass_and_device(&mut client, program_id, payer, device_pk, client_ip); + + let foreign = proof_for(&Keypair::new(), Pubkey::new_unique(), client_ip); + let err = command(client_ip, device_pk, Some(foreign)) + .execute(&client) + .expect_err("a proof naming another payer must not produce a transaction"); + assert!(err.to_string().contains("was issued for"), "{err}"); + } + + /// A proof for a different address pins a different User PDA than the one being created, so + /// the program would reject it. + #[test] + fn test_commands_user_create_with_ip_proof_rejects_mismatched_client_ip() { + let client_ip = Ipv4Addr::new(192, 168, 1, 10); + assert_proof_rejected(client_ip, |proof| { + proof.client_ip = Ipv4Addr::new(198, 51, 100, 4) + }); + } + + /// Same for the connection type: the User PDA is `f(client_ip, user_type)`, so a proof issued + /// for one type does not authorize another on the same address. + #[test] + fn test_commands_user_create_with_ip_proof_rejects_mismatched_user_type() { + let client_ip = Ipv4Addr::new(192, 168, 1, 10); + assert_proof_rejected(client_ip, |proof| { + proof.user_type = UserType::Multicast as u8 + }); + } + + /// Builds an otherwise valid request whose proof has been bent by `mutate`, and asserts the + /// command refuses it before any transaction is sent. + fn assert_proof_rejected(client_ip: Ipv4Addr, mutate: impl FnOnce(&mut IpOwnershipProof)) { + let verifier = Keypair::new(); + let mut client = create_test_client_with_ip_verifier(verifier.pubkey()); + + let program_id = client.get_program_id(); + let payer = client.get_payer(); + let device_pk = Pubkey::new_unique(); + + seed_accesspass_and_device(&mut client, program_id, payer, device_pk, client_ip); + + let mut proof = proof_for(&verifier, payer, client_ip); + mutate(&mut proof); + + let err = command(client_ip, device_pk, Some(proof)) + .execute(&client) + .expect_err("a mismatched proof must not produce a transaction"); + assert!(err.to_string().contains("was issued for"), "{err}"); + } + + /// A proof signed by a key that is no longer the onchain verifier can never validate. The + /// precompile would reject the transaction in the leader, and with `skip_preflight` that is + /// silent — no logs, no program error, just a confirmation timeout — so it has to fail here. + #[test] + fn test_commands_user_create_with_ip_proof_rejects_a_rotated_verifier_key() { + let mut client = create_test_client_with_ip_verifier(Keypair::new().pubkey()); + + let program_id = client.get_program_id(); + let payer = client.get_payer(); + let device_pk = Pubkey::new_unique(); + let client_ip = Ipv4Addr::new(192, 168, 1, 10); + + seed_accesspass_and_device(&mut client, program_id, payer, device_pk, client_ip); + + // Signed by the key GlobalState held before the rotation. + let stale = proof_for(&Keypair::new(), payer, client_ip); + let err = command(client_ip, device_pk, Some(stale)) + .execute(&client) + .expect_err("a proof signed by another key must not produce a transaction"); + assert!(err.to_string().contains("does not verify"), "{err}"); + } + + /// A proof from a newer verification service than this client understands: `signed_message` + /// would build v1 bytes over a v2 proof, so the message the program reconstructs and the one + /// the precompile verifies are unrelated. + #[test] + fn test_commands_user_create_with_ip_proof_rejects_unsupported_version() { + let client_ip = Ipv4Addr::new(192, 168, 1, 10); + let verifier = Keypair::new(); + let mut client = create_test_client_with_ip_verifier(verifier.pubkey()); + + let program_id = client.get_program_id(); + let payer = client.get_payer(); + let device_pk = Pubkey::new_unique(); + + seed_accesspass_and_device(&mut client, program_id, payer, device_pk, client_ip); + + let mut proof = proof_for(&verifier, payer, client_ip); + proof.version = 2; + + let err = command(client_ip, device_pk, Some(proof)) + .execute(&client) + .expect_err("an unsupported proof version must not produce a transaction"); + assert!(err.to_string().contains("version 2"), "{err}"); + } + + fn command( + client_ip: Ipv4Addr, + device_pk: Pubkey, + ip_proof: Option, + ) -> CreateUserCommand { + CreateUserCommand { + user_type: UserType::IBRLWithAllocatedIP, + device_pk, + cyoa_type: UserCYOA::GREOverDIA, + client_ip, + tunnel_endpoint: Ipv4Addr::UNSPECIFIED, + tenant_pk: None, + ip_proof, + } + } + + /// A proof the command will accept: signed by `verifier`, naming `payer` and `client_ip`. + fn proof_for(verifier: &Keypair, payer: Pubkey, client_ip: Ipv4Addr) -> IpOwnershipProof { + doublezero_ip_proof::sign( + verifier, + &payer, + &client_ip, + 931, + UserType::IBRLWithAllocatedIP as u8, + ) + } + + /// The reads every path through `execute` performs: the exact-IP access pass (after the + /// dynamic PDA misses) and the device, for its `dz_prefixes`. + fn seed_accesspass_and_device( + client: &mut crate::MockDoubleZeroClient, + program_id: Pubkey, + payer: Pubkey, + device_pk: Pubkey, + client_ip: Ipv4Addr, + ) { + let (accesspass_pubkey, _) = get_accesspass_pda(&program_id, &client_ip, &payer); + let accesspass = AccessPass { + account_type: AccountType::AccessPass, + bump_seed: 0, + accesspass_type: AccessPassType::Prepaid, + client_ip, + user_payer: payer, + last_access_epoch: 0, + connection_count: 0, + status: AccessPassStatus::Requested, + owner: payer, + mgroup_pub_allowlist: vec![], + mgroup_sub_allowlist: vec![], + tenant_allowlist: vec![], + flags: 0, + unicast_user_count: 0, + max_unicast_users: 1, + multicast_user_count: 0, + max_multicast_users: 1, + }; + client + .expect_get() + .with(predicate::eq(accesspass_pubkey)) + .returning(move |_| Ok(AccountData::AccessPass(accesspass.clone()))); + + let (dynamic_accesspass_pubkey, _) = + get_accesspass_pda(&program_id, &Ipv4Addr::UNSPECIFIED, &payer); + client + .expect_get() + .with(predicate::eq(dynamic_accesspass_pubkey)) + .returning(|_| Err(eyre::eyre!("account not found"))); + + let device = Device { + account_type: AccountType::Device, + dz_prefixes: "10.0.0.0/24".parse().unwrap(), + ..Default::default() + }; + client + .expect_get() + .with(predicate::eq(device_pk)) + .returning(move |_| Ok(AccountData::Device(device.clone()))); + } } diff --git a/smartcontract/sdk/rs/src/commands/user/create_subscribe.rs b/smartcontract/sdk/rs/src/commands/user/create_subscribe.rs index c8655da3d..18a07845d 100644 --- a/smartcontract/sdk/rs/src/commands/user/create_subscribe.rs +++ b/smartcontract/sdk/rs/src/commands/user/create_subscribe.rs @@ -1,3 +1,4 @@ +use doublezero_ip_proof::IpOwnershipProof; use doublezero_serviceability::{ pda::get_user_pda, processors::user::create_subscribe::UserCreateSubscribeArgs, @@ -14,7 +15,7 @@ use std::net::Ipv4Addr; use crate::{ commands::{ accesspass::get::GetAccessPassCommand, device::get::GetDeviceCommand, - multicastgroup::get::GetMulticastGroupCommand, + multicastgroup::get::GetMulticastGroupCommand, user::instructions_with_ip_proof, }, DoubleZeroClient, }; @@ -48,6 +49,15 @@ pub struct CreateSubscribeUserCommand { /// by the pass) covering the device's exchange and listing the target multicast group. /// Appended to the account list only when provided. pub feed_pk: Option, + /// RFC-27 proof that the user's owner originated a request from `client_ip`, obtained from the + /// DoubleZero IP verification service. Supplying it also pulls the Instructions sysvar into + /// the account list and a native `Ed25519SigVerify` instruction into the transaction; leaving + /// it `None` produces the pre-RFC-27 shape, which the program accepts until + /// `require-ip-ownership-proof` is set for the environment. + /// + /// On the `owner` override path the proof must name that owner, not the payer: the program + /// binds it to the user's effective owner. + pub ip_proof: Option, } impl CreateSubscribeUserCommand { @@ -138,11 +148,28 @@ impl CreateSubscribeUserCommand { tunnel_endpoint: self.tunnel_endpoint, dz_prefix_count: dz_prefix_count_u8, owner: self.owner.unwrap_or_default(), - ip_proof: None, + ip_proof: self.ip_proof, extra_group_count: 0, // derived by the builder from extra_mgroup_pks }, ); + // Pair the creation with the Ed25519 instruction that proves its proof before measuring: + // RFC-27 adds that instruction and two account keys to the same transaction, so the group + // cap below has to be judged against what actually goes on the wire. + let mut instructions = match &self.ip_proof { + // `accesspass_payer` is the user's effective owner — the same key the program binds + // the proof to. + Some(proof) => instructions_with_ip_proof( + client, + proof, + &accesspass_payer, + &self.client_ip, + self.user_type as u8, + ix, + )?, + None => vec![ix], + }; + // Unlike a role update, this transaction also carries the device, one account // per device dz_prefix, and an optional feed, so no fixed group cap can bound // it (16 groups fit a role update but overflow a create on a five-prefix @@ -150,10 +177,9 @@ impl CreateSubscribeUserCommand { // send_transaction builds, reserving room for the Permission PDA the builder // appends at the permission rollout. let [cu_limit, heap_frame] = compute_budget_prelude(); - let message = Message::new( - &[cu_limit, heap_frame, ix.clone()], - Some(&client.get_payer()), - ); + let mut all_instructions = vec![cu_limit, heap_frame]; + all_instructions.extend(instructions.iter().cloned()); + let message = Message::new(&all_instructions, Some(&client.get_payer())); let tx_size = 1 + 64 * usize::from(message.header.num_required_signatures) + message.serialize().len(); @@ -175,7 +201,13 @@ impl CreateSubscribeUserCommand { ); } - client.send_transaction(ix).map(|sig| (sig, pda_pubkey)) + let signature = match instructions.len() { + // No proof: the pre-RFC-27 single-instruction send, byte for byte. + 1 => client.send_transaction(instructions.pop().expect("length checked"))?, + _ => client.send_instructions(instructions)?, + }; + + Ok((signature, pda_pubkey)) } } @@ -183,8 +215,10 @@ impl CreateSubscribeUserCommand { mod tests { use crate::{ commands::user::create_subscribe::CreateSubscribeUserCommand, - tests::utils::create_test_client, DoubleZeroClient, + tests::utils::{create_test_client, create_test_client_with_ip_verifier}, + DoubleZeroClient, }; + use doublezero_ip_proof::IpOwnershipProof; use doublezero_serviceability::{ pda::get_accesspass_pda, processors::user::create_subscribe::UserCreateSubscribeArgs, @@ -197,9 +231,15 @@ mod tests { user::{UserCYOA, UserType}, }, }; - use doublezero_serviceability_instruction::user::create_subscribe_user; + use doublezero_serviceability_instruction::{ + ip_proof::with_ed25519_verification, user::create_subscribe_user, + }; use mockall::predicate; - use solana_sdk::{pubkey::Pubkey, signature::Signature}; + use solana_sdk::{ + pubkey::Pubkey, + signature::{Keypair, Signature}, + signer::Signer, + }; use std::net::Ipv4Addr; #[test] @@ -303,6 +343,7 @@ mod tests { tunnel_endpoint: Ipv4Addr::UNSPECIFIED, owner: None, feed_pk: None, + ip_proof: None, } .execute(&client); @@ -311,15 +352,18 @@ mod tests { /// Mock the lookups a create with `mgroup_pks` needs: every group Activated, the /// access pass at the exact-IP PDA, and a device advertising `dz_prefixes`. + /// + /// `accesspass_owner` is the key the pass belongs to — the payer, or the `--owner` + /// override on the foundation-allowlist path. fn expect_create_lookups( client: &mut crate::MockDoubleZeroClient, + accesspass_owner: Pubkey, mgroup_pks: &[Pubkey], device_pk: Pubkey, client_ip: Ipv4Addr, dz_prefixes: &str, ) { let program_id = client.get_program_id(); - let payer = client.get_payer(); for mgroup_pk in mgroup_pks.iter().copied() { let mgroup = MulticastGroup { @@ -332,17 +376,17 @@ mod tests { .returning(move |_| Ok(AccountData::MulticastGroup(mgroup.clone()))); } - let (accesspass_pubkey, _) = get_accesspass_pda(&program_id, &client_ip, &payer); + let (accesspass_pubkey, _) = get_accesspass_pda(&program_id, &client_ip, &accesspass_owner); let accesspass = AccessPass { account_type: AccountType::AccessPass, bump_seed: 0, accesspass_type: AccessPassType::Prepaid, client_ip, - user_payer: payer, + user_payer: accesspass_owner, last_access_epoch: 0, connection_count: 0, status: AccessPassStatus::Requested, - owner: payer, + owner: accesspass_owner, mgroup_pub_allowlist: vec![], mgroup_sub_allowlist: vec![], tenant_allowlist: vec![], @@ -357,7 +401,7 @@ mod tests { .with(predicate::eq(accesspass_pubkey)) .returning(move |_| Ok(AccountData::AccessPass(accesspass.clone()))); let (dynamic_accesspass_pubkey, _) = - get_accesspass_pda(&program_id, &Ipv4Addr::UNSPECIFIED, &payer); + get_accesspass_pda(&program_id, &Ipv4Addr::UNSPECIFIED, &accesspass_owner); client .expect_get() .with(predicate::eq(dynamic_accesspass_pubkey)) @@ -385,8 +429,10 @@ mod tests { let device_pk = Pubkey::new_unique(); let client_ip = Ipv4Addr::new(192, 168, 1, 10); let mgroup_pks: Vec = (0..16).map(|_| Pubkey::new_unique()).collect(); + let payer = client.get_payer(); expect_create_lookups( &mut client, + payer, &mgroup_pks, device_pk, client_ip, @@ -405,6 +451,7 @@ mod tests { tunnel_endpoint: Ipv4Addr::UNSPECIFIED, owner: None, feed_pk: Some(Pubkey::new_unique()), + ip_proof: None, } .execute(&client) .unwrap_err(); @@ -422,8 +469,10 @@ mod tests { let device_pk = Pubkey::new_unique(); let client_ip = Ipv4Addr::new(192, 168, 1, 10); let mgroup_pks: Vec = (0..8).map(|_| Pubkey::new_unique()).collect(); + let payer = client.get_payer(); expect_create_lookups( &mut client, + payer, &mgroup_pks, device_pk, client_ip, @@ -445,8 +494,134 @@ mod tests { tunnel_endpoint: Ipv4Addr::UNSPECIFIED, owner: None, feed_pk: Some(Pubkey::new_unique()), + ip_proof: None, } .execute(&client); assert!(res.is_ok(), "{res:?}"); } + + /// The RFC-27 path on the `--owner` override: the access pass, and therefore the proof, belong + /// to the owner, not the payer. The program binds the proof to the user's effective owner, so + /// a proof naming the payer here would be rejected onchain. + #[test] + fn test_commands_user_create_subscribe_with_ip_proof_for_the_owner() { + let verifier = Keypair::new(); + let mut client = create_test_client_with_ip_verifier(verifier.pubkey()); + + let program_id = client.get_program_id(); + let device_pk = Pubkey::new_unique(); + let mgroup_pk = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let client_ip = Ipv4Addr::new(192, 168, 1, 10); + + expect_create_lookups( + &mut client, + owner, + &[mgroup_pk], + device_pk, + client_ip, + "10.0.0.0/24", + ); + + let proof = proof_for(&verifier, owner, client_ip); + let expected_create = create_subscribe_user( + &program_id, + &client.get_payer(), + &device_pk, + &mgroup_pk, + &get_accesspass_pda(&program_id, &client_ip, &owner).0, + 1, + &[], + None, + UserCreateSubscribeArgs { + user_type: UserType::IBRLWithAllocatedIP, + cyoa_type: UserCYOA::GREOverDIA, + client_ip, + publisher: true, + subscriber: false, + tunnel_endpoint: Ipv4Addr::UNSPECIFIED, + dz_prefix_count: 1, + owner, + ip_proof: Some(proof), + extra_group_count: 0, + }, + ); + let expected = + with_ed25519_verification(&verifier.pubkey(), &proof, expected_create).to_vec(); + client + .expect_send_instructions() + .with(predicate::eq(expected)) + .returning(|_| Ok(Signature::new_unique())); + + let res = + command(client_ip, device_pk, mgroup_pk, Some(owner), Some(proof)).execute(&client); + assert!(res.is_ok(), "{res:?}"); + } + + /// On the override path a proof naming the payer instead of the owner is exactly the mistake + /// the local check exists to catch. + #[test] + fn test_commands_user_create_subscribe_with_ip_proof_rejects_payer_bound_proof() { + let verifier = Keypair::new(); + let mut client = create_test_client_with_ip_verifier(verifier.pubkey()); + + let payer = client.get_payer(); + let device_pk = Pubkey::new_unique(); + let mgroup_pk = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let client_ip = Ipv4Addr::new(192, 168, 1, 10); + + expect_create_lookups( + &mut client, + owner, + &[mgroup_pk], + device_pk, + client_ip, + "10.0.0.0/24", + ); + + let err = command( + client_ip, + device_pk, + mgroup_pk, + Some(owner), + Some(proof_for(&verifier, payer, client_ip)), + ) + .execute(&client) + .expect_err("a proof bound to the payer must not be sent for an owner-override user"); + assert!(err.to_string().contains("was issued for"), "{err}"); + } + + fn command( + client_ip: Ipv4Addr, + device_pk: Pubkey, + mgroup_pk: Pubkey, + owner: Option, + ip_proof: Option, + ) -> CreateSubscribeUserCommand { + CreateSubscribeUserCommand { + user_type: UserType::IBRLWithAllocatedIP, + device_pk, + cyoa_type: UserCYOA::GREOverDIA, + client_ip, + mgroup_pks: vec![mgroup_pk], + publisher: true, + subscriber: false, + tunnel_endpoint: Ipv4Addr::UNSPECIFIED, + owner, + feed_pk: None, + ip_proof, + } + } + + /// A proof the command will accept: signed by `verifier`, naming the user's effective owner. + fn proof_for(verifier: &Keypair, owner: Pubkey, client_ip: Ipv4Addr) -> IpOwnershipProof { + doublezero_ip_proof::sign( + verifier, + &owner, + &client_ip, + 931, + UserType::IBRLWithAllocatedIP as u8, + ) + } } diff --git a/smartcontract/sdk/rs/src/commands/user/mod.rs b/smartcontract/sdk/rs/src/commands/user/mod.rs index e7ccfc362..4211303d5 100644 --- a/smartcontract/sdk/rs/src/commands/user/mod.rs +++ b/smartcontract/sdk/rs/src/commands/user/mod.rs @@ -1,3 +1,9 @@ +use crate::{commands::globalstate::get::GetGlobalStateCommand, DoubleZeroClient}; +use doublezero_ip_proof::{is_supported_version, verify, IpOwnershipProof}; +use doublezero_serviceability_instruction::ip_proof::with_ed25519_verification; +use solana_sdk::{instruction::Instruction, pubkey::Pubkey}; +use std::net::Ipv4Addr; + pub mod check_access_pass; pub mod create; pub mod create_subscribe; @@ -6,3 +12,81 @@ pub mod get; pub mod list; pub mod requestban; pub mod update; + +/// Pairs an RFC-27 user creation with the native `Ed25519SigVerify` instruction that proves its +/// `IpOwnershipProof`, resolving the verifier key from GlobalState. +/// +/// Shared by `CreateUserCommand` and `CreateSubscribeUserCommand` for the same reason the program +/// validates both through one helper: the two must not drift. +/// +/// `proof_owner` is the key the proof has to name. The program binds the proof to the *effective +/// owner* of the user being created, not the transaction payer, which differ on +/// `CreateSubscribeUser`'s foundation-allowlist `--owner` path. +/// +/// The version and the three field comparisons mirror the program's own, and are made here rather +/// than left to it because onchain they surface as `IpProofVersionUnsupported`, +/// `IpProofPayerMismatch`, `IpProofClientIpMismatch` and `IpProofUserTypeMismatch` only after the +/// transaction has been paid for. The epoch window is deliberately not checked: the ledger's +/// current epoch is the program's to judge, and a proof this client thinks is stale may not be. +/// +/// The signature check has no onchain counterpart to fall back on. The program compares the proof's +/// signature against the Ed25519 instruction's, but a signature that does not verify never reaches +/// the program: the precompile rejects the transaction in the leader, and with `skip_preflight` +/// the caller sees a confirmation timeout with no logs and no error. That is the one failure in +/// this path that is otherwise invisible, and it is reachable in normal operation — a client +/// holding a proof signed by a verifier key that has since rotated. +pub(crate) fn instructions_with_ip_proof( + client: &dyn DoubleZeroClient, + proof: &IpOwnershipProof, + proof_owner: &Pubkey, + client_ip: &Ipv4Addr, + user_type: u8, + create_instruction: Instruction, +) -> eyre::Result> { + // The version travels in the proof so a v2 layout can roll out without an atomic cutover. + // Checked first, as the program does: every comparison below is about a message this client + // cannot even reconstruct for a version it does not know. + if !is_supported_version(proof.version) { + eyre::bail!( + "IP ownership proof version {} is not supported by this client", + proof.version + ); + } + if proof.payer != *proof_owner { + eyre::bail!( + "IP ownership proof was issued for {} but this user is created for {}", + proof.payer, + proof_owner + ); + } + if proof.client_ip != *client_ip { + eyre::bail!( + "IP ownership proof was issued for {} but this user is created for {client_ip}", + proof.client_ip + ); + } + if proof.user_type != user_type { + eyre::bail!( + "IP ownership proof was issued for user type {} but this user is created as {user_type}", + proof.user_type + ); + } + + // The verifier key travels neither in the proof nor in the service's response: a client reads + // it from GlobalState, the same place the program reads it. + let (_, globalstate) = GetGlobalStateCommand.execute(client)?; + let verifier = globalstate.ip_verifier_authority_pk; + if verifier == Pubkey::default() { + eyre::bail!( + "No IP verifier authority is configured onchain; cannot attach an IP ownership proof" + ); + } + + verify(proof, &verifier).map_err(|e| { + eyre::eyre!( + "IP ownership proof does not verify against the onchain verifier {verifier}: {e}" + ) + })?; + + Ok(with_ed25519_verification(&verifier, proof, create_instruction).to_vec()) +} diff --git a/smartcontract/sdk/rs/src/doublezeroclient.rs b/smartcontract/sdk/rs/src/doublezeroclient.rs index 44264aae6..ca7b88f17 100644 --- a/smartcontract/sdk/rs/src/doublezeroclient.rs +++ b/smartcontract/sdk/rs/src/doublezeroclient.rs @@ -40,6 +40,16 @@ pub trait DoubleZeroClient { /// accounts (RFC-26); the send path no longer touches account layout. fn send_transaction(&self, instruction: Instruction) -> eyre::Result; + /// The same path for a transaction that needs more than one instruction: the + /// compute-budget prelude, then `instructions` in the order given, signed and + /// sent as one atomic transaction. + /// + /// RFC-27 is why this exists — a user creation carrying an `IpOwnershipProof` + /// must ride alongside the native `Ed25519SigVerify` instruction that the + /// program introspects the Instructions sysvar to find, and the two only mean + /// anything together. + fn send_instructions(&self, instructions: Vec) -> eyre::Result; + fn get_transactions(&self, pubkey: Pubkey) -> eyre::Result>; } diff --git a/smartcontract/sdk/rs/src/tests.rs b/smartcontract/sdk/rs/src/tests.rs index 7248836cf..0ecaeb5a7 100644 --- a/smartcontract/sdk/rs/src/tests.rs +++ b/smartcontract/sdk/rs/src/tests.rs @@ -14,6 +14,15 @@ pub mod utils { }; pub fn create_test_client() -> MockDoubleZeroClient { + create_test_client_with_ip_verifier(Pubkey::default()) + } + + /// The same client with `globalstate.ip_verifier_authority_pk` set, for the RFC-27 paths that + /// read the verifier key to build the Ed25519 instruction. `Pubkey::default()` means "no + /// verifier configured", which those paths must refuse. + pub fn create_test_client_with_ip_verifier( + ip_verifier_authority_pk: Pubkey, + ) -> MockDoubleZeroClient { let mut client = MockDoubleZeroClient::new(); // Payer @@ -40,7 +49,7 @@ pub mod utils { qa_allowlist: vec![], feature_flags: 0, feed_authority_pk: Pubkey::default(), - ip_verifier_authority_pk: Pubkey::default(), + ip_verifier_authority_pk, }; client .expect_get()