Skip to content
Merged
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
286 changes: 284 additions & 2 deletions Cargo.lock

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions config_spec.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,9 @@ name = "daemon"
type = "bool"
default = "false"
doc = "Run the server as a daemon"

[[param]]
name = "sp_keys_file"
type = "String"
optional = true
doc = "Path to a binary silent payments keys file to import at startup."
3 changes: 3 additions & 0 deletions crates/wallet/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ silentpayments = { version = "0.5", features = ["receiving", "encode"] }
bitcoin = "0.32.8"
bitcoinkernel = "0.2"
log = "0.4"

[dev-dependencies]
tempfile = "3"
7 changes: 7 additions & 0 deletions crates/wallet/src/io.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
use std::{io, path::Path};

pub trait FileExt: Sized {
type Error: From<io::Error>;
fn save(&self, path: &Path) -> Result<(), Self::Error>;
fn load(path: &Path) -> Result<Self, Self::Error>;
}
1 change: 1 addition & 0 deletions crates/wallet/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
pub mod io;
pub mod silentpayments;
236 changes: 236 additions & 0 deletions crates/wallet/src/silentpayments/keys_file.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
use std::{
fs,
io::{self, Write},
path::Path,
};

use bitcoin::secp256k1::{self, Secp256k1, SecretKey, XOnlyPublicKey};

use crate::io::FileExt;

const MAGIC: &[u8; 4] = b"SPKF";
const KEY_LEN: usize = 32;
const TAG_LEN: usize = 1;
const FILE_LEN: usize = MAGIC.len() + KEY_LEN + TAG_LEN + KEY_LEN;

const SPEND_TAG_SECRET: u8 = 0;
const SPEND_TAG_XONLY_PUB: u8 = 1;

/// `Secret` for software wallets. `XOnlyPublic` for hardware-signer / watch-only.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpendKey {
Secret(SecretKey),
XOnlyPublic(XOnlyPublicKey),
}

impl SpendKey {
pub fn xonly(&self) -> XOnlyPublicKey {
match self {
SpendKey::Secret(sk) => {
let secp = Secp256k1::signing_only();
sk.public_key(&secp).x_only_public_key().0
}
SpendKey::XOnlyPublic(pk) => *pk,
}
}
}

/// On-disk format: 4-byte magic `SPKF` (Silent Payment Key File), 32-byte scan
/// secret, 1-byte spend tag, 32-byte spend material. 69 bytes total.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SilentPaymentKeysFile {
pub scan_key: SecretKey,
pub spend: SpendKey,
}

#[derive(Debug)]
pub enum KeysFileError {
Io(io::Error),
BadMagic,
WrongLength { expected: usize, actual: usize },
UnknownSpendTag(u8),
InvalidKey(secp256k1::Error),
}

impl std::fmt::Display for KeysFileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
KeysFileError::Io(e) => write!(f, "i/o error: {e}"),
KeysFileError::BadMagic => write!(f, "bad magic: not a silent payments keys file"),
KeysFileError::WrongLength { expected, actual } => write!(
f,
"wrong file length: expected {expected} bytes, got {actual}"
),
KeysFileError::UnknownSpendTag(t) => write!(f, "unknown spend tag: {t}"),
KeysFileError::InvalidKey(e) => write!(f, "invalid key: {e}"),
}
}
}

impl std::error::Error for KeysFileError {}

impl From<io::Error> for KeysFileError {
fn from(e: io::Error) -> Self {
KeysFileError::Io(e)
}
}

impl From<secp256k1::Error> for KeysFileError {
fn from(e: secp256k1::Error) -> Self {
KeysFileError::InvalidKey(e)
}
}

impl SilentPaymentKeysFile {
pub fn new(scan_key: SecretKey, spend: SpendKey) -> Self {
Self { scan_key, spend }
}

pub fn spend_xonly(&self) -> XOnlyPublicKey {
self.spend.xonly()
}

pub fn to_bytes(&self) -> [u8; FILE_LEN] {
let mut buf = [0u8; FILE_LEN];
let mut off = 0;
buf[off..off + MAGIC.len()].copy_from_slice(MAGIC);
off += MAGIC.len();
buf[off..off + KEY_LEN].copy_from_slice(&self.scan_key.secret_bytes());
off += KEY_LEN;
match &self.spend {
SpendKey::Secret(sk) => {
buf[off] = SPEND_TAG_SECRET;
buf[off + TAG_LEN..].copy_from_slice(&sk.secret_bytes());
}
SpendKey::XOnlyPublic(pk) => {
buf[off] = SPEND_TAG_XONLY_PUB;
buf[off + TAG_LEN..].copy_from_slice(&pk.serialize());
}
}
buf
}

pub fn from_bytes(bytes: &[u8]) -> Result<Self, KeysFileError> {
if bytes.len() != FILE_LEN {
return Err(KeysFileError::WrongLength {
expected: FILE_LEN,
actual: bytes.len(),
});
}
let body = bytes
.strip_prefix(MAGIC.as_slice())
.ok_or(KeysFileError::BadMagic)?;
let scan_key = SecretKey::from_slice(&body[..KEY_LEN])?;
let tag = body[KEY_LEN];
let spend_bytes = &body[KEY_LEN + TAG_LEN..];
let spend = match tag {
SPEND_TAG_SECRET => SpendKey::Secret(SecretKey::from_slice(spend_bytes)?),
SPEND_TAG_XONLY_PUB => SpendKey::XOnlyPublic(XOnlyPublicKey::from_slice(spend_bytes)?),
other => return Err(KeysFileError::UnknownSpendTag(other)),
};
Ok(Self { scan_key, spend })
}
}

