Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,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

Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions crates/doublezero-daemon-cli/src/connect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1083,6 +1083,7 @@ impl Connect {
client_ip,
tunnel_endpoint,
tenant_pk: None,
ip_proof: None,
})?;
spinner.set_message("Multicast user created");
user_pk
Expand Down Expand Up @@ -1363,6 +1364,7 @@ impl Connect {
client_ip: *client_ip,
tunnel_endpoint,
tenant_pk,
ip_proof: None,
});

match res {
Expand Down Expand Up @@ -1478,6 +1480,7 @@ impl Connect {
tunnel_endpoint,
owner: None,
feed_pk: None,
ip_proof: None,
});

let user_pk = match res {
Expand Down Expand Up @@ -1623,6 +1626,7 @@ impl Connect {
tunnel_endpoint,
owner: None,
feed_pk: None,
ip_proof: None,
});

let user_pk = match res {
Expand Down Expand Up @@ -2744,6 +2748,7 @@ mod tests {
client_ip: user.client_ip,
tunnel_endpoint: user.tunnel_endpoint,
tenant_pk,
ip_proof: None,
};

let users = self.users.clone();
Expand Down Expand Up @@ -2781,6 +2786,7 @@ mod tests {
tunnel_endpoint: user.tunnel_endpoint,
owner: None,
feed_pk: None,
ip_proof: None,
};

let users = self.users.clone();
Expand Down
10 changes: 7 additions & 3 deletions crates/doublezero-serviceability-instruction/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
166 changes: 166 additions & 0 deletions crates/doublezero-serviceability-instruction/src/ip_proof.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
1 change: 1 addition & 0 deletions crates/doublezero-serviceability-instruction/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
113 changes: 113 additions & 0 deletions crates/doublezero-serviceability-instruction/src/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,119 @@ 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<Instruction>)) -> 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<IpOwnershipProof>` 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<IpOwnershipProof>| {
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<IpOwnershipProof>| {
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,
Expand Down
Loading
Loading