impl FileExt for SilentPaymentKeysFile {
type Error = KeysFileError;

/// Refuses to overwrite (`AlreadyExists`).
fn save(&self, path: &Path) -> Result<(), Self::Error> {
Comment thread
rustaceanrob marked this conversation as resolved.
let mut file = fs::File::create_new(path)?;
file.write_all(&self.to_bytes())?;
Ok(())
}

fn load(path: &Path) -> Result<Self, Self::Error> {
let bytes = fs::read(path)?;
Self::from_bytes(&bytes)
}
}

#[cfg(test)]
mod tests {
use super::*;

fn test_keys() -> (SecretKey, SecretKey) {
let scan = SecretKey::from_slice(&[1u8; 32]).expect("valid scan key");
let spend = SecretKey::from_slice(&[2u8; 32]).expect("valid spend key");
(scan, spend)
}

fn xonly_from(sk: SecretKey) -> XOnlyPublicKey {
sk.public_key(&Secp256k1::signing_only())
.x_only_public_key()
.0
}

#[test]
fn roundtrip_preserves_secret_variant() {
let (scan, spend) = test_keys();
let original = SilentPaymentKeysFile::new(scan, SpendKey::Secret(spend));
let bytes = original.to_bytes();
let decoded = SilentPaymentKeysFile::from_bytes(&bytes).expect("decode");
assert_eq!(original, decoded);
}

#[test]
fn roundtrip_preserves_xonly_public_variant() {
let (scan, spend) = test_keys();
let xonly = xonly_from(spend);
let original = SilentPaymentKeysFile::new(scan, SpendKey::XOnlyPublic(xonly));
let bytes = original.to_bytes();
let decoded = SilentPaymentKeysFile::from_bytes(&bytes).expect("decode");
assert_eq!(original, decoded);
}

#[test]
fn from_bytes_rejects_bad_magic() {
let (scan, spend) = test_keys();
let mut bytes = b"XXXX".to_vec();
bytes.extend_from_slice(&scan.secret_bytes());
bytes.push(SPEND_TAG_SECRET);
bytes.extend_from_slice(&spend.secret_bytes());
assert!(matches!(
SilentPaymentKeysFile::from_bytes(&bytes),
Err(KeysFileError::BadMagic)
));
}

#[test]
fn from_bytes_rejects_unknown_spend_tag() {
let (scan, spend) = test_keys();
let mut bytes = MAGIC.to_vec();
bytes.extend_from_slice(&scan.secret_bytes());
bytes.push(99);
bytes.extend_from_slice(&spend.secret_bytes());
assert!(matches!(
SilentPaymentKeysFile::from_bytes(&bytes),
Err(KeysFileError::UnknownSpendTag(99))
));
}

#[test]
fn save_load_roundtrips_through_disk() {
let (scan, spend) = test_keys();
let original = SilentPaymentKeysFile::new(scan, SpendKey::Secret(spend));
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("keys.bin");
original.save(&path).expect("save");
let decoded = SilentPaymentKeysFile::load(&path).expect("load");
assert_eq!(original, decoded);
}

#[test]
fn save_refuses_to_overwrite_existing_file() {
let (scan, spend) = test_keys();
let file = SilentPaymentKeysFile::new(scan, SpendKey::Secret(spend));
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("keys.bin");
file.save(&path).expect("first save");
let err = file.save(&path).expect_err("second save must fail");
assert!(
matches!(&err, KeysFileError::Io(e) if e.kind() == io::ErrorKind::AlreadyExists),
"expected Io(AlreadyExists), got {err:?}"
);
}
}
2 changes: 2 additions & 0 deletions crates/wallet/src/silentpayments/mod.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
mod keys_file;
mod scanning;
mod wallet;

pub use ::silentpayments::receiving::{Label, Receiver};
pub use ::silentpayments::{Network, SilentPaymentAddress};
pub use keys_file::{SilentPaymentKeysFile, SpendKey};
pub use scanning::{scan_transaction, InputData};
pub use wallet::{Coin, HistoryEntry, SilentPaymentKeys, SpentBy, Wallet};

Expand Down
16 changes: 14 additions & 2 deletions crates/wallet/src/silentpayments/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ use std::{
fmt,
};

use bitcoin::secp256k1::{PublicKey, Scalar, SecretKey};
use bitcoin::secp256k1::{Parity, PublicKey, Scalar, SecretKey, XOnlyPublicKey};
use bitcoin::{hashes::Hash, Amount, OutPoint, ScriptBuf, Txid};
use bitcoinkernel::prelude::{TransactionExt, TxInExt, TxOutPointExt, TxidExt};

use crate::silentpayments::scanning::scan_block_inner;
use crate::silentpayments::{Label, Network, Receiver};
use crate::silentpayments::{build_receiver, Label, Network, Receiver};

#[derive(Debug)]
pub struct SilentPaymentKeys {
Expand Down Expand Up @@ -96,6 +96,18 @@ impl Wallet {
}
}

pub fn import_keys(
&mut self,
scan_key: SecretKey,
spend_xonly: XOnlyPublicKey,
) -> Result<(), ::silentpayments::Error> {
let spend_pub = PublicKey::from_x_only_public_key(spend_xonly, Parity::Even);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know much about the semantics of taproot, but I assume it's defined all x-only keys are even parity? I only ask because it seems strange the from_x_only_public_key function would need a parity argument at all if it's just going to be passed as Even every time

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe from_x_only_public_key is just the secp256k1 primitive and not taproot-specific.

let receiver = build_receiver(&scan_key, spend_pub, self.network)?;
self.spend_key = Some(spend_pub);
self.keys = Some(SilentPaymentKeys { receiver, scan_key });
Ok(())
}

pub fn scan_block(
&mut self,
kernel_block: bitcoinkernel::Block,
Expand Down
40 changes: 30 additions & 10 deletions src/bin/cli.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
use std::path::PathBuf;

use bitcoin::hex::FromHex;
use bitcoin::secp256k1::{rand::rngs::OsRng, Secp256k1, SecretKey, XOnlyPublicKey};
use clap::Parser;
use kernel_node::ext::DirnameExt;
use kernel_node::server_capnp::server;
use tokio::net::UnixStream;
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
use wallet::io::FileExt;
use wallet::silentpayments::{SilentPaymentKeysFile, SpendKey};

const DEFAULT_DATA_DIR: &str = "~/.kernel-node/";

Expand Down Expand Up @@ -45,10 +49,16 @@ struct Echo {
enum WalletCmd {
/// Generate fresh scan and spend keys for receiving silent payments.
///
/// Prints the scan private key, spend private key, and spend x-only public
/// key as hex on stdout. WARNING: scan_key and spend_priv must be kept secret
/// — anyone with them can spend received funds.
GenerateKeys,
/// By default, prints the scan key, spend private key, and spend
/// x-only public key as hex on stdout. With `--out <path>`, writes
/// the secrets to a binary file and prints only the spend public
/// key on stderr. WARNING: anyone with the scan key and spend
/// private key can spend received funds.
GenerateKeys {
/// Write the keys to this binary file. Must not already exist.
#[arg(long)]
out: Option<PathBuf>,
},
/// Import BIP-352 silent payment keys to enable scanning for incoming payments.
///
/// Both keys are hex-encoded. The scan key derives the ECDH shared secret with
Expand Down Expand Up @@ -98,12 +108,22 @@ async fn connect_server(datadir_path: &str) -> server::Client {
fn main() {
let cli = Args::parse();

if let Commands::Wallet(WalletCmd::GenerateKeys) = &cli.commands {
if let Commands::Wallet(WalletCmd::GenerateKeys { out }) = &cli.commands {
let (scan_priv, spend_priv, spend_pub) = generate_keys();
eprintln!("WARNING: scan_key and spend_priv must be kept secret — anyone with them can spend received funds.");
println!("scan_key={}", scan_priv.display_secret());
println!("spend_priv={}", spend_priv.display_secret());
println!("spend_pub={}", spend_pub);
match out {
Some(path) => {
let file = SilentPaymentKeysFile::new(scan_priv, SpendKey::Secret(spend_priv));
file.save(path).expect("failed to write keys file");
eprintln!("Wrote silent payment keys to {}", path.display());
eprintln!("spend_pub={}", spend_pub);
}
None => {
eprintln!("WARNING: scan_key and spend_priv must be kept secret — anyone with them can spend received funds.");
println!("scan_key={}", scan_priv.display_secret());
println!("spend_priv={}", spend_priv.display_secret());
println!("spend_pub={}", spend_pub);
}
}
return;
}

Expand Down Expand Up @@ -139,7 +159,7 @@ fn main() {
let wallet_response = client.make_wallet_request().send().promise.await.unwrap();
let client = wallet_response.get().unwrap().get_wallet().unwrap();
match cmd {
WalletCmd::GenerateKeys => unreachable!("handled before runtime"),
WalletCmd::GenerateKeys { .. } => unreachable!("handled before runtime"),
WalletCmd::ImportKeys {
scan_key,
spend_key,
Expand Down
Loading
Loading