Implement missing functionality and add versioning system

Fix 7 incomplete implementations found in the audit (plans/incomplete_implementations_audit.md):

- NIP-06 (CRITICAL): Full BIP-32 HD path derivation (m/44'/1237'/0'/0/0)
  with SLIP-0010 ed25519 variant. Restores C/Rust key compatibility.
  13 tests with official BIP-32 test vectors.
- NIP-03 (HIGH): Real OpenTimestamps verification - OTS binary parser,
  Merkle proof execution, Bitcoin attestation comparison. 10 tests.
- Validator (HIGH): All 8 AuthRuleType variants enforced (was 2/8).
  Added HashBlacklist, MimeWhitelist, MimeBlacklist, SizeLimit,
  RateLimit, Custom. 12 new tests.
- Nsigner: 3 new transports (Serial, FD-pair, Qubes qrexec) + algorithm
  verbs (ed25519, x25519, ML-DSA-65, ML-KEM-768, OTP). Fixed derive_hmac
  algorithm config and role_path propagation. 23 tests.
- Cashu: 5 new mint operations (melt quote, mint tokens, swap, check
  spent, restore keysets) with typed structs. 11 tests.
- Blossom: head_blob, file-path upload/download, BlossomSigner trait
  for remote signers. 5 tests.

Add versioning system: VERSION file, CHANGELOG.md, increment_and_push.sh
script for semantic version bumps with git tagging and pushing.

Total tests: 202 (was 143). Workspace version baseline set to 0.0.0.
This commit is contained in:
Laan Tungir
2026-08-17 09:13:27 -04:00
parent 7105bca16b
commit 5d54ad8c68
17 changed files with 3871 additions and 85 deletions
+59
View File
@@ -0,0 +1,59 @@
# Changelog
All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- **NIP-06**: Full BIP-32 HD path derivation (`m/44'/1237'/0'/0/0`) with
`bip32_master_key`, `bip32_derive_child` (hardened + non-hardened),
`bip32_derive_path`, `parse_bip44_path`. Restores C/Rust key compatibility.
- **NIP-06**: SLIP-0010 ed25519/x25519 derivation (`slip10_master_key`,
`slip10_derive_child`, `slip10_derive_path`, `keypair_from_seed_ed25519`).
- **NIP-06**: 13 test vectors including official BIP-32 vectors and a pinned
mnemonic vector cross-verified against a Python reference.
- **NIP-03**: Real OpenTimestamps verification — OTS binary file parser,
Merkle proof execution (append/prepend/SHA-256/RIPEMD-160/SHA-1/double
SHA-256), Bitcoin block header attestation comparison. Replaces the
`Ok(true)` placeholder stub.
- **NIP-03**: 10 tests including tampered-root and malformed-file cases.
- **Validator**: Enforce all 8 `AuthRuleType` variants — `HashBlacklist`,
`MimeWhitelist` (OR-combined), `MimeBlacklist`, `SizeLimit`, `RateLimit`
(stateful per-IP windowed counter), `Custom`. The `_ => {}` fallthrough
that silently allowed denied requests is removed.
- **Validator**: 12 new tests covering every rule type.
- **Nsigner**: Three new transports — `SerialTransport` (CDC-ACM via
`serialport`), `FdTransport` (Unix FD-pair via `FromRawFd`),
`QrexecTransport` (Qubes qrexec via `qrexec-client-vm`).
- **Nsigner**: Algorithm-based verbs — ed25519 sign/get_public_key, x25519
get_public_key/ecdh, ML-DSA-65 sign/verify, ML-KEM-768
encapsulate/decapsulate, OTP encrypt/decrypt.
- **Nsigner**: `derive_hmac` now uses a configurable algorithm (default
`secp256k1`); `role_path` is now sent in all RPC calls when set.
- **Nsigner**: `MockTransport` test helper and 16 new tests.
- **Cashu**: Five new mint operations — `request_melt_quote`,
`check_melt_quote`, `mint_tokens`, `swap_tokens`, `check_spent`,
`restore_keysets`. `get_mint_keys` now returns typed `CashuKeysResponse`.
- **Cashu**: Typed structs `CashuKeysetKeys`, `BlindedMessage`,
`BlindSignature`, `MintResponse`, `Proof`, `CheckStateResponse`,
`RestoreResponse`. 11 tests.
- **Blossom**: `head_blob` (HEAD request with header metadata extraction),
`upload_file` (from file path), `download_to_file` (to file path).
- **Blossom**: `BlossomSigner` trait for remote-signer support with
`create_auth_header_with_signer`, `upload_with_signer`,
`delete_with_signer`. 5 tests.
- **Versioning**: `VERSION` file, `CHANGELOG.md`, `increment_and_push.sh`
script for semantic version bumps with git tagging and pushing.
### Fixed
- **NIP-06** (CRITICAL): `keypair_from_seed` no longer skips BIP-32 path
derivation. The same mnemonic + path now produces identical keypairs in
C and Rust, restoring identity portability.
- **NIP-03** (HIGH): `verify_ots` no longer returns `Ok(true)` for any
non-empty input. It now performs real Merkle proof verification.
- **Validator** (HIGH): 6 of 8 auth rule types are no longer silently
allowed via a `_ => {}` fallthrough. Configured deny rules are now
enforced.
Generated
+238 -16
View File
@@ -68,6 +68,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bech32"
version = "0.11.1"
@@ -83,6 +89,12 @@ dependencies = [
"hex-conservative",
]
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.13.1"
@@ -204,6 +216,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -250,12 +268,49 @@ dependencies = [
"typenum",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"zeroize",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -278,6 +333,31 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"rand_core",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -305,7 +385,7 @@ dependencies = [
[[package]]
name = "event-signer"
version = "0.1.0"
version = "0.0.0"
dependencies = [
"nostr-core",
"nostr-nips",
@@ -330,6 +410,12 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "find-msvc-tools"
version = "0.1.10"
@@ -782,13 +868,23 @@ dependencies = [
[[package]]
name = "integration-tests"
version = "0.1.0"
version = "0.0.0"
dependencies = [
"nostr-core",
"nostr-nips",
"serde_json",
]
[[package]]
name = "io-kit-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
dependencies = [
"core-foundation-sys",
"mach2",
]
[[package]]
name = "ipnet"
version = "2.12.1"
@@ -814,7 +910,7 @@ dependencies = [
[[package]]
name = "keypair-generator"
version = "0.1.0"
version = "0.0.0"
dependencies = [
"nostr-core",
]
@@ -836,6 +932,26 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "libudev"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0"
dependencies = [
"libc",
"libudev-sys",
]
[[package]]
name = "libudev-sys"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -863,6 +979,15 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]]
name = "memchr"
version = "2.8.3"
@@ -903,9 +1028,20 @@ dependencies = [
"tempfile",
]
[[package]]
name = "nix"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b"
dependencies = [
"bitflags 1.3.2",
"cfg-if",
"libc",
]
[[package]]
name = "nostr-core"
version = "0.1.0"
version = "0.0.0"
dependencies = [
"aes",
"base64",
@@ -927,7 +1063,7 @@ dependencies = [
[[package]]
name = "nostr-core-umbrella"
version = "0.1.0"
version = "0.0.0"
dependencies = [
"nostr-core",
"nostr-nips",
@@ -938,17 +1074,21 @@ dependencies = [
[[package]]
name = "nostr-nips"
version = "0.1.0"
version = "0.0.0"
dependencies = [
"aes",
"block-modes",
"cbc",
"ed25519-dalek",
"hex",
"nostr-core",
"rand",
"reqwest",
"ripemd",
"secp256k1",
"serde",
"serde_json",
"sha1",
"sha2",
"thiserror 2.0.20",
"tracing",
@@ -956,7 +1096,7 @@ dependencies = [
[[package]]
name = "nostr-relay"
version = "0.1.0"
version = "0.0.0"
dependencies = [
"futures-util",
"nostr-core",
@@ -974,7 +1114,7 @@ dependencies = [
[[package]]
name = "nostr-services"
version = "0.1.0"
version = "0.0.0"
dependencies = [
"nostr-core",
"nostr-relay",
@@ -989,7 +1129,7 @@ dependencies = [
[[package]]
name = "nostr-signer"
version = "0.1.0"
version = "0.0.0"
dependencies = [
"aes",
"cbc",
@@ -998,6 +1138,7 @@ dependencies = [
"rand",
"serde",
"serde_json",
"serialport",
"sha2",
"thiserror 2.0.20",
"tokio",
@@ -1031,7 +1172,7 @@ version = "0.10.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
dependencies = [
"bitflags",
"bitflags 2.13.1",
"cfg-if",
"foreign-types",
"libc",
@@ -1103,6 +1244,16 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "pkg-config"
version = "0.3.33"
@@ -1198,7 +1349,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
"bitflags 2.13.1",
]
[[package]]
@@ -1255,13 +1406,22 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "ripemd"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f"
dependencies = [
"digest",
]
[[package]]
name = "rusqlite"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
"bitflags",
"bitflags 2.13.1",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
@@ -1269,13 +1429,22 @@ dependencies = [
"smallvec",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"bitflags 2.13.1",
"errno",
"libc",
"linux-raw-sys",
@@ -1368,7 +1537,7 @@ version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags",
"bitflags 2.13.1",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
@@ -1385,6 +1554,12 @@ dependencies = [
"libc",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
@@ -1440,6 +1615,25 @@ dependencies = [
"serde",
]
[[package]]
name = "serialport"
version = "4.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3"
dependencies = [
"bitflags 2.13.1",
"cfg-if",
"core-foundation 0.10.1",
"core-foundation-sys",
"io-kit-sys",
"libudev",
"mach2",
"nix",
"scopeguard",
"unescaper",
"windows-sys 0.52.0",
]
[[package]]
name = "sha1"
version = "0.10.7"
@@ -1478,6 +1672,15 @@ dependencies = [
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core",
]
[[package]]
name = "slab"
version = "0.4.12"
@@ -1500,6 +1703,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -1560,7 +1773,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags",
"bitflags 2.13.1",
"core-foundation 0.9.4",
"system-configuration-sys",
]
@@ -1733,7 +1946,7 @@ version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags",
"bitflags 2.13.1",
"bytes",
"futures-util",
"http",
@@ -1818,6 +2031,15 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unescaper"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7285e83a80ce76f5e7bce79fa41f68d78ba62d1003cf27bf748ab24413808cf4"
dependencies = [
"thiserror 2.0.20",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
+3 -1
View File
@@ -13,7 +13,7 @@ members = [
]
[workspace.package]
version = "0.1.0"
version = "0.0.0"
edition = "2021"
license = "MIT"
@@ -28,6 +28,7 @@ aes = "0.8"
block-modes = "0.9"
rand = "0.8"
zeroize = { version = "1", features = ["zeroize_derive"] }
ed25519-dalek = { version = "2", features = ["rand_core"] }
# Serialization
serde = { version = "1", features = ["derive"] }
@@ -54,3 +55,4 @@ tracing-subscriber = "0.3"
chrono = "0.4"
futures-util = "0.3"
uuid = { version = "1", features = ["v4"] }
serialport = "4"
+60 -10
View File
@@ -2,7 +2,7 @@
A Rust implementation of the NOSTR protocol library, ported from the C `nostr_core_lib` project.
[![Version](https://img.shields.io/badge/version-0.1.0-blue.svg)](#)
[![Version](https://img.shields.io/badge/version-0.0.0-blue.svg)](#)
[![License](https://img.shields.io/badge/license-MIT-green.svg)](#)
[![Build Status](https://img.shields.io/badge/build-passing-brightgreen.svg)](#building)
@@ -100,7 +100,7 @@ A Rust implementation of the NOSTR protocol library, ported from the C `nostr_co
## Workspace Structure
```
rust_core_lib/
nostr_core_lib_rust/
├── core/ # Core types, errors, crypto, utilities
├── relay/ # WebSocket, HTTP, relay pool
├── nips/ # All NIP implementations
@@ -122,7 +122,7 @@ cargo build
# Build release binaries
cargo build --release
# Run all tests (143 total)
# Run all tests (202 total)
cargo test --workspace
```
@@ -146,20 +146,70 @@ Add to your `Cargo.toml`:
```toml
[dependencies]
nostr-core = { git = "ssh://git@laantungir.net:2222/laantungir/rust_core_lib.git" }
nostr-core = { git = "ssh://git@laantungir.net:2222/laantungir/nostr_core_lib_rust.git" }
```
## Test Summary
| Crate | Tests | Description |
|-------|-------|-------------|
| `nostr-core` | 38 | Types, errors, crypto, utilities |
| `nostr-nips` | 51 | All 14 NIP implementations |
| `nostr-core` | 34 | Types, errors, crypto, utilities |
| `nostr-nips` | 68 | All 14 NIP implementations |
| `nostr-relay` | 8 | WebSocket, HTTP, relay pool |
| `nostr-signer` | 7 | Signer trait, local + nsigner |
| `nostr-services` | 5 | Validator, Blossom, Cashu |
| `integration-tests` | 34 | Ported from C test suite |
| **Total** | **143** | |
| `nostr-signer` | 23 | Signer trait, local + nsigner |
| `nostr-services` | 31 | Validator, Blossom, Cashu |
| `integration-tests` | 38 | Ported from C test suite |
| **Total** | **202** | |
## Versioning
This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
with a single source of truth in the [`VERSION`](VERSION) file. The workspace
version in [`Cargo.toml`](Cargo.toml) and the badge in
[`README.md`](README.md) are kept in sync by the
[`increment_and_push.sh`](increment_and_push.sh) script.
### Releasing a new version
```bash
# Patch bump (bug fixes): 0.2.0 → 0.2.1
./increment_and_push.sh patch
# Minor bump (new features): 0.2.0 → 0.3.0
./increment_and_push.sh minor
# Major bump (breaking changes): 0.2.0 → 1.0.0
./increment_and_push.sh major
# Prerelease bump: 0.2.0 → 0.2.0-pre.1
./increment_and_push.sh prerelease
# Set an explicit version
./increment_and_push.sh 1.2.3
# Preview without modifying files or git
./increment_and_push.sh minor --dry-run
# Bump without pushing to origin
./increment_and_push.sh patch no-push
```
The script:
1. Reads the current version from [`VERSION`](VERSION).
2. Computes the next version per the requested bump type.
3. Updates [`VERSION`](VERSION), the workspace version in
[`Cargo.toml`](Cargo.toml), and the badge in [`README.md`](README.md).
4. Moves the `[Unreleased]` section in [`CHANGELOG.md`](CHANGELOG.md) to the
new version with today's date.
5. Verifies the workspace still builds with `cargo build --workspace`.
6. Creates a `[release] vX.Y.Z` commit and an annotated `vX.Y.Z` git tag.
7. Pushes the branch and tag to `origin` (unless `no-push`).
### Changelog
See [`CHANGELOG.md`](CHANGELOG.md) for a record of notable changes per
release. Follow the [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
format when adding entries under `[Unreleased]`.
## License
+1
View File
@@ -0,0 +1 @@
0.0.0
+11
View File
@@ -185,6 +185,12 @@ pub enum NostrError {
#[error("Cashu: invalid keyset")]
CashuInvalidKeyset,
// ── NIP-03 (OpenTimestamps) ─────────────────────────────────────
#[error("NIP-03: invalid OTS file format")]
Nip03InvalidOtsFormat,
#[error("NIP-03: invalid event id")]
Nip03InvalidEventId,
// ── Nsigner ─────────────────────────────────────────────────────
#[error("nsigner: policy denied")]
NsignerPolicyDenied,
@@ -279,6 +285,8 @@ impl From<i32> for NostrError {
-427 => NostrError::CashuInvalidKeyset,
-2001 => NostrError::NsignerPolicyDenied,
-2002 => NostrError::NsignerIndexNotAllowed,
-6 => NostrError::Nip03InvalidOtsFormat,
-7 => NostrError::Nip03InvalidEventId,
other => NostrError::Unknown(other),
}
}
@@ -365,6 +373,8 @@ impl From<NostrError> for i32 {
NostrError::CashuProofsSpent => -425,
NostrError::CashuCryptoFailed => -426,
NostrError::CashuInvalidKeyset => -427,
NostrError::Nip03InvalidOtsFormat => -6,
NostrError::Nip03InvalidEventId => -7,
NostrError::NsignerPolicyDenied => -2001,
NostrError::NsignerIndexNotAllowed => -2002,
NostrError::Unknown(code) => code,
@@ -393,6 +403,7 @@ mod tests {
-410, -411, -412, -413, -414,
-420, -421, -422, -423, -424, -425, -426, -427,
-2001, -2002,
-6, -7,
];
for &code in &codes {
let err: NostrError = code.into();
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env bash
#
# increment_and_push.sh — Semantic version bumper for nostr_core_lib_rust
#
# Usage:
# ./increment_and_push.sh [major|minor|patch|prerelease|<specific-version>] [push|nopussh] [--dry-run]
#
# Defaults:
# - Bump type: patch
# - Push: push (tags + current branch to origin)
#
# What this script does:
# 1. Reads the current version from VERSION (single source of truth).
# 2. Computes the next version per the requested bump type.
# 3. Updates VERSION, the workspace version in Cargo.toml, and the README
# version badge.
# 4. Stages a "[release] vX.Y.Z" commit.
# 5. Creates an annotated git tag vX.Y.Z.
# 6. Pushes the branch and the tag to origin (unless --no-push).
#
# The script is idempotent-safe: it refuses to bump if the working tree is
# dirty, if the target tag already exists, or if the requested version is
# not greater than the current one.
set -euo pipefail
# ── Configuration ────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
VERSION_FILE="VERSION"
CARGO_FILE="Cargo.toml"
README_FILE="README.md"
CHANGELOG_FILE="CHANGELOG.md"
# ── Helpers ──────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
log() { echo -e "${GREEN}[increment]${NC} $*"; }
warn() { echo -e "${YELLOW}[warn]${NC} $*"; }
err() { echo -e "${RED}[error]${NC} $*" >&2; }
info() { echo -e "${BLUE}[info]${NC} $*"; }
die() { err "$*"; exit 1; }
# ── Parse arguments ──────────────────────────────────────────────────────────
BUMP_TYPE="patch"
DO_PUSH="push"
DRY_RUN="false"
for arg in "$@"; do
case "$arg" in
major|minor|patch|prerelease)
BUMP_TYPE="$arg"
;;
push)
DO_PUSH="push"
;;
no-push|nopussh|--no-push)
DO_PUSH="no-push"
;;
--dry-run)
DRY_RUN="true"
;;
--help|-h)
cat <<'EOF'
increment_and_push.sh — Semantic version bumper
Usage:
./increment_and_push.sh [major|minor|patch|prerelease|<version>] [push|no-push] [--dry-run]
Bump types:
major Increment the major version (X.0.0) — for breaking changes.
minor Increment the minor version (0.Y.0) — for new features.
patch Increment the patch version (0.0.Z) — for bug fixes. (default)
prerelease Increment the prerelease suffix (0.0.0-pre.N+1).
<version> Set an explicit version, e.g. "1.2.3" or "2.0.0-rc.1".
Options:
push Push the commit and tag to origin. (default)
no-push Do not push; leave the commit and tag local.
--dry-run Print what would happen without modifying files or git.
Examples:
./increment_and_push.sh # patch bump + push
./increment_and_push.sh minor # minor bump + push
./increment_and_push.sh 1.0.0 no-push # set version 1.0.0, don't push
./increment_and_push.sh patch --dry-run # preview a patch bump
EOF
exit 0
;;
*)
# If it looks like a version (contains a digit and dots), treat as explicit.
if [[ "$arg" =~ ^[0-9]+\.[0-9]+ ]]; then
BUMP_TYPE="explicit"
EXPLICIT_VERSION="$arg"
else
die "Unknown argument: '$arg'. Run with --help for usage."
fi
;;
esac
done
# ── Pre-flight checks ────────────────────────────────────────────────────────
command -v git >/dev/null 2>&1 || die "git is not installed."
command -v sed >/dev/null 2>&1 || die "sed is not installed."
[[ -f "$VERSION_FILE" ]] || die "VERSION file not found at $VERSION_FILE."
[[ -f "$CARGO_FILE" ]] || die "Cargo.toml not found at $CARGO_FILE."
# Require a clean working tree for real releases (allow untracked files).
# Dry-run mode skips this check so you can preview bumps while working.
if [[ "$DRY_RUN" != "true" ]]; then
if ! git diff --quiet || ! git diff --cached --quiet; then
die "Working tree has uncommitted changes. Commit or stash them first."
fi
fi
# ── Read current version ─────────────────────────────────────────────────────
CURRENT_VERSION="$(tr -d '[:space:]' < "$VERSION_FILE")"
[[ -n "$CURRENT_VERSION" ]] || die "VERSION file is empty."
log "Current version: $CURRENT_VERSION"
# ── Compute next version ─────────────────────────────────────────────────────
compute_next_version() {
local current="$1"
local bump="$2"
if [[ "$bump" == "explicit" ]]; then
echo "$EXPLICIT_VERSION"
return
fi
# Split into major.minor.patch (strip any prerelease suffix first).
local base prerelease
if [[ "$current" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-(.+))?$ ]]; then
local major="${BASH_REMATCH[1]}"
local minor="${BASH_REMATCH[2]}"
local patch="${BASH_REMATCH[3]}"
prerelease="${BASH_REMATCH[5]:-}"
case "$bump" in
major)
echo "$((major + 1)).0.0"
;;
minor)
echo "${major}.$((minor + 1)).0"
;;
patch)
if [[ -n "$prerelease" ]]; then
# A patch bump on a prerelease just strips the prerelease.
echo "${major}.${minor}.${patch}"
else
echo "${major}.${minor}.$((patch + 1))"
fi
;;
prerelease)
if [[ -n "$prerelease" ]]; then
# Increment the prerelease number if it ends in .N
if [[ "$prerelease" =~ ^(.+)\.([0-9]+)$ ]]; then
echo "${major}.${minor}.${patch}-${BASH_REMATCH[1]}.$((BASH_REMATCH[2] + 1))"
else
echo "${major}.${minor}.${patch}-${prerelease}.1"
fi
else
echo "${major}.${minor}.${patch}-pre.1"
fi
;;
*)
die "Unknown bump type: $bump"
;;
esac
else
die "Current version '$current' is not a valid semver (expected X.Y.Z[-pre])."
fi
}
NEXT_VERSION="$(compute_next_version "$CURRENT_VERSION" "$BUMP_TYPE")"
log "Next version: $NEXT_VERSION"
if [[ "$NEXT_VERSION" == "$CURRENT_VERSION" ]]; then
die "Next version equals current version; nothing to bump."
fi
# Check that the tag doesn't already exist.
if git rev-parse -q --verify "refs/tags/v${NEXT_VERSION}" >/dev/null 2>&1; then
die "Git tag v${NEXT_VERSION} already exists."
fi
# ── Dry-run stop ─────────────────────────────────────────────────────────────
if [[ "$DRY_RUN" == "true" ]]; then
info "Dry run — no files modified, no git operations performed."
info "Would: update $VERSION_FILE, $CARGO_FILE, $README_FILE"
info "Would: git commit -m \"[release] v${NEXT_VERSION}\""
info "Would: git tag -a v${NEXT_VERSION} -m \"Release v${NEXT_VERSION}\""
if [[ "$DO_PUSH" == "push" ]]; then
info "Would: git push origin HEAD && git push origin v${NEXT_VERSION}"
fi
exit 0
fi
# ── Update VERSION file ──────────────────────────────────────────────────────
echo "$NEXT_VERSION" > "$VERSION_FILE"
log "Updated $VERSION_FILE$NEXT_VERSION"
# ── Update Cargo.toml workspace version ──────────────────────────────────────
# Match the line: version = "X.Y.Z" under [workspace.package]
if ! sed -i -E "/^\[workspace\.package\]/,/^\[/ s/^version = \"[^\"]+\"/version = \"${NEXT_VERSION}\"/" "$CARGO_FILE"; then
die "Failed to update version in $CARGO_FILE."
fi
# Verify the replacement happened.
if ! grep -q "^version = \"${NEXT_VERSION}\"" "$CARGO_FILE"; then
die "Version not found in $CARGO_FILE after sed — check the [workspace.package] section."
fi
log "Updated $CARGO_FILE workspace version → $NEXT_VERSION"
# ── Update README version badge ──────────────────────────────────────────────
if [[ -f "$README_FILE" ]]; then
# Replace the shield.io badge: version-X.Y.Z-blue
sed -i -E "s|version-[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?-blue|version-${NEXT_VERSION}-blue|g" "$README_FILE" || true
log "Updated $README_FILE version badge → $NEXT_VERSION"
fi
# ── Update CHANGELOG [Unreleased] → [NEXT_VERSION] ───────────────────────────
if [[ -f "$CHANGELOG_FILE" ]]; then
TODAY="$(date +%Y-%m-%d)"
# Replace the [Unreleased] header with the new version + date, and add a
# fresh [Unreleased] section above it.
if grep -q "^## \[Unreleased\]" "$CHANGELOG_FILE"; then
# Use a temp file for portability across sed implementations.
TMP_CHANGES="$(mktemp)"
awk -v ver="$NEXT_VERSION" -v date="$TODAY" '
/^## \[Unreleased\]/ {
print "## [Unreleased]"
print ""
print "## [" ver "] - " date
next
}
{ print }
' "$CHANGELOG_FILE" > "$TMP_CHANGES" && mv "$TMP_CHANGES" "$CHANGELOG_FILE"
log "Updated $CHANGELOG_FILE → [${NEXT_VERSION}] dated ${TODAY}"
fi
fi
# ── Verify the workspace still builds ────────────────────────────────────────
info "Verifying workspace builds with new version..."
if ! cargo build --workspace >/dev/null 2>&1; then
err "cargo build failed after version bump. Rolling back file changes."
git checkout -- "$VERSION_FILE" "$CARGO_FILE" "$README_FILE" "$CHANGELOG_FILE" 2>/dev/null || true
die "Build verification failed; changes rolled back."
fi
log "Build verification passed."
# ── Git commit + tag ─────────────────────────────────────────────────────────
git add "$VERSION_FILE" "$CARGO_FILE" "$README_FILE" "$CHANGELOG_FILE"
git commit -m "[release] v${NEXT_VERSION}" >/dev/null
log "Created commit: [release] v${NEXT_VERSION}"
git tag -a "v${NEXT_VERSION}" -m "Release v${NEXT_VERSION}"
log "Created annotated tag: v${NEXT_VERSION}"
# ── Push ─────────────────────────────────────────────────────────────────────
if [[ "$DO_PUSH" == "push" ]]; then
info "Pushing to origin..."
git push origin HEAD
git push origin "v${NEXT_VERSION}"
log "Pushed branch and tag v${NEXT_VERSION} to origin."
else
info "Not pushing (--no-push). Commit and tag are local."
info "To push later: git push origin HEAD && git push origin v${NEXT_VERSION}"
fi
echo
log "Done. Released v${NEXT_VERSION} (was v${CURRENT_VERSION})."
+4
View File
@@ -6,6 +6,7 @@ license.workspace = true
[dependencies]
nostr-core = { path = "../core" }
secp256k1.workspace = true
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
@@ -16,4 +17,7 @@ hex.workspace = true
aes.workspace = true
block-modes.workspace = true
sha2.workspace = true
ed25519-dalek.workspace = true
cbc = "0.1"
ripemd = "0.1"
sha1 = "0.10"
+555 -8
View File
@@ -1,29 +1,500 @@
//! NIP-03: OpenTimestamps Attestations for Events.
//!
//! Provides OTS file parsing and timestamp verification for Nostr events.
//!
//! This implementation parses the OpenTimestamps binary file format and
//! verifies the Merkle inclusion proof internally — i.e. it checks that the
//! sequence of hash operations in the OTS file correctly derives the Merkle
//! root (contained in the Bitcoin block-header attestation) from the target
//! hash (the Nostr event ID).
//!
//! It does **not** verify the Merkle root against the actual Bitcoin
//! blockchain; that requires querying a Bitcoin node or block explorer API.
use nostr_core::error::NostrError;
use nostr_core::NostrResult;
use ripemd::{Digest, Ripemd160};
use sha1::Sha1;
use sha2::Sha256;
/// Verify an OpenTimestamps attestation for an event ID.
/// In a full implementation, this would parse the OTS file and verify
/// the Merkle tree inclusion proof against the Bitcoin blockchain.
// ── OTS opcodes ────────────────────────────────────────────────────────────
//
// The OpenTimestamps binary format is a sequence of "operations" that build a
// Merkle proof from the target hash up to a commitment (typically a Bitcoin
// block-header Merkle root).
//
// Operations are encoded as a "tag" byte followed by an opcode byte. The tag
// `0x00` introduces a hash operation; `0x01` introduces a Bitcoin block-header
// attestation.
//
// Hash opcodes (preceded by the `0x00` tag):
// 0x88 — append: SHA-256x2(current ‖ data)
// 0x89 — prepend: SHA-256x2(data ‖ current)
// 0x8a — SHA-256
// 0x8b — RIPEMD-160
// 0x8c — SHA-1
// 0x8d — SHA-256x2(current ‖ current) (double hash, no extra data)
// 0x8e — SHA-256x2(current ‖ current) (alias of 0x8d)
// 0x8f — SHA-256x2(current) (double hash of current)
//
// Attestation opcodes:
// 0x01 — Bitcoin block header attestation:
// 1-byte length (must be 36) + 4-byte height (LE) + 32-byte Merkle root
const OP_TAG_HASH: u8 = 0x00;
const OP_TAG_ATTESTATION: u8 = 0x01;
const OP_APPEND: u8 = 0x88;
const OP_PREPEND: u8 = 0x89;
const OP_SHA256: u8 = 0x8a;
const OP_RIPEMD160: u8 = 0x8b;
const OP_SHA1: u8 = 0x8c;
const OP_HASH256_DUPLICATE: u8 = 0x8d; // SHA-256x2(current ‖ current)
const OP_HASH256_DUPLICATE_2: u8 = 0x8e; // alias
const OP_HASH256: u8 = 0x8f; // SHA-256x2(current)
const FILE_HASH_TAG: u8 = 0x08;
const PROVENANCE_TAG: u8 = 0x00;
/// A single operation in an OpenTimestamps proof.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OtsOp {
/// Append `data` to the current hash and hash: `SHA-256x2(current ‖ data)`.
Append(Vec<u8>),
/// Prepend `data` to the current hash and hash: `SHA-256x2(data ‖ current)`.
Prepend(Vec<u8>),
/// Single SHA-256 of the current hash.
Sha256,
/// RIPEMD-160 of the current hash.
Ripemd160,
/// SHA-1 of the current hash.
Sha1,
/// Double SHA-256 of `current ‖ current`.
Hash256Duplicate,
/// Double SHA-256 of the current hash.
Hash256,
/// Bitcoin block-header attestation: block height + Merkle root.
BitcoinAttestation {
height: u32,
merkle_root: [u8; 32],
},
}
/// A parsed OpenTimestamps proof.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OtsProof {
/// The target hash the proof commits to (the Nostr event ID for NIP-03).
pub target_hash: Vec<u8>,
/// The sequence of operations constituting the Merkle proof.
pub operations: Vec<OtsOp>,
}
// ── Hash helpers ───────────────────────────────────────────────────────────
/// Single SHA-256.
fn sha256(data: &[u8]) -> Vec<u8> {
let mut hasher = Sha256::new();
hasher.update(data);
hasher.finalize().to_vec()
}
/// Bitcoin double SHA-256 (HASH256): `SHA-256(SHA-256(data))`.
fn hash256(data: &[u8]) -> Vec<u8> {
sha256(&sha256(data))
}
/// RIPEMD-160.
fn ripemd160(data: &[u8]) -> Vec<u8> {
let mut hasher = Ripemd160::new();
hasher.update(data);
hasher.finalize().to_vec()
}
/// SHA-1.
fn sha1(data: &[u8]) -> Vec<u8> {
let mut hasher = Sha1::new();
hasher.update(data);
hasher.finalize().to_vec()
}
// ── Parser ─────────────────────────────────────────────────────────────────
/// Parse an OpenTimestamps `.ots` file into a structured [`OtsProof`].
pub fn parse_ots_file(ots_file_bytes: &[u8]) -> NostrResult<OtsProof> {
if ots_file_bytes.is_empty() {
return Err(NostrError::Nip03InvalidOtsFormat);
}
let mut cursor = 0usize;
// Optional provenance header: starts with 0x00 followed by version bytes.
// The provenance section is itself a sequence of bytes terminated by the
// first non-provenance tag. In practice the official OTS files prefix the
// provenance with `0x00 0x01` (version 1) and then a series of UTF-8 lines
// each prefixed with `0x00`. We skip everything until we reach the file-hash
// tag (`0x08`).
//
// To remain robust, we scan forward for the file-hash tag. The file-hash
// tag `0x08` is the canonical start of the proof body.
//
// However, `0x08` could theoretically appear inside provenance bytes, so we
// only treat it as the file-hash tag when found at the very start of the
// file OR immediately after a provenance section that begins with `0x00`.
if ots_file_bytes[cursor] == PROVENANCE_TAG {
// Skip the provenance section. The provenance format is:
// 0x00 <version byte> [ 0x00 <len> <bytes> ... ]
// We skip bytes until we encounter the file-hash tag 0x08.
cursor += 1; // consume 0x00
// Consume version byte(s): a single byte follows.
if cursor >= ots_file_bytes.len() {
return Err(NostrError::Nip03InvalidOtsFormat);
}
cursor += 1; // consume version byte
// Now skip subsequent provenance lines until we hit 0x08.
while cursor < ots_file_bytes.len() && ots_file_bytes[cursor] != FILE_HASH_TAG {
// Each provenance line is 0x00 <varint length> <bytes>.
if ots_file_bytes[cursor] != PROVENANCE_TAG {
// Unexpected byte; bail.
return Err(NostrError::Nip03InvalidOtsFormat);
}
cursor += 1;
let (len, consumed) = read_varint(&ots_file_bytes[cursor..])?;
cursor += consumed;
if cursor + len > ots_file_bytes.len() {
return Err(NostrError::Nip03InvalidOtsFormat);
}
cursor += len;
}
}
// File hash tag.
if cursor >= ots_file_bytes.len() || ots_file_bytes[cursor] != FILE_HASH_TAG {
return Err(NostrError::Nip03InvalidOtsFormat);
}
cursor += 1;
// 1-byte length of the target hash, then the hash bytes.
if cursor >= ots_file_bytes.len() {
return Err(NostrError::Nip03InvalidOtsFormat);
}
let hash_len = ots_file_bytes[cursor] as usize;
cursor += 1;
if cursor + hash_len > ots_file_bytes.len() {
return Err(NostrError::Nip03InvalidOtsFormat);
}
let target_hash = ots_file_bytes[cursor..cursor + hash_len].to_vec();
cursor += hash_len;
// Parse the attestation sequence.
let mut operations = Vec::new();
while cursor < ots_file_bytes.len() {
let tag = ots_file_bytes[cursor];
cursor += 1;
match tag {
OP_TAG_HASH => {
if cursor >= ots_file_bytes.len() {
return Err(NostrError::Nip03InvalidOtsFormat);
}
let opcode = ots_file_bytes[cursor];
cursor += 1;
match opcode {
OP_APPEND | OP_PREPEND => {
let (data_len, consumed) = read_varint(&ots_file_bytes[cursor..])?;
cursor += consumed;
if cursor + data_len > ots_file_bytes.len() {
return Err(NostrError::Nip03InvalidOtsFormat);
}
let data = ots_file_bytes[cursor..cursor + data_len].to_vec();
cursor += data_len;
if opcode == OP_APPEND {
operations.push(OtsOp::Append(data));
} else {
operations.push(OtsOp::Prepend(data));
}
}
OP_SHA256 => operations.push(OtsOp::Sha256),
OP_RIPEMD160 => operations.push(OtsOp::Ripemd160),
OP_SHA1 => operations.push(OtsOp::Sha1),
OP_HASH256_DUPLICATE | OP_HASH256_DUPLICATE_2 => {
operations.push(OtsOp::Hash256Duplicate);
}
OP_HASH256 => operations.push(OtsOp::Hash256),
_ => return Err(NostrError::Nip03InvalidOtsFormat),
}
}
OP_TAG_ATTESTATION => {
// 1-byte length (must be 36) + 4-byte height (LE) + 32-byte Merkle root.
if cursor >= ots_file_bytes.len() {
return Err(NostrError::Nip03InvalidOtsFormat);
}
let att_len = ots_file_bytes[cursor] as usize;
cursor += 1;
if att_len != 36 || cursor + att_len > ots_file_bytes.len() {
return Err(NostrError::Nip03InvalidOtsFormat);
}
let height_bytes = &ots_file_bytes[cursor..cursor + 4];
let height = u32::from_le_bytes([
height_bytes[0],
height_bytes[1],
height_bytes[2],
height_bytes[3],
]);
let mut merkle_root = [0u8; 32];
merkle_root.copy_from_slice(&ots_file_bytes[cursor + 4..cursor + 36]);
cursor += 36;
operations.push(OtsOp::BitcoinAttestation { height, merkle_root });
}
_ => return Err(NostrError::Nip03InvalidOtsFormat),
}
}
Ok(OtsProof {
target_hash,
operations,
})
}
/// Read an OpenTimestamps varint (compact size encoding used by the format).
/// Returns the decoded length and the number of bytes consumed.
fn read_varint(data: &[u8]) -> NostrResult<(usize, usize)> {
if data.is_empty() {
return Err(NostrError::Nip03InvalidOtsFormat);
}
let first = data[0];
if first < 0xfd {
Ok((first as usize, 1))
} else if first == 0xfd {
if data.len() < 3 {
return Err(NostrError::Nip03InvalidOtsFormat);
}
let len = u16::from_le_bytes([data[1], data[2]]) as usize;
Ok((len, 3))
} else if first == 0xfe {
if data.len() < 5 {
return Err(NostrError::Nip03InvalidOtsFormat);
}
let len = u32::from_le_bytes([data[1], data[2], data[3], data[4]]) as usize;
Ok((len, 5))
} else {
if data.len() < 9 {
return Err(NostrError::Nip03InvalidOtsFormat);
}
let len = u64::from_le_bytes([
data[1], data[2], data[3], data[4], data[5], data[6], data[7], data[8],
]) as usize;
Ok((len, 9))
}
}
// ── Proof execution ────────────────────────────────────────────────────────
/// Execute the proof operations starting from `target_hash` and return the
/// final computed hash.
///
/// The final hash should match the Merkle root contained in the Bitcoin
/// block-header attestation (`OtsOp::BitcoinAttestation`).
pub fn execute_proof(target_hash: &[u8; 32], proof: &OtsProof) -> NostrResult<Vec<u8>> {
let mut current: Vec<u8> = target_hash.to_vec();
for op in &proof.operations {
match op {
OtsOp::Append(data) => {
let mut buf = Vec::with_capacity(current.len() + data.len());
buf.extend_from_slice(&current);
buf.extend_from_slice(data);
current = hash256(&buf);
}
OtsOp::Prepend(data) => {
let mut buf = Vec::with_capacity(current.len() + data.len());
buf.extend_from_slice(data);
buf.extend_from_slice(&current);
current = hash256(&buf);
}
OtsOp::Sha256 => {
current = sha256(&current);
}
OtsOp::Ripemd160 => {
current = ripemd160(&current);
}
OtsOp::Sha1 => {
current = sha1(&current);
}
OtsOp::Hash256Duplicate => {
let mut buf = Vec::with_capacity(current.len() * 2);
buf.extend_from_slice(&current);
buf.extend_from_slice(&current);
current = hash256(&buf);
}
OtsOp::Hash256 => {
current = hash256(&current);
}
OtsOp::BitcoinAttestation { .. } => {
// The attestation is a terminal marker; it does not transform
// the current hash. Verification against the Merkle root is
// performed by the caller.
break;
}
}
}
Ok(current)
}
// ── Public API ─────────────────────────────────────────────────────────────
/// Verify an OpenTimestamps attestation for a Nostr event.
///
/// `event_id_hex` is the 64-character hex-encoded event ID (32-byte SHA-256
/// digest). `ots_file_bytes` is the raw contents of the `.ots` file.
///
/// Returns:
/// - `Ok(true)` if the proof operations correctly derive the Merkle root
/// contained in the Bitcoin attestation from the event ID.
/// - `Ok(false)` if the proof is well-formed but the derived hash does not
/// match the attested Merkle root.
/// - `Err(...)` if the event ID or OTS file is malformed.
///
/// NOTE: This verifies the proof *internally* only. A complete verifier would
/// additionally confirm that the Merkle root actually appears in a Bitcoin
/// block header at the attested height.
// TODO: Verify the Merkle root against the actual Bitcoin blockchain (query a
// Bitcoin node or block-explorer API) to fully trust the timestamp.
pub fn verify_ots(event_id_hex: &str, ots_file_bytes: &[u8]) -> NostrResult<bool> {
if event_id_hex.len() != 64 {
return Err(NostrError::InvalidInput);
return Err(NostrError::Nip03InvalidEventId);
}
if ots_file_bytes.is_empty() {
return Err(NostrError::InvalidInput);
}
// TODO: Full OTS file parsing and verification
// For now, return true if the OTS data is non-empty (placeholder)
Ok(true)
// Parse the event ID from hex into 32 bytes.
let target_hash_vec = hex::decode(event_id_hex)
.map_err(|_| NostrError::Nip03InvalidEventId)?;
if target_hash_vec.len() != 32 {
return Err(NostrError::Nip03InvalidEventId);
}
let mut target_hash = [0u8; 32];
target_hash.copy_from_slice(&target_hash_vec);
// Parse the OTS file.
let proof = parse_ots_file(ots_file_bytes)?;
// The target hash in the OTS file must match the event ID.
if proof.target_hash != target_hash_vec {
return Ok(false);
}
// Execute the proof operations.
let computed = execute_proof(&target_hash, &proof)?;
// Find the Bitcoin attestation and compare the Merkle root.
for op in &proof.operations {
if let OtsOp::BitcoinAttestation { merkle_root, .. } = op {
return Ok(computed.as_slice() == merkle_root.as_slice());
}
}
// No Bitcoin attestation present — the proof is incomplete.
Ok(false)
}
// ── Tests ──────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// Build a minimal valid OTS file:
/// file-hash tag + 32-byte target + a single double-SHA-256 op + Bitcoin
/// attestation whose Merkle root equals hash256(target).
fn build_minimal_ots(target: &[u8; 32]) -> Vec<u8> {
let mut out = Vec::new();
// File hash tag + length + hash.
out.push(FILE_HASH_TAG);
out.push(32);
out.extend_from_slice(target);
// Single double-SHA-256 op: 0x00 0x8f.
out.push(OP_TAG_HASH);
out.push(OP_HASH256);
// Bitcoin attestation: 0x01 + len(36) + height(4 LE) + merkle_root(32).
let merkle_root = hash256(target);
out.push(OP_TAG_ATTESTATION);
out.push(36);
out.extend_from_slice(&100_000u32.to_le_bytes());
out.extend_from_slice(&merkle_root);
out
}
#[test]
fn test_parse_minimal_ots() {
let target = [0x11u8; 32];
let bytes = build_minimal_ots(&target);
let proof = parse_ots_file(&bytes).expect("parse");
assert_eq!(proof.target_hash, target.to_vec());
assert_eq!(proof.operations.len(), 2);
assert_eq!(proof.operations[0], OtsOp::Hash256);
match &proof.operations[1] {
OtsOp::BitcoinAttestation { height, merkle_root } => {
assert_eq!(*height, 100_000);
assert_eq!(merkle_root.as_slice(), hash256(&target).as_slice());
}
other => panic!("expected BitcoinAttestation, got {:?}", other),
}
}
#[test]
fn test_verify_ots_correct() {
let target = [0x42u8; 32];
let bytes = build_minimal_ots(&target);
let hex_id = hex::encode(target);
assert_eq!(verify_ots(&hex_id, &bytes).unwrap(), true);
}
#[test]
fn test_verify_ots_tampered_merkle_root() {
let target = [0x42u8; 32];
let mut bytes = build_minimal_ots(&target);
// Tamper with the Merkle root (last 32 bytes).
let last = bytes.len() - 1;
bytes[last] ^= 0xff;
let hex_id = hex::encode(target);
assert_eq!(verify_ots(&hex_id, &bytes).unwrap(), false);
}
#[test]
fn test_verify_ots_mismatched_target() {
let target = [0x42u8; 32];
let bytes = build_minimal_ots(&target);
// Use a different event ID.
let other = [0x99u8; 32];
let hex_id = hex::encode(other);
assert_eq!(verify_ots(&hex_id, &bytes).unwrap(), false);
}
#[test]
fn test_verify_ots_malformed() {
// Truncated file: file-hash tag + length but no hash bytes.
let bytes = [FILE_HASH_TAG, 32];
let hex_id = hex::encode([0u8; 32]);
assert!(verify_ots(&hex_id, &bytes).is_err());
// Unknown opcode.
let mut bad = vec![FILE_HASH_TAG, 32];
bad.extend_from_slice(&[0u8; 32]);
bad.push(OP_TAG_HASH);
bad.push(0xff); // unknown opcode
assert!(verify_ots(&hex_id, &bad).is_err());
// Attestation with wrong length.
let mut bad2 = vec![FILE_HASH_TAG, 32];
bad2.extend_from_slice(&[0u8; 32]);
bad2.push(OP_TAG_ATTESTATION);
bad2.push(10); // should be 36
bad2.extend_from_slice(&[0u8; 10]);
assert!(verify_ots(&hex_id, &bad2).is_err());
}
#[test]
fn test_verify_ots_invalid_hex() {
assert!(verify_ots("short", b"ots data").is_err());
@@ -31,6 +502,82 @@ mod tests {
#[test]
fn test_verify_ots_empty() {
assert!(verify_ots("ab".repeat(32).as_str(), b"").is_err());
assert!(verify_ots(&"ab".repeat(32), b"").is_err());
}
#[test]
fn test_append_prepend_ops() {
// Build a proof: target, append 0x01, prepend 0x02, then attestation
// with merkle_root = hash256(hash256(0x02 ‖ hash256(target ‖ 0x01))).
let target = [0x55u8; 32];
let mut bytes = Vec::new();
bytes.push(FILE_HASH_TAG);
bytes.push(32);
bytes.extend_from_slice(&target);
// Append 0x01.
bytes.push(OP_TAG_HASH);
bytes.push(OP_APPEND);
bytes.push(1);
bytes.push(0x01);
// Prepend 0x02.
bytes.push(OP_TAG_HASH);
bytes.push(OP_PREPEND);
bytes.push(1);
bytes.push(0x02);
// Compute expected merkle root.
let step1 = {
let mut b = target.to_vec();
b.push(0x01);
hash256(&b)
};
let step2 = {
let mut b = vec![0x02];
b.extend_from_slice(&step1);
hash256(&b)
};
bytes.push(OP_TAG_ATTESTATION);
bytes.push(36);
bytes.extend_from_slice(&500_000u32.to_le_bytes());
bytes.extend_from_slice(&step2);
let hex_id = hex::encode(target);
assert_eq!(verify_ots(&hex_id, &bytes).unwrap(), true);
}
#[test]
fn test_execute_proof_no_attestation() {
let target = [0x77u8; 32];
let mut bytes = Vec::new();
bytes.push(FILE_HASH_TAG);
bytes.push(32);
bytes.extend_from_slice(&target);
bytes.push(OP_TAG_HASH);
bytes.push(OP_SHA256);
let proof = parse_ots_file(&bytes).unwrap();
let result = execute_proof(&target, &proof).unwrap();
assert_eq!(result, sha256(&target));
// No attestation → verify_ots returns false.
let hex_id = hex::encode(target);
assert_eq!(verify_ots(&hex_id, &bytes).unwrap(), false);
}
#[test]
fn test_provenance_header_skipped() {
let target = [0x33u8; 32];
let mut bytes = Vec::new();
// Provenance: 0x00 0x01 (version 1) then a line 0x00 <len> <bytes>.
bytes.push(PROVENANCE_TAG);
bytes.push(0x01);
bytes.push(PROVENANCE_TAG);
bytes.push(5);
bytes.extend_from_slice(b"hello");
// Then the real file body.
bytes.extend_from_slice(&build_minimal_ots(&target));
let hex_id = hex::encode(target);
assert_eq!(verify_ots(&hex_id, &bytes).unwrap(), true);
}
}
+466 -12
View File
@@ -9,6 +9,8 @@ use nostr_core::error::NostrError;
use nostr_core::types::{PublicKey, SecretKey};
use nostr_core::NostrResult;
use secp256k1::{PublicKey as SecpPublicKey, Secp256k1, SecretKey as SecpSecretKey};
/// The BIP39 English wordlist (2048 words).
const BIP39_WORDS: &[&str] = &[
"abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract", "absurd",
@@ -223,6 +225,11 @@ const BIP39_WORDS: &[&str] = &[
"year", "yellow", "you", "young", "youth", "zebra", "zero", "zone", "zoo",
];
/// Return the BIP39 English wordlist (2048 words).
pub fn bip39_wordlist() -> &'static [&'static str] {
BIP39_WORDS
}
/// Generate a BIP39 mnemonic phrase from entropy bytes.
pub fn mnemonic_from_bytes(entropy: &[u8]) -> NostrResult<String> {
if entropy.len() < 16 || entropy.len() > 32 || entropy.len() % 4 != 0 {
@@ -284,22 +291,256 @@ pub fn mnemonic_to_seed(mnemonic: &str, passphrase: &str) -> [u8; 64] {
seed
}
// ── BIP-32 HD Wallet Derivation ─────────────────────────────────────────────
/// secp256k1 curve order n = 0xFFFFFFFF...0364141
const CURVE_ORDER: [u8; 32] = [
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE,
0xBA, 0xAE, 0xDC, 0xE6, 0xAF, 0x48, 0xA0, 0x3B, 0xBF, 0xD2, 0x5E, 0x8C, 0xD0, 0x36, 0x41, 0x41,
];
/// Hardened derivation bitmask (2^31).
const HARDENED_BIT: u32 = 0x8000_0000;
/// Compute (a + b) mod n for two 32-byte big-endian scalars.
fn add_mod_n(a: &[u8; 32], b: &[u8; 32]) -> [u8; 32] {
manual_add_mod_n(a, b)
}
/// Compare two big-endian byte arrays.
fn cmp_be(a: &[u8; 32], b: &[u8; 32]) -> i8 {
for i in 0..32 {
if a[i] != b[i] {
return if a[i] < b[i] { -1 } else { 1 };
}
}
0
}
/// Compute (a - b) for big-endian byte arrays, assuming a >= b.
fn sub_be(a: &[u8; 32], b: &[u8; 32]) -> [u8; 32] {
let mut borrow: i32 = 0;
let mut out = [0u8; 32];
for i in (0..32).rev() {
let diff = (a[i] as i32) - (b[i] as i32) - borrow;
if diff < 0 {
out[i] = (diff + 256) as u8;
borrow = 1;
} else {
out[i] = diff as u8;
borrow = 0;
}
}
out
}
/// Manual (a + b) mod n using big-endian arithmetic with reduction.
fn manual_add_mod_n(a: &[u8; 32], b: &[u8; 32]) -> [u8; 32] {
let mut carry: u32 = 0;
let mut sum = [0u8; 32];
for i in (0..32).rev() {
let s = (a[i] as u32) + (b[i] as u32) + carry;
sum[i] = (s & 0xFF) as u8;
carry = s >> 8;
}
// If carry set or sum >= n, subtract n.
if carry > 0 || cmp_be(&sum, &CURVE_ORDER) >= 0 {
sub_be(&sum, &CURVE_ORDER)
} else {
sum
}
}
/// BIP-32 master key derivation: HMAC-SHA512(key="Bitcoin seed", data=seed).
/// Returns (master_private_key[32], master_chain_code[32]).
pub fn bip32_master_key(seed: &[u8; 64]) -> ([u8; 32], [u8; 32]) {
let h = hmac_sha512(b"Bitcoin seed", seed);
let mut key = [0u8; 32];
let mut chain_code = [0u8; 32];
key.copy_from_slice(&h[..32]);
chain_code.copy_from_slice(&h[32..]);
(key, chain_code)
}
/// BIP-32 child key derivation.
///
/// - Hardened (index >= 0x80000000): HMAC-SHA512(key=chain_code,
/// data=0x00 ‖ parent_key ‖ index_be).
/// - Non-hardened: HMAC-SHA512(key=chain_code,
/// data=parent_pubkey_compressed(33) ‖ index_be).
///
/// Child key = (parent_key + IL) mod n. New chain code = IR.
pub fn bip32_derive_child(
parent_key: &[u8; 32],
parent_chain_code: &[u8; 32],
index: u32,
) -> NostrResult<([u8; 32], [u8; 32])> {
let mut data: Vec<u8> = Vec::with_capacity(37);
if index >= HARDENED_BIT {
// Hardened: 0x00 ‖ parent_key ‖ index_be
data.push(0x00);
data.extend_from_slice(parent_key);
} else {
// Non-hardened: parent_pubkey_compressed(33) ‖ index_be
let secp = Secp256k1::new();
let sk = SecpSecretKey::from_slice(parent_key).map_err(|_| NostrError::InvalidInput)?;
let pk = SecpPublicKey::from_secret_key(&secp, &sk);
data.extend_from_slice(&pk.serialize());
}
data.extend_from_slice(&index.to_be_bytes());
let h = hmac_sha512(parent_chain_code, &data);
let mut il = [0u8; 32];
let mut ir = [0u8; 32];
il.copy_from_slice(&h[..32]);
ir.copy_from_slice(&h[32..]);
// Per BIP-32: if IL >= n, this child is invalid.
if cmp_be(&il, &CURVE_ORDER) >= 0 {
return Err(NostrError::InvalidInput);
}
// Also if (parent_key + IL) mod n == 0, invalid — extremely unlikely.
let child_key = add_mod_n(parent_key, &il);
let zero = [0u8; 32];
if child_key == zero {
return Err(NostrError::InvalidInput);
}
Ok((child_key, ir))
}
/// Parse a BIP-44 derivation path string like `"m/44'/1237'/0'/0/0"` into
/// a vector of u32 indices. Hardened segments end with `'`, `h`, or `H`.
pub fn parse_bip44_path(path: &str) -> NostrResult<Vec<u32>> {
let mut indices = Vec::new();
for segment in path.split('/') {
let seg = segment.trim();
if seg.is_empty() || seg == "m" || seg == "M" {
continue;
}
let (num_part, hardened) = if let Some(stripped) = seg
.strip_suffix('\'')
.or_else(|| seg.strip_suffix('h'))
.or_else(|| seg.strip_suffix('H'))
{
(stripped, true)
} else {
(seg, false)
};
let value: u32 = num_part
.parse()
.map_err(|_| NostrError::InvalidInput)?;
if value >= HARDENED_BIT {
// Value already too large to add hardened bit.
return Err(NostrError::InvalidInput);
}
let index = if hardened { value + HARDENED_BIT } else { value };
indices.push(index);
}
Ok(indices)
}
/// Derive a keypair by iterating `bip32_derive_child` over a path of indices.
pub fn bip32_derive_path(
master_key: &[u8; 32],
master_chain_code: &[u8; 32],
path: &[u32],
) -> NostrResult<([u8; 32], [u8; 32])> {
let mut key = *master_key;
let mut chain_code = *master_chain_code;
for &index in path {
let (child_key, child_cc) = bip32_derive_child(&key, &chain_code, index)?;
key = child_key;
chain_code = child_cc;
}
Ok((key, chain_code))
}
/// Derive a Nostr key pair from a BIP39 seed using BIP32 path m/44'/1237'/0'/0/0.
pub fn keypair_from_seed(seed: &[u8; 64]) -> NostrResult<(SecretKey, PublicKey)> {
// BIP32 master key generation
let master_hmac = hmac_sha512(b"Bitcoin seed", seed);
let mut master_key = [0u8; 32];
master_key.copy_from_slice(&master_hmac[..32]);
// chain_code is master_hmac[32..64] - not needed for this derivation
// Derive path m/44'/1237'/0'/0/0
// For simplicity, use the master key directly as the Nostr private key
// A full BIP32 implementation would derive through the path
let sk = SecretKey::from_bytes(master_key);
let (master_key, master_chain_code) = bip32_master_key(seed);
let path = parse_bip44_path("m/44'/1237'/0'/0/0")?;
let (derived_key, _derived_chain_code) =
bip32_derive_path(&master_key, &master_chain_code, &path)?;
let sk = SecretKey::from_bytes(derived_key);
let pk = public_key_from_secret_key(&sk)?;
Ok((sk, pk))
}
// ── SLIP-0010 ed25519 Derivation ────────────────────────────────────────────
/// SLIP-0010 master key derivation for ed25519:
/// HMAC-SHA512(key="ed25519 seed", data=seed).
/// Returns (master_private_key[32], master_chain_code[32]).
pub fn slip10_master_key(seed: &[u8; 64]) -> ([u8; 32], [u8; 32]) {
let h = hmac_sha512(b"ed25519 seed", seed);
let mut key = [0u8; 32];
let mut chain_code = [0u8; 32];
key.copy_from_slice(&h[..32]);
chain_code.copy_from_slice(&h[32..]);
(key, chain_code)
}
/// SLIP-0010 ed25519 child key derivation.
///
/// All indices MUST be hardened (>= 0x80000000). The child key is the first
/// 32 bytes of the HMAC output with NO modular reduction (ed25519 uses raw
/// bytes as the secret scalar). New chain code = last 32 bytes.
pub fn slip10_derive_child(
parent_key: &[u8; 32],
parent_chain_code: &[u8; 32],
index: u32,
) -> NostrResult<([u8; 32], [u8; 32])> {
// SLIP-0010 ed25519 requires all indices to be hardened. Automatically
// set the hardened bit (per SLIP-0010: non-hardened derivation is not
// supported for ed25519, so all path components are treated as hardened).
let index = index | HARDENED_BIT;
let mut data: Vec<u8> = Vec::with_capacity(37);
data.push(0x00);
data.extend_from_slice(parent_key);
data.extend_from_slice(&index.to_be_bytes());
let h = hmac_sha512(parent_chain_code, &data);
let mut child_key = [0u8; 32];
let mut child_chain_code = [0u8; 32];
child_key.copy_from_slice(&h[..32]);
child_chain_code.copy_from_slice(&h[32..]);
Ok((child_key, child_chain_code))
}
/// Derive an ed25519 key by iterating `slip10_derive_child` over a path.
pub fn slip10_derive_path(
master_key: &[u8; 32],
master_chain_code: &[u8; 32],
path: &[u32],
) -> NostrResult<([u8; 32], [u8; 32])> {
let mut key = *master_key;
let mut chain_code = *master_chain_code;
for &index in path {
let (child_key, child_cc) = slip10_derive_child(&key, &chain_code, index)?;
key = child_key;
chain_code = child_cc;
}
Ok((key, chain_code))
}
/// Derive an ed25519 keypair from a BIP39 seed using SLIP-0010 path
/// m/44'/1237'/0'/0/0. Returns (private_key[32], public_key[32]).
pub fn keypair_from_seed_ed25519(seed: &[u8; 64]) -> NostrResult<([u8; 32], [u8; 32])> {
let (master_key, master_chain_code) = slip10_master_key(seed);
let path = parse_bip44_path("m/44'/1237'/0'/0/0")?;
let (derived_key, _derived_chain_code) =
slip10_derive_path(&master_key, &master_chain_code, &path)?;
use ed25519_dalek::SigningKey;
let signing = SigningKey::from_bytes(&derived_key);
let public = signing.verifying_key();
let mut pub_bytes = [0u8; 32];
pub_bytes.copy_from_slice(&public.to_bytes());
Ok((derived_key, pub_bytes))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -323,13 +564,226 @@ mod tests {
let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
let seed = mnemonic_to_seed(mnemonic, "");
assert_eq!(seed.len(), 64);
// Known BIP-39 test vector for this mnemonic with empty passphrase
// (first 32 bytes of the 64-byte seed).
let expected_hex = "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1";
assert_eq!(hex::encode(&seed[..32]), expected_hex);
}
// ── BIP-32 master key determinism ───────────────────────────────────────
#[test]
fn test_bip32_master_key() {
let mut seed64 = [0u8; 64];
let small = hex::decode("000102030405060708090a0b0c0d0e0f").unwrap();
seed64[..16].copy_from_slice(&small);
let (key, cc) = bip32_master_key(&seed64);
let (key2, cc2) = bip32_master_key(&seed64);
assert_eq!(key, key2);
assert_eq!(cc, cc2);
// Master key must be a valid secp256k1 secret key.
assert!(SecpSecretKey::from_slice(&key).is_ok());
assert_eq!(cc.len(), 32);
}
// ── parse_bip44_path ────────────────────────────────────────────────────
#[test]
fn test_parse_bip44_path() {
let path = parse_bip44_path("m/44'/1237'/0'/0/0").unwrap();
assert_eq!(
path,
vec![
0x8000_0000 + 44,
0x8000_0000 + 1237,
0x8000_0000 + 0,
0,
0,
]
);
// Lowercase h and uppercase H also indicate hardened derivation.
let path_h = parse_bip44_path("m/44h/1237h/0h/0/0").unwrap();
assert_eq!(path_h, path);
let path_cap = parse_bip44_path("M/44H/1237H/0H/0/0").unwrap();
assert_eq!(path_cap, path);
// Path without leading m/ should also parse.
let path_no_m = parse_bip44_path("44'/1237'/0'/0/0").unwrap();
assert_eq!(path_no_m, path);
// Invalid segment should error.
assert!(parse_bip44_path("m/abc'/0").is_err());
// Value too large for hardened bit should error.
assert!(parse_bip44_path("m/8000000000'/0").is_err());
}
// ── BIP-32 hardened child derivation: known vector ──────────────────────
//
// BIP-32 test vector 1, deriving m/0' from the master key:
// master private key = e8f32e723decf4051aefac8e2c93c9c5b214313817cdb01a1494b917c8436b35
// master chain code = 873dff81c02f525623fd1fe5167eac3a55a049de3d314bb42ee227ffed37d508
// m/0' private key = edb2e14f9ee77d26dd93b4ecede8d16ed408ce149b6cd80b0715a2d911a0afea
// m/0' chain code = 47fdacbd0f1097043b78c63e20c34ef4ed9a111d980047ad16282c7ae6236141
#[test]
fn test_bip32_derive_child_hardened_vector() {
let mut parent_key = [0u8; 32];
parent_key.copy_from_slice(
&hex::decode("e8f32e723decf4051aefac8e2c93c9c5b214313817cdb01a1494b917c8436b35")
.unwrap(),
);
let mut parent_cc = [0u8; 32];
parent_cc.copy_from_slice(
&hex::decode("873dff81c02f525623fd1fe5167eac3a55a049de3d314bb42ee227ffed37d508")
.unwrap(),
);
let (child_key, child_cc) =
bip32_derive_child(&parent_key, &parent_cc, 0x8000_0000).unwrap();
assert_eq!(
hex::encode(child_key),
"edb2e14f9ee77d26dd93b4ecede8d16ed408ce149b6cd80b0715a2d911a0afea"
);
assert_eq!(
hex::encode(child_cc),
"47fdacbd0f1097043b78c63c20c34ef4ed9a111d980047ad16282c7ae6236141"
);
}
// ── BIP-32 non-hardened child derivation: known vector ──────────────────
//
// BIP-32 test vector 1, deriving m/0'/1 from m/0':
// m/0' private key = edb2e14f9ee77d26dd93b4ecede8d16ed408ce149b6cd80b0715a2d911a0afea
// m/0' chain code = 47fdacbd0f1097043b78c63e20c34ef4ed9a111d980047ad16282c7ae6236141
// m/0'/1 private key= 3c6cb8d0f6a264c91ea8b5030fadaa8e538b020f0a387421a12de9319dc93368
// m/0'/1 chain code = 2a7857631386ba23dacac3412ddca3f0da4b55a1d6e0231fcb4d8290c9421327
#[test]
fn test_bip32_derive_child_nonhardened_vector() {
let mut parent_key = [0u8; 32];
parent_key.copy_from_slice(
&hex::decode("edb2e14f9ee77d26dd93b4ecede8d16ed408ce149b6cd80b0715a2d911a0afea")
.unwrap(),
);
let mut parent_cc = [0u8; 32];
parent_cc.copy_from_slice(
&hex::decode("47fdacbd0f1097043b78c63c20c34ef4ed9a111d980047ad16282c7ae6236141")
.unwrap(),
);
let (child_key, child_cc) = bip32_derive_child(&parent_key, &parent_cc, 1).unwrap();
assert_eq!(
hex::encode(child_key),
"3c6cb8d0f6a264c91ea8b5030fadaa8e538b020f0a387421a12de9319dc93368"
);
assert_eq!(
hex::encode(child_cc),
"2a7857631386ba23dacac34180dd1983734e444fdbf774041578e9b6adb37c19"
);
}
// ── Full path derivation through m/44'/1237'/0'/0/0 ─────────────────────
#[test]
fn test_keypair_from_seed() {
let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
let seed = mnemonic_to_seed(mnemonic, "");
let result = keypair_from_seed(&seed);
assert!(result.is_ok());
let (sk, pk) = keypair_from_seed(&seed).expect("derivation should succeed");
// The derived secret key must be a valid secp256k1 secret key.
assert!(SecpSecretKey::from_slice(sk.as_bytes()).is_ok(),
"derived secret key must be valid");
// The public key must match the secret key (round-trip consistency).
let derived_pk = public_key_from_secret_key(&sk).unwrap();
assert_eq!(pk, derived_pk, "public key must match secret key");
// Determinism: deriving twice must produce the same keypair.
let (sk2, pk2) = keypair_from_seed(&seed).unwrap();
assert_eq!(sk, sk2);
assert_eq!(pk, pk2);
}
// ── SLIP-0010 ed25519 tests ─────────────────────────────────────────────
#[test]
fn test_slip10_master_key() {
let mut seed64 = [0u8; 64];
let small = hex::decode("000102030405060708090a0b0c0d0e0f").unwrap();
seed64[..16].copy_from_slice(&small);
let (key, cc) = slip10_master_key(&seed64);
let (key2, cc2) = slip10_master_key(&seed64);
assert_eq!(key, key2);
assert_eq!(cc, cc2);
assert_eq!(key.len(), 32);
assert_eq!(cc.len(), 32);
}
// SLIP-0010 ed25519 child derivation: all indices are treated as hardened.
#[test]
fn test_slip10_derive_child_hardens_all() {
let key = [0x42u8; 32];
let cc = [0x43u8; 32];
// Non-hardened indices are auto-hardened and must succeed.
let (k0, c0) = slip10_derive_child(&key, &cc, 0).unwrap();
let (k1, c1) = slip10_derive_child(&key, &cc, 1).unwrap();
// Explicitly hardened index 0 must match auto-hardened index 0.
let (k0h, c0h) = slip10_derive_child(&key, &cc, 0x8000_0000).unwrap();
assert_eq!(k0, k0h);
assert_eq!(c0, c0h);
// Different indices produce different keys.
assert_ne!(k0, k1);
assert_ne!(c0, c1);
}
// SLIP-0010 ed25519 full path derivation produces a valid ed25519 keypair.
#[test]
fn test_keypair_from_seed_ed25519() {
let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
let seed = mnemonic_to_seed(mnemonic, "");
let (priv_key, pub_key) = keypair_from_seed_ed25519(&seed).unwrap();
assert_eq!(priv_key.len(), 32);
assert_eq!(pub_key.len(), 32);
// Determinism.
let (priv2, pub2) = keypair_from_seed_ed25519(&seed).unwrap();
assert_eq!(priv_key, priv2);
assert_eq!(pub_key, pub2);
// The public key must not be all zeros.
assert_ne!(pub_key, [0u8; 32]);
}
// ── Pinned parity test vectors (verified against a reference BIP-32
// implementation) ──────────────────────────────────────────────────────
//
// For mnemonic "abandon ... about" (empty passphrase), deriving through
// m/44'/1237'/0'/0/0 yields:
// Nostr secret key = 5f29af3b9676180290e77a4efad265c4c2ff28a5302461f73597fda26bb25731
// Nostr public key = e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f
#[test]
fn test_keypair_from_seed_pinned_vector() {
let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
let seed = mnemonic_to_seed(mnemonic, "");
let (sk, pk) = keypair_from_seed(&seed).unwrap();
assert_eq!(
sk.to_hex(),
"5f29af3b9676180290e77a4efad265c4c2ff28a5302461f73597fda26bb25731"
);
assert_eq!(
pk.to_hex(),
"e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f"
);
}
// SLIP-0010 ed25519 pinned vector for the same mnemonic.
// ed25519 secret key = dc03109ee8e06e18cee872b30efece39283e898c3355739a89fce2de6aa80cb1
// ed25519 public key = 73b263ebc50f4ad169f18d1d618489cb8e6dd29fbfed8d5f3dc0e4dc32931eb6
#[test]
fn test_keypair_from_seed_ed25519_pinned_vector() {
let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
let seed = mnemonic_to_seed(mnemonic, "");
let (priv_key, pub_key) = keypair_from_seed_ed25519(&seed).unwrap();
assert_eq!(
hex::encode(priv_key),
"dc03109ee8e06e18cee872b30efece39283e898c3355739a89fce2de6aa80cb1"
);
assert_eq!(
hex::encode(pub_key),
"73b263ebc50f4ad169f18d1d618489cb8e6dd29fbfed8d5f3dc0e4dc32931eb6"
);
}
}
+219
View File
@@ -0,0 +1,219 @@
# Audit: Incomplete Implementations in nostr_core_lib_rust
This audit verifies the reported BIP-32 HD derivation gap in NIP-06 and catalogs
every other place where the Rust port skips functionality present in the C
`nostr_core_lib`. Findings are ordered by severity (correctness-breaking first).
## Severity Legend
- **CRITICAL** — produces wrong output / breaks C/Rust compatibility
- **HIGH** — feature advertised by the API but silently does nothing
- **MEDIUM** — planned feature with a stub that returns success unconditionally
- **LOW** — convenience variant missing; core path works
---
## 1. NIP-06: BIP-32 HD path derivation — CRITICAL
**File:** [`nips/src/nip006.rs`](nips/src/nip006.rs:293) — `keypair_from_seed`
**Confirmed.** The function performs BIP-39 seed derivation and BIP-32 master
key extraction, then **skips the path derivation entirely**:
```rust
// nips/src/nip006.rs:300-305
// Derive path m/44'/1237'/0'/0/0
// For simplicity, use the master key directly as the Nostr private key
// A full BIP32 implementation would derive through the path
let sk = SecretKey::from_bytes(master_key);
```
The chain code (`master_hmac[32..64]`) is computed and then discarded with a
`// not needed for this derivation` comment. The same mnemonic + path produces
**completely different keypairs** in C vs Rust, breaking identity portability.
### What must be implemented
| Function | Purpose |
|----------|---------|
| `bip32_derive_child(parent_key, parent_chain_code, index, hardened)` | Core HMAC-SHA512 child derivation: hardened uses `0x00 ‖ parent_priv ‖ index`, non-hardened uses `parent_pub ‖ index`; split result into child key `[..32]` + child chain code `[32..]`; non-hardened requires modular addition with parent key mod n. |
| `bip32_derive_path(master_key, chain_code, path: &[u32])` | Iterate path segments, threading `(key, chain_code)` through each level. |
| `parse_bip44_path("m/44'/1237'/0'/0/0")` | Parse into `Vec<u32>`; set bit 31 for hardened (`'`) segments. |
| `slip10_derive_ed25519` / `slip10_derive_x25519` | SLIP-0010 variant: all segments hardened, child key = `HMAC-SHA512(chain_code, 0x00 ‖ parent_priv ‖ index)[..32]` (no modular reduction, no non-hardened support). |
### Test requirement
Add BIP-32 test vectors (e.g. the standard `abandon abandon ... about` vector
for `m/44'/1237'/0'/0/0`) and assert byte-for-byte equality with the C
implementation's output. Without this, the port is not verifiable.
---
## 2. NIP-03: OpenTimestamps verification — HIGH
**File:** [`nips/src/nip003.rs`](nips/src/nip003.rs:11) — `verify_ots`
The entire NIP-03 module is a stub. After two input-length checks, it returns
`Ok(true)` unconditionally:
```rust
// nips/src/nip003.rs:18-20
// TODO: Full OTS file parsing and verification
// For now, return true if the OTS data is non-empty (placeholder)
Ok(true)
```
This means any non-empty OTS blob is reported as a valid attestation. The C
version parses the OTS binary format and verifies the Merkle inclusion proof
against the Bitcoin blockchain. The Rust port has **no OTS parser, no Merkle
proof verification, and no Bitcoin header lookup**.
### What must be implemented
- OTS binary file parser (attestation + proof operations sequence)
- Merkle tree inclusion proof verification
- Bitcoin block header / tx lookup (or delegation to an OTS verifier crate)
- Real success/failure return based on proof validity
---
## 3. Validator: 6 of 8 auth rule types unimplemented — HIGH
**File:** [`services/src/validator.rs`](services/src/validator.rs:269) — `validate_request`
The rule dispatch only handles `PubkeyWhitelist` and `PubkeyBlacklist`. Every
other rule type falls through a `_ => {}` arm that silently allows the request:
```rust
// services/src/validator.rs:297
_ => {} // Other rules not yet implemented
```
The `AuthRuleType` enum advertises 8 variants; only 2 are enforced:
| Rule type | Implemented? |
|-----------|--------------|
| `PubkeyWhitelist` | ✅ |
| `PubkeyBlacklist` | ✅ |
| `HashBlacklist` | ❌ (silently allowed) |
| `MimeWhitelist` | ❌ (silently allowed) |
| `MimeBlacklist` | ❌ (silently allowed) |
| `SizeLimit` | ❌ (silently allowed) |
| `RateLimit` | ❌ (silently allowed) |
| `Custom` | ❌ (silently allowed) |
This is a security gap: rules configured in the database are queried, loaded,
and then ignored. A request that should be denied by a `SizeLimit` or
`HashBlacklist` rule is admitted.
### What must be implemented
Each arm needs its matching logic against `AuthRequest` fields
(`resource_hash`, `mime_type`, `file_size`, `client_ip`). `RateLimit` requires
a stateful counter (per-IP window) — likely needs a cache field on
`RequestValidator` or the backend.
---
## 4. Nsigner: 3 of 5 transports missing — MEDIUM
**File:** [`signer/src/nsigner.rs`](signer/src/nsigner.rs:15) — `NsignerTransport`
Only `UnixTransport` and `TcpTransport` are implemented. The module docstring
and the rewrite plan (Step 3.2) promise five transports:
| Transport | Implemented? |
|-----------|--------------|
| Unix socket | ✅ |
| TCP | ✅ |
| Serial (CDC-ACM) | ❌ |
| FD-pair | ❌ |
| Qubes qrexec | ❌ |
The serial and qrexec transports are the primary channels for hardware-isolated
n_signer deployments (Qubes OS, USB signers). Without them the Rust port cannot
talk to those daemons.
---
## 5. Nsigner: algorithm verbs & post-quantum operations missing — MEDIUM
**File:** [`signer/src/nsigner.rs`](signer/src/nsigner.rs:340) — `derive_hmac` and trait impl
The `NostrSigner` trait ([`signer/src/traits.rs`](signer/src/traits.rs:13)) only
covers secp256k1 operations. The rewrite plan (Step 3.4) specifies a much
broader algorithm surface that is absent:
- `ed25519` / `x25519` derive & sign (requires SLIP-0010, see item 1)
- `ml-dsa-65` sign/verify (post-quantum signatures)
- `ML-KEM-768` encapsulate / decapsulate (post-quantum KEM)
- OTP encrypt / decrypt
- `derive_hmac` hardcodes `"algorithm": "secp256k1"` at
[`nsigner.rs:344`](signer/src/nsigner.rs:344) — no algorithm parameterization
- `role_path` is stored ([`nsigner.rs:296`](signer/src/nsigner.rs:296)) but
never sent in any RPC call
---
## 6. Cashu mint client: 5 of 8 operations missing — MEDIUM
**File:** [`services/src/cashu.rs`](services/src/cashu.rs:51)
Implemented: `get_mint_info`, `get_mint_keys`, `request_mint_quote`,
`check_mint_quote`. The rewrite plan (Step 3.7) lists 8 operations:
| Operation | Implemented? |
|-----------|--------------|
| Mint info | ✅ |
| Keyset keys | ✅ (but returns raw `serde_json::Value`, not `CashuKeyset`) |
| Mint quote (request/check) | ✅ |
| Melt quote (request/check) | ❌ |
| Mint tokens | ❌ |
| Swap | ❌ |
| Check spent proofs | ❌ |
| Restore keysets | ❌ |
`CashuKeyset` is defined ([`cashu.rs:22`](services/src/cashu.rs:22)) but
`get_mint_keys` returns untyped JSON, so the struct is dead code.
---
## 7. Blossom client: convenience variants missing — LOW
**File:** [`services/src/blossom.rs`](services/src/blossom.rs:20)
Implemented: `create_auth_header`, `upload` (bytes), `download` (to memory),
`delete`. The rewrite plan (Step 3.6) also specifies:
| Operation | Implemented? |
|-----------|--------------|
| Auth header | ✅ |
| Upload (bytes) | ✅ |
| Upload (file path) | ❌ |
| Upload (with `NostrSigner` trait) | ❌ (only raw `SecretKey`) |
| Download (to memory) | ✅ |
| Download (to file path) | ❌ |
| HEAD request | ❌ |
| Delete | ✅ |
The `NostrSigner`-accepting variants matter for remote-signer deployments where
the caller does not have the raw secret key locally.
---
## Summary
| # | Area | Severity | Root cause |
|---|------|----------|------------|
| 1 | NIP-06 BIP-32 path derivation | CRITICAL | Steps 3-4 of derivation skipped; chain code discarded |
| 2 | NIP-03 OpenTimestamps | HIGH | Entire module is a `Ok(true)` stub |
| 3 | Validator auth rules | HIGH | 6/8 rule types fall through to `_ => {}` |
| 4 | Nsigner transports | MEDIUM | 3/5 transports not implemented |
| 5 | Nsigner algorithm verbs | MEDIUM | PQ + ed25519/x25519 + OTP missing; role_path unused |
| 6 | Cashu mint client | MEDIUM | 5/8 operations missing; keyset struct unused |
| 7 | Blossom client | LOW | HEAD + file-path + signer-trait variants missing |
The BIP-32 gap reported by the user is real and is the most severe because it
silently produces wrong keys. Items 2 and 3 are the next most dangerous because
they silently report success for operations that were not performed — a
security-relevant failure mode for both timestamp attestation and auth rules.
+1 -1
View File
@@ -9,7 +9,7 @@ Rewrite the C-based `nostr_core_lib` (~20+ NIP modules, custom crypto, WebSocket
### Workspace Structure
```
rust_core_lib/
nostr_core_lib_rust/
├── Cargo.toml # Workspace root
├── core/ # Core types, errors, crypto, utilities
│ ├── Cargo.toml
+269 -1
View File
@@ -3,7 +3,7 @@
//! Provides upload, download, and management of files on Blossom servers.
use nostr_core::error::NostrError;
use nostr_core::types::SecretKey;
use nostr_core::types::{PublicKey, SecretKey, Signature};
use nostr_core::NostrResult;
/// A blob descriptor returned by Blossom operations.
@@ -16,6 +16,24 @@ pub struct BlobDescriptor {
pub created: i64,
}
/// Trait for signers that can create Blossom auth headers.
/// This allows using either a local secret key or a remote signer.
pub trait BlossomSigner: Send + Sync {
/// Get the public key for auth header construction.
fn get_public_key(&self) -> NostrResult<PublicKey>;
/// Sign a 32-byte digest and return the signature.
fn sign_digest(&self, digest: &[u8; 32]) -> NostrResult<Signature>;
}
impl BlossomSigner for SecretKey {
fn get_public_key(&self) -> NostrResult<PublicKey> {
nostr_core::crypto::keys::public_key_from_secret_key(self)
}
fn sign_digest(&self, digest: &[u8; 32]) -> NostrResult<Signature> {
nostr_core::crypto::keys::schnorr_sign(self, digest)
}
}
/// Create a Blossom authentication header.
pub fn create_auth_header(
private_key: &SecretKey,
@@ -48,6 +66,35 @@ pub fn create_auth_header(
))
}
/// Create a Blossom auth header using any BlossomSigner.
pub fn create_auth_header_with_signer(
signer: &dyn BlossomSigner,
operation: &str,
sha256_hex: &str,
expiration_seconds: i32,
) -> NostrResult<String> {
let pubkey = signer.get_public_key()?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let exp = now + expiration_seconds as u64;
let payload = format!("{}:{}:{}:{}", operation, sha256_hex, now, exp);
let payload_hash = nostr_core::crypto::sha256::sha256(payload.as_bytes());
let sig = signer.sign_digest(&payload_hash)?;
Ok(format!(
"Nostr {}:{}:{}:{}:{}",
pubkey.to_hex(),
operation,
sha256_hex,
exp,
sig.to_hex()
))
}
/// Upload data to a Blossom server.
pub async fn upload(
server_url: &str,
@@ -91,6 +138,49 @@ pub async fn upload(
})
}
/// Upload data using a BlossomSigner (supports remote signers).
pub async fn upload_with_signer(
server_url: &str,
data: &[u8],
content_type: &str,
signer: &dyn BlossomSigner,
sha256_hex: &str,
timeout_seconds: u64,
) -> NostrResult<BlobDescriptor> {
let auth_header = create_auth_header_with_signer(signer, "upload", sha256_hex, 60)?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(timeout_seconds))
.build()
.map_err(|_| NostrError::NetworkFailed)?;
let response = client
.put(&format!("{}/upload", server_url))
.header("Authorization", &auth_header)
.header("Content-Type", content_type)
.body(data.to_vec())
.send()
.await
.map_err(|_| NostrError::NetworkFailed)?;
if !response.status().is_success() {
return Err(NostrError::NetworkFailed);
}
let body: serde_json::Value = response
.json()
.await
.map_err(|_| NostrError::InvalidInput)?;
Ok(BlobDescriptor {
sha256: body["sha256"].as_str().unwrap_or("").to_string(),
url: body["url"].as_str().unwrap_or("").to_string(),
size: body["size"].as_i64().unwrap_or(0),
content_type: body["content_type"].as_str().unwrap_or("").to_string(),
created: body["created"].as_i64().unwrap_or(0),
})
}
/// Download data from a Blossom server.
pub async fn download(
server_url: &str,
@@ -126,6 +216,19 @@ pub async fn download(
Ok(bytes)
}
/// Download a blob from a Blossom server and save it to a local file path.
pub async fn download_to_file(
server_url: &str,
sha256_hex: &str,
file_path: &str,
timeout_seconds: u64,
max_bytes: usize,
) -> NostrResult<u64> {
let bytes = download(server_url, sha256_hex, timeout_seconds, max_bytes).await?;
std::fs::write(file_path, &bytes).map_err(|_| NostrError::IoFailed)?;
Ok(bytes.len() as u64)
}
/// Delete a blob from a Blossom server.
pub async fn delete(
server_url: &str,
@@ -154,6 +257,123 @@ pub async fn delete(
Ok(())
}
/// Delete a blob using a BlossomSigner.
pub async fn delete_with_signer(
server_url: &str,
sha256_hex: &str,
signer: &dyn BlossomSigner,
timeout_seconds: u64,
) -> NostrResult<()> {
let auth_header = create_auth_header_with_signer(signer, "delete", sha256_hex, 60)?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(timeout_seconds))
.build()
.map_err(|_| NostrError::NetworkFailed)?;
let response = client
.delete(&format!("{}/{}", server_url, sha256_hex))
.header("Authorization", &auth_header)
.send()
.await
.map_err(|_| NostrError::NetworkFailed)?;
if !response.status().is_success() {
return Err(NostrError::NetworkFailed);
}
Ok(())
}
/// Check if a blob exists on the server and get its metadata without downloading.
pub async fn head_blob(
server_url: &str,
sha256_hex: &str,
timeout_seconds: u64,
) -> NostrResult<BlobDescriptor> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(timeout_seconds))
.build()
.map_err(|_| NostrError::NetworkFailed)?;
let response = client
.head(&format!("{}/{}", server_url, sha256_hex))
.send()
.await
.map_err(|_| NostrError::NetworkFailed)?;
let status = response.status();
if status.as_u16() == 404 {
return Err(NostrError::InvalidInput);
}
if !status.is_success() {
return Err(NostrError::NetworkFailed);
}
let headers = response.headers();
let sha256 = headers
.get("x-sha-256")
.or_else(|| headers.get("x-content-sha256"))
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.unwrap_or_else(|| sha256_hex.to_string());
let content_type = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let size = headers
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0);
let url = headers
.get("location")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("{}/{}", server_url, sha256_hex));
let created = headers
.get("x-created-at")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0);
Ok(BlobDescriptor {
sha256,
url,
size,
content_type,
created,
})
}
/// Upload a file from a local file path to a Blossom server.
pub async fn upload_file(
server_url: &str,
file_path: &str,
content_type: &str,
private_key: &SecretKey,
timeout_seconds: u64,
) -> NostrResult<BlobDescriptor> {
let data = std::fs::read(file_path).map_err(|_| NostrError::IoFailed)?;
let hash = nostr_core::crypto::sha256::sha256(&data);
let sha256_hex = nostr_core::util::hex_util::bytes_to_hex(&hash);
upload(
server_url,
&data,
content_type,
private_key,
&sha256_hex,
timeout_seconds,
)
.await
}
#[cfg(test)]
mod tests {
use super::*;
@@ -166,4 +386,52 @@ mod tests {
assert!(header.starts_with("Nostr "));
assert!(header.contains(&pk.to_hex()));
}
#[test]
fn test_create_auth_header_with_signer() {
let (sk, pk) = generate_keypair();
let signer: &dyn BlossomSigner = &sk;
let header = create_auth_header_with_signer(signer, "upload", "abc123", 60).unwrap();
assert!(header.starts_with("Nostr "));
assert!(header.contains(&pk.to_hex()));
// Format: Nostr <pubkey>:<operation>:<sha256>:<exp>:<sig>
let rest = header.strip_prefix("Nostr ").unwrap();
let parts: Vec<&str> = rest.split(':').collect();
assert_eq!(parts.len(), 5);
assert_eq!(parts[1], "upload");
assert_eq!(parts[2], "abc123");
}
#[test]
fn test_blossom_signer_for_secret_key() {
let (sk, pk) = generate_keypair();
let signer: &dyn BlossomSigner = &sk;
let got_pk = signer.get_public_key().unwrap();
assert_eq!(got_pk, pk);
let digest = nostr_core::crypto::sha256::sha256(b"test message");
let sig = signer.sign_digest(&digest).unwrap();
assert_eq!(sig.0.len(), 64);
}
#[tokio::test]
async fn test_upload_file_not_found() {
let (sk, _pk) = generate_keypair();
let result = upload_file(
"http://127.0.0.1:9",
"/nonexistent/path/does/not/exist/file.bin",
"application/octet-stream",
&sk,
5,
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_head_blob_compiles() {
// Verify head_blob compiles and returns an error for an unreachable server.
let result = head_blob("http://127.0.0.1:9", "abc123", 2).await;
assert!(result.is_err());
}
}
+448 -2
View File
@@ -18,7 +18,7 @@ pub struct CashuMintInfo {
pub nuts: Option<Vec<String>>,
}
/// Cashu keyset information.
/// Cashu keyset information (legacy, without keys map).
#[derive(Debug, Clone, serde::Deserialize)]
pub struct CashuKeyset {
pub id: String,
@@ -26,6 +26,21 @@ pub struct CashuKeyset {
pub active: bool,
}
/// Cashu keyset including the keys map returned by `/v1/keys`.
#[derive(Debug, Clone, serde::Deserialize)]
pub struct CashuKeysetKeys {
pub id: String,
pub unit: String,
pub active: bool,
pub keys: Option<std::collections::HashMap<String, String>>,
}
/// Response from the `/v1/keys` endpoint.
#[derive(Debug, Clone, serde::Deserialize)]
pub struct CashuKeysResponse {
pub keysets: Vec<CashuKeysetKeys>,
}
/// Cashu mint quote.
#[derive(Debug, Clone, serde::Deserialize)]
pub struct CashuMintQuote {
@@ -47,6 +62,70 @@ pub struct CashuMeltQuote {
pub payment_preimage: Option<String>,
}
/// A blinded message submitted to the mint for minting or swapping.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct BlindedMessage {
pub amount: u64,
pub id: String, // keyset ID
pub B: String, // blinded public key (hex)
}
/// A blind signature returned by the mint.
#[derive(Debug, Clone, serde::Deserialize)]
pub struct BlindSignature {
pub amount: u64,
pub id: String,
pub C: String, // blind signature (hex)
}
/// Response from the mint/swap endpoints containing blind signatures.
#[derive(Debug, Clone, serde::Deserialize)]
pub struct MintResponse {
pub signatures: Vec<BlindSignature>,
}
/// A Cashu proof (token) that can be spent or swapped.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Proof {
pub amount: u64,
pub id: String, // keyset ID
pub secret: String, // secret commitment
pub C: String, // signature (hex)
}
/// A single proof entry for the checkstate request (only the secret is needed).
#[derive(Debug, Clone, serde::Serialize)]
struct CheckStateProof {
secret: String,
}
/// Request body for the `/v1/checkstate/{unit}` endpoint.
#[derive(Debug, Clone, serde::Serialize)]
struct CheckStateRequest {
proofs: Vec<CheckStateProof>,
}
/// A single state entry returned by the checkstate endpoint.
#[derive(Debug, Clone, serde::Deserialize)]
pub struct CheckStateEntry {
pub state: String,
pub active: bool,
}
/// Response from the `/v1/checkstate/{unit}` endpoint.
#[derive(Debug, Clone, serde::Deserialize)]
pub struct CheckStateResponse {
pub states: Vec<CheckStateEntry>,
}
/// Response from the `/v1/restore/{unit}` endpoint.
#[derive(Debug, Clone, serde::Deserialize)]
pub struct RestoreResponse {
pub outputs: Vec<BlindedMessage>,
pub signatures: Vec<BlindSignature>,
pub promises: Option<Vec<BlindSignature>>, // legacy field
}
/// Fetch mint information.
pub async fn get_mint_info(mint_url: &str) -> NostrResult<CashuMintInfo> {
let url = format!("{}/v1/info", mint_url);
@@ -72,7 +151,7 @@ pub async fn get_mint_info(mint_url: &str) -> NostrResult<CashuMintInfo> {
}
/// Fetch mint keys.
pub async fn get_mint_keys(mint_url: &str) -> NostrResult<serde_json::Value> {
pub async fn get_mint_keys(mint_url: &str) -> NostrResult<CashuKeysResponse> {
let url = format!("{}/v1/keys", mint_url);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
@@ -154,6 +233,212 @@ pub async fn check_mint_quote(
.map_err(|_| NostrError::CashuJsonParseFailed)
}
/// Request a melt quote for paying a Lightning invoice.
pub async fn request_melt_quote(
mint_url: &str,
unit: &str,
request: &str, // BOLT11 Lightning invoice
) -> NostrResult<CashuMeltQuote> {
let url = format!("{}/v1/melt/quote/{}", mint_url, unit);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|_| NostrError::CashuHttpFailed)?;
let body = serde_json::json!({ "request": request });
let response = client
.post(&url)
.json(&body)
.send()
.await
.map_err(|_| NostrError::CashuHttpFailed)?;
if !response.status().is_success() {
return Err(NostrError::CashuHttpFailed);
}
response
.json()
.await
.map_err(|_| NostrError::CashuJsonParseFailed)
}
/// Check the status of a melt quote.
pub async fn check_melt_quote(
mint_url: &str,
unit: &str,
quote_id: &str,
) -> NostrResult<CashuMeltQuote> {
let url = format!("{}/v1/melt/quote/{}/{}", mint_url, unit, quote_id);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|_| NostrError::CashuHttpFailed)?;
let response = client
.get(&url)
.send()
.await
.map_err(|_| NostrError::CashuHttpFailed)?;
if !response.status().is_success() {
return Err(NostrError::CashuHttpFailed);
}
response
.json()
.await
.map_err(|_| NostrError::CashuJsonParseFailed)
}
/// Mint tokens by submitting blinded messages.
pub async fn mint_tokens(
mint_url: &str,
unit: &str,
quote_id: &str,
blinded_messages: &[BlindedMessage],
) -> NostrResult<MintResponse> {
let url = format!("{}/v1/mint/{}", mint_url, unit);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|_| NostrError::CashuHttpFailed)?;
let body = serde_json::json!({
"quote": quote_id,
"outputs": blinded_messages,
});
let response = client
.post(&url)
.json(&body)
.send()
.await
.map_err(|_| NostrError::CashuHttpFailed)?;
if !response.status().is_success() {
return Err(NostrError::CashuHttpFailed);
}
response
.json()
.await
.map_err(|_| NostrError::CashuJsonParseFailed)
}
/// Swap tokens by submitting inputs and blinded outputs.
pub async fn swap_tokens(
mint_url: &str,
unit: &str,
inputs: &[Proof],
outputs: &[BlindedMessage],
) -> NostrResult<MintResponse> {
let url = format!("{}/v1/swap/{}", mint_url, unit);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|_| NostrError::CashuHttpFailed)?;
let body = serde_json::json!({
"inputs": inputs,
"outputs": outputs,
});
let response = client
.post(&url)
.json(&body)
.send()
.await
.map_err(|_| NostrError::CashuHttpFailed)?;
if !response.status().is_success() {
return Err(NostrError::CashuHttpFailed);
}
response
.json()
.await
.map_err(|_| NostrError::CashuJsonParseFailed)
}
/// Check whether proofs have been spent.
pub async fn check_spent(
mint_url: &str,
unit: &str,
proofs: &[Proof],
) -> NostrResult<Vec<bool>> {
let url = format!("{}/v1/checkstate/{}", mint_url, unit);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|_| NostrError::CashuHttpFailed)?;
let request = CheckStateRequest {
proofs: proofs
.iter()
.map(|p| CheckStateProof {
secret: p.secret.clone(),
})
.collect(),
};
let response = client
.post(&url)
.json(&request)
.send()
.await
.map_err(|_| NostrError::CashuHttpFailed)?;
if !response.status().is_success() {
return Err(NostrError::CashuHttpFailed);
}
let resp: CheckStateResponse = response
.json()
.await
.map_err(|_| NostrError::CashuJsonParseFailed)?;
Ok(resp
.states
.iter()
.map(|e| e.state.eq_ignore_ascii_case("SPENT"))
.collect())
}
/// Restore keysets and signatures from the mint.
pub async fn restore_keysets(
mint_url: &str,
unit: &str,
blinded_messages: &[BlindedMessage],
) -> NostrResult<RestoreResponse> {
let url = format!("{}/v1/restore/{}", mint_url, unit);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|_| NostrError::CashuHttpFailed)?;
let body = serde_json::json!({
"outputs": blinded_messages,
});
let response = client
.post(&url)
.json(&body)
.send()
.await
.map_err(|_| NostrError::CashuHttpFailed)?;
if !response.status().is_success() {
return Err(NostrError::CashuHttpFailed);
}
response
.json()
.await
.map_err(|_| NostrError::CashuJsonParseFailed)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -171,4 +456,165 @@ mod tests {
assert_eq!(info.name.unwrap(), "Test Mint");
assert_eq!(info.nuts.unwrap().len(), 2);
}
#[test]
fn test_cashu_keys_response_deserialize() {
let json = r#"{
"keysets": [
{
"id": "00abc",
"unit": "sat",
"active": true,
"keys": {
"1": "02abcd",
"2": "03ef01"
}
}
]
}"#;
let resp: CashuKeysResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.keysets.len(), 1);
let ks = &resp.keysets[0];
assert_eq!(ks.id, "00abc");
assert_eq!(ks.unit, "sat");
assert!(ks.active);
let keys = ks.keys.as_ref().unwrap();
assert_eq!(keys.len(), 2);
assert_eq!(keys.get("1").unwrap(), "02abcd");
assert_eq!(keys.get("2").unwrap(), "03ef01");
}
#[test]
fn test_cashu_keys_response_no_keys() {
let json = r#"{
"keysets": [
{ "id": "00abc", "unit": "sat", "active": false }
]
}"#;
let resp: CashuKeysResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.keysets.len(), 1);
assert!(resp.keysets[0].keys.is_none());
}
#[test]
fn test_cashu_melt_quote_deserialize() {
let json = r#"{
"quote": "q1",
"amount": 100,
"fee_reserve": 1,
"state": "UNPAID",
"expiry": 1700000000,
"payment_preimage": null
}"#;
let q: CashuMeltQuote = serde_json::from_str(json).unwrap();
assert_eq!(q.quote, "q1");
assert_eq!(q.amount, 100);
assert_eq!(q.fee_reserve, 1);
assert_eq!(q.state, "UNPAID");
assert_eq!(q.expiry, Some(1700000000));
assert!(q.payment_preimage.is_none());
}
#[test]
fn test_blinded_message_deserialize() {
let json = r#"{
"amount": 8,
"id": "00abc",
"B": "02abcdef"
}"#;
let bm: BlindedMessage = serde_json::from_str(json).unwrap();
assert_eq!(bm.amount, 8);
assert_eq!(bm.id, "00abc");
assert_eq!(bm.B, "02abcdef");
}
#[test]
fn test_blind_signature_deserialize() {
let json = r#"{
"amount": 8,
"id": "00abc",
"C": "03cdef"
}"#;
let bs: BlindSignature = serde_json::from_str(json).unwrap();
assert_eq!(bs.amount, 8);
assert_eq!(bs.id, "00abc");
assert_eq!(bs.C, "03cdef");
}
#[test]
fn test_mint_response_deserialize() {
let json = r#"{
"signatures": [
{ "amount": 1, "id": "00abc", "C": "03aa" },
{ "amount": 2, "id": "00abc", "C": "03bb" }
]
}"#;
let resp: MintResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.signatures.len(), 2);
assert_eq!(resp.signatures[0].amount, 1);
assert_eq!(resp.signatures[1].C, "03bb");
}
#[test]
fn test_proof_deserialize() {
let json = r#"{
"amount": 4,
"id": "00abc",
"secret": "secret123",
"C": "03cc"
}"#;
let p: Proof = serde_json::from_str(json).unwrap();
assert_eq!(p.amount, 4);
assert_eq!(p.id, "00abc");
assert_eq!(p.secret, "secret123");
assert_eq!(p.C, "03cc");
}
#[test]
fn test_check_state_response_deserialize() {
let json = r#"{
"states": [
{ "state": "UNSPENT", "active": true },
{ "state": "SPENT", "active": false }
]
}"#;
let resp: CheckStateResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.states.len(), 2);
assert_eq!(resp.states[0].state, "UNSPENT");
assert!(resp.states[0].active);
assert_eq!(resp.states[1].state, "SPENT");
assert!(!resp.states[1].active);
}
#[test]
fn test_restore_response_deserialize() {
let json = r#"{
"outputs": [
{ "amount": 1, "id": "00abc", "B": "02aa" }
],
"signatures": [
{ "amount": 1, "id": "00abc", "C": "03bb" }
],
"promises": null
}"#;
let resp: RestoreResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.outputs.len(), 1);
assert_eq!(resp.signatures.len(), 1);
assert!(resp.promises.is_none());
}
#[test]
fn test_restore_response_with_promises() {
let json = r#"{
"outputs": [],
"signatures": [],
"promises": [
{ "amount": 2, "id": "00abc", "C": "03cc" }
]
}"#;
let resp: RestoreResponse = serde_json::from_str(json).unwrap();
assert!(resp.promises.is_some());
assert_eq!(resp.promises.as_ref().unwrap().len(), 1);
assert_eq!(resp.promises.as_ref().unwrap()[0].amount, 2);
}
}
+453 -15
View File
@@ -6,6 +6,9 @@
use nostr_core::error::NostrError;
use nostr_core::types::{Event, PublicKey};
use nostr_core::NostrResult;
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
/// Validate event structure (local implementation to avoid circular deps).
fn validate_event_structure(event: &Event) -> NostrResult<()> {
@@ -225,14 +228,25 @@ impl AuthDbBackend for SqliteAuthBackend {
}
}
/// Entry tracking rate-limit state for a single client IP.
#[derive(Debug, Clone, Default)]
struct RateLimitEntry {
count: u32,
window_start: u64,
}
/// The main request validator.
pub struct RequestValidator {
backend: Box<dyn AuthDbBackend>,
rate_limit_state: Mutex<HashMap<String, RateLimitEntry>>,
}
impl RequestValidator {
pub fn new(backend: Box<dyn AuthDbBackend>) -> Self {
RequestValidator { backend }
RequestValidator {
backend,
rate_limit_state: Mutex::new(HashMap::new()),
}
}
pub fn init(&mut self, db_path: &str, app_name: &str) -> NostrResult<()> {
@@ -265,8 +279,43 @@ impl RequestValidator {
}
};
// Pre-pass: collect enabled MimeWhitelist targets. A request is allowed
// if its MIME type matches ANY whitelist rule; if whitelist rules exist
// and none match, deny.
let mime_whitelist: Vec<&String> = rules
.iter()
.filter(|r| r.rule_type == AuthRuleType::MimeWhitelist && r.enabled)
.map(|r| &r.target)
.collect();
if !mime_whitelist.is_empty() {
match request.mime_type {
Some(ref mime) => {
if !mime_whitelist.iter().any(|t| *t == mime) {
return AuthResult {
valid: false,
error_code: NostrError::AuthRulesDenied,
reason: "MIME type not in whitelist".to_string(),
pubkey: request.event.as_ref().map(|e| e.pubkey.clone()),
};
}
}
None => {
return AuthResult {
valid: false,
error_code: NostrError::AuthRulesDenied,
reason: "MIME type required by whitelist".to_string(),
pubkey: request.event.as_ref().map(|e| e.pubkey.clone()),
};
}
}
}
// Apply rules
for rule in &rules {
if !rule.enabled {
continue;
}
match rule.rule_type {
AuthRuleType::PubkeyWhitelist => {
if let Some(ref event) = request.event {
@@ -275,7 +324,7 @@ impl RequestValidator {
return AuthResult {
valid: false,
error_code: NostrError::AuthRulesDenied,
reason: format!("Pubkey not in whitelist"),
reason: "Pubkey not in whitelist".to_string(),
pubkey: Some(event.pubkey.clone()),
};
}
@@ -288,13 +337,73 @@ impl RequestValidator {
return AuthResult {
valid: false,
error_code: NostrError::AuthRulesDenied,
reason: format!("Pubkey in blacklist"),
reason: "Pubkey in blacklist".to_string(),
pubkey: Some(event.pubkey.clone()),
};
}
}
}
_ => {} // Other rules not yet implemented
AuthRuleType::HashBlacklist => {
if let Some(ref hash) = request.resource_hash {
if *hash == rule.target {
return AuthResult {
valid: false,
error_code: NostrError::AuthRulesDenied,
reason: "Resource hash is blacklisted".to_string(),
pubkey: request.event.as_ref().map(|e| e.pubkey.clone()),
};
}
}
}
AuthRuleType::MimeWhitelist => {
// Handled in the pre-pass above; skip here.
}
AuthRuleType::MimeBlacklist => {
if let Some(ref mime) = request.mime_type {
if *mime == rule.target {
return AuthResult {
valid: false,
error_code: NostrError::AuthRulesDenied,
reason: "MIME type is blacklisted".to_string(),
pubkey: request.event.as_ref().map(|e| e.pubkey.clone()),
};
}
}
}
AuthRuleType::SizeLimit => {
if let Some(size) = request.file_size {
let limit = rule.value.parse::<i64>().unwrap_or(0);
if size > limit {
return AuthResult {
valid: false,
error_code: NostrError::AuthRulesDenied,
reason: format!(
"File size {} exceeds limit {}",
size, limit
),
pubkey: request.event.as_ref().map(|e| e.pubkey.clone()),
};
}
}
}
AuthRuleType::RateLimit => {
if let Some(ref ip) = request.client_ip {
if let Some(denied) = self.check_rate_limit(rule, ip) {
return denied;
}
}
}
AuthRuleType::Custom => {
if rule.value == "deny" {
return AuthResult {
valid: false,
error_code: NostrError::AuthRulesDenied,
reason: "Denied by custom rule".to_string(),
pubkey: request.event.as_ref().map(|e| e.pubkey.clone()),
};
}
// "allow" or anything else: skip (passthrough).
}
}
}
@@ -305,12 +414,81 @@ impl RequestValidator {
pubkey: request.event.as_ref().map(|e| e.pubkey.clone()),
}
}
/// Evaluate a RateLimit rule for the given client IP. Returns `Some(AuthResult)`
/// (denied) if the IP has exceeded the limit, or `None` if allowed.
fn check_rate_limit(&self, rule: &AuthRule, ip: &str) -> Option<AuthResult> {
// Parse config like "100/60" (max requests / window seconds).
let (max_requests, window_seconds) = parse_rate_limit_config(&rule.value);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let mut state = self.rate_limit_state.lock().ok()?;
let entry = state.entry(ip.to_string()).or_insert_with(|| RateLimitEntry {
count: 0,
window_start: now,
});
// Reset the window if it has expired.
if now.saturating_sub(entry.window_start) >= window_seconds {
entry.count = 0;
entry.window_start = now;
}
if entry.count >= max_requests {
return Some(AuthResult {
valid: false,
error_code: NostrError::AuthRulesDenied,
reason: format!(
"Rate limit exceeded for {} (max {}/{}s)",
ip, max_requests, window_seconds
),
pubkey: None,
});
}
entry.count += 1;
None
}
}
/// Parse a rate-limit config string of the form "max/window_seconds"
/// (e.g. "100/60"). Returns `(0, 0)` on parse failure.
fn parse_rate_limit_config(config: &str) -> (u32, u64) {
let mut parts = config.split('/');
let max = parts.next().and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
let window = parts.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
(max, window)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_validator() -> RequestValidator {
let mut backend = Box::new(SqliteAuthBackend::new());
backend.init(":memory:", "test").unwrap();
RequestValidator::new(backend)
}
fn add_rule(validator: &RequestValidator, rule: AuthRule) {
validator.backend.rule_add(&rule).unwrap();
}
fn base_request(op: &str) -> AuthRequest {
AuthRequest {
operation: op.to_string(),
event: None,
resource_hash: None,
mime_type: None,
file_size: None,
client_ip: None,
}
}
#[test]
fn test_sqlite_backend_init() {
let mut backend = SqliteAuthBackend::new();
@@ -339,17 +517,277 @@ mod tests {
#[test]
fn test_validator_creation() {
let mut backend = Box::new(SqliteAuthBackend::new());
backend.init(":memory:", "test").unwrap();
let validator = RequestValidator::new(backend);
let result = validator.validate_request(&AuthRequest {
operation: "publish".to_string(),
event: None,
resource_hash: None,
mime_type: None,
file_size: None,
client_ip: None,
});
let validator = make_validator();
let result = validator.validate_request(&base_request("publish"));
assert!(result.valid);
}
#[test]
fn test_hash_blacklist_denied() {
let validator = make_validator();
add_rule(
&validator,
AuthRule {
rule_id: 0,
rule_type: AuthRuleType::HashBlacklist,
operation: "upload".to_string(),
target: "deadbeef".to_string(),
value: "".to_string(),
priority: 0,
enabled: true,
},
);
let mut req = base_request("upload");
req.resource_hash = Some("deadbeef".to_string());
let result = validator.validate_request(&req);
assert!(!result.valid);
assert_eq!(result.error_code, NostrError::AuthRulesDenied);
}
#[test]
fn test_hash_blacklist_allowed() {
let validator = make_validator();
add_rule(
&validator,
AuthRule {
rule_id: 0,
rule_type: AuthRuleType::HashBlacklist,
operation: "upload".to_string(),
target: "deadbeef".to_string(),
value: "".to_string(),
priority: 0,
enabled: true,
},
);
let mut req = base_request("upload");
req.resource_hash = Some("cafef00d".to_string());
let result = validator.validate_request(&req);
assert!(result.valid);
}
#[test]
fn test_mime_whitelist_allowed() {
let validator = make_validator();
add_rule(
&validator,
AuthRule {
rule_id: 0,
rule_type: AuthRuleType::MimeWhitelist,
operation: "upload".to_string(),
target: "image/png".to_string(),
value: "".to_string(),
priority: 0,
enabled: true,
},
);
let mut req = base_request("upload");
req.mime_type = Some("image/png".to_string());
let result = validator.validate_request(&req);
assert!(result.valid);
}
#[test]
fn test_mime_whitelist_denied() {
let validator = make_validator();
add_rule(
&validator,
AuthRule {
rule_id: 0,
rule_type: AuthRuleType::MimeWhitelist,
operation: "upload".to_string(),
target: "image/png".to_string(),
value: "".to_string(),
priority: 0,
enabled: true,
},
);
let mut req = base_request("upload");
req.mime_type = Some("application/zip".to_string());
let result = validator.validate_request(&req);
assert!(!result.valid);
assert_eq!(result.error_code, NostrError::AuthRulesDenied);
}
#[test]
fn test_mime_whitelist_no_mime_denied() {
let validator = make_validator();
add_rule(
&validator,
AuthRule {
rule_id: 0,
rule_type: AuthRuleType::MimeWhitelist,
operation: "upload".to_string(),
target: "image/png".to_string(),
value: "".to_string(),
priority: 0,
enabled: true,
},
);
let req = base_request("upload");
let result = validator.validate_request(&req);
assert!(!result.valid);
assert_eq!(result.error_code, NostrError::AuthRulesDenied);
}
#[test]
fn test_mime_blacklist_denied() {
let validator = make_validator();
add_rule(
&validator,
AuthRule {
rule_id: 0,
rule_type: AuthRuleType::MimeBlacklist,
operation: "upload".to_string(),
target: "application/zip".to_string(),
value: "".to_string(),
priority: 0,
enabled: true,
},
);
let mut req = base_request("upload");
req.mime_type = Some("application/zip".to_string());
let result = validator.validate_request(&req);
assert!(!result.valid);
assert_eq!(result.error_code, NostrError::AuthRulesDenied);
}
#[test]
fn test_size_limit_denied() {
let validator = make_validator();
add_rule(
&validator,
AuthRule {
rule_id: 0,
rule_type: AuthRuleType::SizeLimit,
operation: "upload".to_string(),
target: "".to_string(),
value: "1024".to_string(),
priority: 0,
enabled: true,
},
);
let mut req = base_request("upload");
req.file_size = Some(2048);
let result = validator.validate_request(&req);
assert!(!result.valid);
assert_eq!(result.error_code, NostrError::AuthRulesDenied);
}
#[test]
fn test_size_limit_allowed() {
let validator = make_validator();
add_rule(
&validator,
AuthRule {
rule_id: 0,
rule_type: AuthRuleType::SizeLimit,
operation: "upload".to_string(),
target: "".to_string(),
value: "1024".to_string(),
priority: 0,
enabled: true,
},
);
let mut req = base_request("upload");
req.file_size = Some(512);
let result = validator.validate_request(&req);
assert!(result.valid);
}
#[test]
fn test_rate_limit_denied() {
let validator = make_validator();
add_rule(
&validator,
AuthRule {
rule_id: 0,
rule_type: AuthRuleType::RateLimit,
operation: "publish".to_string(),
target: "".to_string(),
value: "2/3600".to_string(),
priority: 0,
enabled: true,
},
);
let mut req = base_request("publish");
req.client_ip = Some("10.0.0.1".to_string());
// First two requests allowed.
assert!(validator.validate_request(&req).valid);
assert!(validator.validate_request(&req).valid);
// Third request denied.
let result = validator.validate_request(&req);
assert!(!result.valid);
assert_eq!(result.error_code, NostrError::AuthRulesDenied);
}
#[test]
fn test_rate_limit_allowed() {
let validator = make_validator();
add_rule(
&validator,
AuthRule {
rule_id: 0,
rule_type: AuthRuleType::RateLimit,
operation: "publish".to_string(),
target: "".to_string(),
value: "100/3600".to_string(),
priority: 0,
enabled: true,
},
);
let mut req = base_request("publish");
req.client_ip = Some("10.0.0.2".to_string());
let result = validator.validate_request(&req);
assert!(result.valid);
}
#[test]
fn test_rate_limit_window_reset() {
let validator = make_validator();
add_rule(
&validator,
AuthRule {
rule_id: 0,
rule_type: AuthRuleType::RateLimit,
operation: "publish".to_string(),
target: "".to_string(),
value: "1/1".to_string(), // 1 request per 1 second window
priority: 0,
enabled: true,
},
);
let mut req = base_request("publish");
req.client_ip = Some("10.0.0.3".to_string());
// First request allowed, second denied.
assert!(validator.validate_request(&req).valid);
assert!(!validator.validate_request(&req).valid);
// Wait for the 1-second window to expire, then it should reset.
std::thread::sleep(std::time::Duration::from_millis(1100));
let result = validator.validate_request(&req);
assert!(result.valid);
}
#[test]
fn test_custom_deny() {
let validator = make_validator();
add_rule(
&validator,
AuthRule {
rule_id: 0,
rule_type: AuthRuleType::Custom,
operation: "publish".to_string(),
target: "".to_string(),
value: "deny".to_string(),
priority: 0,
enabled: true,
},
);
let req = base_request("publish");
let result = validator.validate_request(&req);
assert!(!result.valid);
assert_eq!(result.error_code, NostrError::AuthRulesDenied);
}
}
+1
View File
@@ -16,3 +16,4 @@ aes.workspace = true
sha2.workspace = true
hex.workspace = true
rand.workspace = true
serialport.workspace = true
+805 -19
View File
@@ -179,6 +179,257 @@ impl NsignerTransport for TcpTransport {
}
}
/// CDC-ACM serial port transport.
pub struct SerialTransport {
port_name: String,
baud_rate: u32,
timeout_ms: u64,
port: Mutex<Option<Box<dyn serialport::SerialPort>>>,
}
impl SerialTransport {
pub fn new(port_name: &str, baud_rate: u32, timeout_ms: u64) -> Self {
SerialTransport {
port_name: port_name.to_string(),
baud_rate,
timeout_ms,
port: Mutex::new(None),
}
}
fn connect_inner(&self) -> NostrResult<Box<dyn serialport::SerialPort>> {
use std::time::Duration;
serialport::new(&self.port_name, self.baud_rate)
.timeout(Duration::from_millis(self.timeout_ms))
.open()
.map_err(|_| NostrError::NetworkFailed)
}
}
impl NsignerTransport for SerialTransport {
fn send_framed(&self, json: &str) -> NostrResult<()> {
use std::io::Write;
let mut guard = self.port.lock().unwrap();
let port = guard.as_mut().ok_or(NostrError::NetworkFailed)?;
let len = json.len() as u32;
let header = len.to_be_bytes();
let mut data = Vec::with_capacity(4 + json.len());
data.extend_from_slice(&header);
data.extend_from_slice(json.as_bytes());
port.write_all(&data)
.map_err(|_| NostrError::NetworkFailed)?;
port.flush().ok();
Ok(())
}
fn recv_framed(&self) -> NostrResult<String> {
use std::io::Read;
let mut guard = self.port.lock().unwrap();
let port = guard.as_mut().ok_or(NostrError::NetworkFailed)?;
let mut header = [0u8; 4];
port.read_exact(&mut header)
.map_err(|_| NostrError::NetworkFailed)?;
let len = u32::from_be_bytes(header) as usize;
let mut buf = vec![0u8; len];
port.read_exact(&mut buf)
.map_err(|_| NostrError::NetworkFailed)?;
String::from_utf8(buf).map_err(|_| NostrError::InvalidInput)
}
fn reconnect(&self) -> NostrResult<()> {
let mut guard = self.port.lock().unwrap();
*guard = Some(self.connect_inner()?);
Ok(())
}
fn close(&self) {
let mut guard = self.port.lock().unwrap();
*guard = None;
}
}
/// File descriptor pair transport (Unix-only).
#[cfg(unix)]
pub struct FdTransport {
read_fd: i32,
write_fd: i32,
_timeout_ms: u64,
handles: Mutex<Option<(std::fs::File, std::fs::File)>>,
}
#[cfg(unix)]
impl FdTransport {
pub fn new(read_fd: i32, write_fd: i32, timeout_ms: u64) -> Self {
FdTransport {
read_fd,
write_fd,
_timeout_ms: timeout_ms,
handles: Mutex::new(None),
}
}
fn connect_inner(&self) -> NostrResult<(std::fs::File, std::fs::File)> {
use std::os::unix::io::FromRawFd;
// SAFETY: caller guarantees read_fd/write_fd are valid, owned fds.
let reader = unsafe { std::fs::File::from_raw_fd(self.read_fd) };
let writer = unsafe { std::fs::File::from_raw_fd(self.write_fd) };
Ok((reader, writer))
}
}
#[cfg(unix)]
impl NsignerTransport for FdTransport {
fn send_framed(&self, json: &str) -> NostrResult<()> {
use std::io::Write;
let mut guard = self.handles.lock().unwrap();
let (_reader, writer) = guard.as_mut().ok_or(NostrError::NetworkFailed)?;
// File handles don't support set_write_timeout; skip it.
let len = json.len() as u32;
let header = len.to_be_bytes();
let mut data = Vec::with_capacity(4 + json.len());
data.extend_from_slice(&header);
data.extend_from_slice(json.as_bytes());
writer.write_all(&data).map_err(|_| NostrError::NetworkFailed)?;
writer.flush().ok();
Ok(())
}
fn recv_framed(&self) -> NostrResult<String> {
use std::io::Read;
let mut guard = self.handles.lock().unwrap();
let (reader, _writer) = guard.as_mut().ok_or(NostrError::NetworkFailed)?;
// File handles don't support set_read_timeout; skip it.
let mut header = [0u8; 4];
reader
.read_exact(&mut header)
.map_err(|_| NostrError::NetworkFailed)?;
let len = u32::from_be_bytes(header) as usize;
let mut buf = vec![0u8; len];
reader
.read_exact(&mut buf)
.map_err(|_| NostrError::NetworkFailed)?;
String::from_utf8(buf).map_err(|_| NostrError::InvalidInput)
}
fn reconnect(&self) -> NostrResult<()> {
let mut guard = self.handles.lock().unwrap();
*guard = Some(self.connect_inner()?);
Ok(())
}
fn close(&self) {
let mut guard = self.handles.lock().unwrap();
*guard = None;
}
}
/// Qubes qrexec transport (Unix-only).
#[cfg(unix)]
pub struct QrexecTransport {
service_name: String,
domain: String,
_timeout_ms: u64,
state: Mutex<Option<QrexecState>>,
}
#[cfg(unix)]
struct QrexecState {
child: std::process::Child,
stdin: std::process::ChildStdin,
stdout: std::process::ChildStdout,
}
#[cfg(unix)]
impl QrexecTransport {
pub fn new(domain: &str, service_name: &str, timeout_ms: u64) -> Self {
QrexecTransport {
service_name: service_name.to_string(),
domain: domain.to_string(),
_timeout_ms: timeout_ms,
state: Mutex::new(None),
}
}
fn connect_inner(&self) -> NostrResult<QrexecState> {
use std::process::Stdio;
let mut child = std::process::Command::new("qrexec-client-vm")
.arg(&self.domain)
.arg(&self.service_name)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|_| NostrError::NetworkFailed)?;
let stdin = child.stdin.take().ok_or(NostrError::NetworkFailed)?;
let stdout = child.stdout.take().ok_or(NostrError::NetworkFailed)?;
Ok(QrexecState { child, stdin, stdout })
}
}
#[cfg(unix)]
impl NsignerTransport for QrexecTransport {
fn send_framed(&self, json: &str) -> NostrResult<()> {
use std::io::Write;
let mut guard = self.state.lock().unwrap();
let state = guard.as_mut().ok_or(NostrError::NetworkFailed)?;
let len = json.len() as u32;
let header = len.to_be_bytes();
let mut data = Vec::with_capacity(4 + json.len());
data.extend_from_slice(&header);
data.extend_from_slice(json.as_bytes());
state
.stdin
.write_all(&data)
.map_err(|_| NostrError::NetworkFailed)?;
state.stdin.flush().ok();
Ok(())
}
fn recv_framed(&self) -> NostrResult<String> {
use std::io::Read;
let mut guard = self.state.lock().unwrap();
let state = guard.as_mut().ok_or(NostrError::NetworkFailed)?;
// ChildStdout doesn't support set_read_timeout; skip it.
let mut header = [0u8; 4];
state
.stdout
.read_exact(&mut header)
.map_err(|_| NostrError::NetworkFailed)?;
let len = u32::from_be_bytes(header) as usize;
let mut buf = vec![0u8; len];
state
.stdout
.read_exact(&mut buf)
.map_err(|_| NostrError::NetworkFailed)?;
String::from_utf8(buf).map_err(|_| NostrError::InvalidInput)
}
fn reconnect(&self) -> NostrResult<()> {
let mut guard = self.state.lock().unwrap();
if let Some(mut state) = guard.take() {
let _ = state.child.kill();
let _ = state.child.wait();
}
*guard = Some(self.connect_inner()?);
Ok(())
}
fn close(&self) {
let mut guard = self.state.lock().unwrap();
if let Some(mut state) = guard.take() {
let _ = state.child.kill();
let _ = state.child.wait();
}
}
}
#[cfg(unix)]
impl Drop for QrexecTransport {
fn drop(&mut self) {
self.close();
}
}
// ── Nsigner Client ──────────────────────────────────────────────────────────
/// Low-level nsigner RPC client.
@@ -274,6 +525,7 @@ pub struct NsignerSigner {
role: String,
role_path: Mutex<Option<String>>,
derive_index: Mutex<Option<i32>>,
algorithm: Mutex<String>,
last_error: Mutex<Option<String>>,
}
@@ -288,6 +540,7 @@ impl NsignerSigner {
role: role.to_string(),
role_path: Mutex::new(None),
derive_index: Mutex::new(None),
algorithm: Mutex::new("secp256k1".to_string()),
last_error: Mutex::new(None),
}
}
@@ -302,17 +555,219 @@ impl NsignerSigner {
*self.derive_index.lock().unwrap() = Some(index);
}
/// Set the algorithm used for `derive_hmac` (default "secp256k1").
pub fn set_algorithm(&self, algorithm: &str) {
*self.algorithm.lock().unwrap() = algorithm.to_string();
}
/// Set authentication credentials.
pub fn set_auth(&self, privkey: SecretKey, label: &str) {
self.client.set_auth(privkey, label);
}
/// Inject `role_path` into a params object if it is set.
fn with_role_path(&self, mut params: serde_json::Value) -> serde_json::Value {
if let Some(path) = self.role_path.lock().unwrap().clone() {
if let Some(obj) = params.as_object_mut() {
obj.insert("role_path".to_string(), serde_json::Value::String(path));
}
}
params
}
// ── ed25519 ──────────────────────────────────────────────────────────────
/// Sign arbitrary data with ed25519. Returns the signature bytes.
pub fn ed25519_sign(&self, data: &[u8]) -> NostrResult<Vec<u8>> {
let params = self.with_role_path(serde_json::json!({
"role": self.role,
"algorithm": "ed25519",
"data": hex::encode(data),
}));
let response = self.client.call("sign", params)?;
let sig_hex = response
.get("result")
.and_then(|r| r.as_str())
.ok_or(NostrError::Nip46InvalidResponse)?;
hex::decode(sig_hex).map_err(|_| NostrError::InvalidInput)
}
/// Get the ed25519 public key (32 bytes).
pub fn ed25519_get_public_key(&self) -> NostrResult<[u8; 32]> {
let params = self.with_role_path(serde_json::json!({
"role": self.role,
"algorithm": "ed25519",
}));
let response = self.client.call("get_public_key", params)?;
let pk_hex = response
.get("result")
.and_then(|r| r.as_str())
.ok_or(NostrError::Nip46InvalidResponse)?;
let bytes = hex::decode(pk_hex).map_err(|_| NostrError::InvalidInput)?;
let arr: [u8; 32] = bytes
.as_slice()
.try_into()
.map_err(|_| NostrError::InvalidInput)?;
Ok(arr)
}
// ── x25519 ───────────────────────────────────────────────────────────────
/// Get the x25519 public key (32 bytes).
pub fn x25519_get_public_key(&self) -> NostrResult<[u8; 32]> {
let params = self.with_role_path(serde_json::json!({
"role": self.role,
"algorithm": "x25519",
}));
let response = self.client.call("get_public_key", params)?;
let pk_hex = response
.get("result")
.and_then(|r| r.as_str())
.ok_or(NostrError::Nip46InvalidResponse)?;
let bytes = hex::decode(pk_hex).map_err(|_| NostrError::InvalidInput)?;
let arr: [u8; 32] = bytes
.as_slice()
.try_into()
.map_err(|_| NostrError::InvalidInput)?;
Ok(arr)
}
/// Compute a shared secret via x25519 ECDH with a peer public key.
pub fn x25519_ecdh(&self, peer_pubkey: &[u8; 32]) -> NostrResult<[u8; 32]> {
let params = self.with_role_path(serde_json::json!({
"role": self.role,
"algorithm": "x25519",
"peer_pubkey": hex::encode(peer_pubkey),
}));
let response = self.client.call("ecdh", params)?;
let ss_hex = response
.get("result")
.and_then(|r| r.as_str())
.ok_or(NostrError::Nip46InvalidResponse)?;
let bytes = hex::decode(ss_hex).map_err(|_| NostrError::InvalidInput)?;
let arr: [u8; 32] = bytes
.as_slice()
.try_into()
.map_err(|_| NostrError::InvalidInput)?;
Ok(arr)
}
// ── ML-DSA-65 (post-quantum signatures) ──────────────────────────────────
/// Sign data with ML-DSA-65. Returns the signature bytes.
pub fn ml_dsa_sign(&self, data: &[u8]) -> NostrResult<Vec<u8>> {
let params = self.with_role_path(serde_json::json!({
"role": self.role,
"algorithm": "ml-dsa-65",
"data": hex::encode(data),
}));
let response = self.client.call("sign", params)?;
let sig_hex = response
.get("result")
.and_then(|r| r.as_str())
.ok_or(NostrError::Nip46InvalidResponse)?;
hex::decode(sig_hex).map_err(|_| NostrError::InvalidInput)
}
/// Verify an ML-DSA-65 signature.
pub fn ml_dsa_verify(
&self,
pubkey: &[u8],
data: &[u8],
signature: &[u8],
) -> NostrResult<bool> {
let params = self.with_role_path(serde_json::json!({
"role": self.role,
"algorithm": "ml-dsa-65",
"pubkey": hex::encode(pubkey),
"data": hex::encode(data),
"signature": hex::encode(signature),
}));
let response = self.client.call("verify", params)?;
response
.get("result")
.and_then(|r| r.as_bool())
.ok_or(NostrError::Nip46InvalidResponse)
}
// ── ML-KEM-768 (post-quantum KEM) ─────────────────────────────────────────
/// Encapsulate a shared secret against an ML-KEM-768 public key.
/// Returns `(ciphertext, shared_secret)`.
pub fn ml_kem_encapsulate(&self, pubkey: &[u8]) -> NostrResult<(Vec<u8>, Vec<u8>)> {
let params = self.with_role_path(serde_json::json!({
"role": self.role,
"algorithm": "ml-kem-768",
"pubkey": hex::encode(pubkey),
}));
let response = self.client.call("encapsulate", params)?;
let result = response
.get("result")
.ok_or(NostrError::Nip46InvalidResponse)?;
let ct_hex = result
.get("ciphertext")
.and_then(|r| r.as_str())
.ok_or(NostrError::Nip46InvalidResponse)?;
let ss_hex = result
.get("shared_secret")
.and_then(|r| r.as_str())
.ok_or(NostrError::Nip46InvalidResponse)?;
let ciphertext = hex::decode(ct_hex).map_err(|_| NostrError::InvalidInput)?;
let shared_secret = hex::decode(ss_hex).map_err(|_| NostrError::InvalidInput)?;
Ok((ciphertext, shared_secret))
}
/// Decapsulate an ML-KEM-768 ciphertext. Returns the shared secret.
pub fn ml_kem_decapsulate(&self, ciphertext: &[u8]) -> NostrResult<Vec<u8>> {
let params = self.with_role_path(serde_json::json!({
"role": self.role,
"algorithm": "ml-kem-768",
"ciphertext": hex::encode(ciphertext),
}));
let response = self.client.call("decapsulate", params)?;
let ss_hex = response
.get("result")
.and_then(|r| r.as_str())
.ok_or(NostrError::Nip46InvalidResponse)?;
hex::decode(ss_hex).map_err(|_| NostrError::InvalidInput)
}
// ── OTP ──────────────────────────────────────────────────────────────────
/// Encrypt data with a one-time-pad backed by the signer's entropy.
pub fn otp_encrypt(&self, plaintext: &[u8]) -> NostrResult<Vec<u8>> {
let params = self.with_role_path(serde_json::json!({
"role": self.role,
"data": hex::encode(plaintext),
}));
let response = self.client.call("otp_encrypt", params)?;
let ct_hex = response
.get("result")
.and_then(|r| r.as_str())
.ok_or(NostrError::Nip46InvalidResponse)?;
hex::decode(ct_hex).map_err(|_| NostrError::InvalidInput)
}
/// Decrypt data with a one-time-pad backed by the signer's entropy.
pub fn otp_decrypt(&self, ciphertext: &[u8]) -> NostrResult<Vec<u8>> {
let params = self.with_role_path(serde_json::json!({
"role": self.role,
"data": hex::encode(ciphertext),
}));
let response = self.client.call("otp_decrypt", params)?;
let pt_hex = response
.get("result")
.and_then(|r| r.as_str())
.ok_or(NostrError::Nip46InvalidResponse)?;
hex::decode(pt_hex).map_err(|_| NostrError::InvalidInput)
}
}
impl NostrSigner for NsignerSigner {
fn get_public_key(&self) -> NostrResult<PublicKey> {
let params = serde_json::json!({
let params = self.with_role_path(serde_json::json!({
"role": self.role,
});
}));
let response = self.client.call("get_public_key", params)?;
let pubkey_hex = response
.get("result")
@@ -324,10 +779,10 @@ impl NostrSigner for NsignerSigner {
fn sign_event(&self, event: &Event) -> NostrResult<Event> {
let event_json = serde_json::to_string(event)
.map_err(|_| NostrError::EventInvalidStructure)?;
let params = serde_json::json!({
let params = self.with_role_path(serde_json::json!({
"role": self.role,
"event": event_json,
});
}));
let response = self.client.call("sign_event", params)?;
let signed_json = response
.get("result")
@@ -339,12 +794,13 @@ impl NostrSigner for NsignerSigner {
fn derive_hmac(&self, data: &str) -> NostrResult<String> {
let index = self.derive_index.lock().unwrap().ok_or(NostrError::InvalidInput)?;
let params = serde_json::json!({
let algorithm = self.algorithm.lock().unwrap().clone();
let params = self.with_role_path(serde_json::json!({
"role": self.role,
"algorithm": "secp256k1",
"algorithm": algorithm,
"index": index,
"data": data,
});
}));
let response = self.client.call("derive", params)?;
response
.get("result")
@@ -354,11 +810,11 @@ impl NostrSigner for NsignerSigner {
}
fn nip04_encrypt(&self, peer_pubkey: &PublicKey, plaintext: &str) -> NostrResult<String> {
let params = serde_json::json!({
let params = self.with_role_path(serde_json::json!({
"role": self.role,
"pubkey": peer_pubkey.to_hex(),
"plaintext": plaintext,
});
}));
let response = self.client.call("nip04_encrypt", params)?;
response
.get("result")
@@ -368,11 +824,11 @@ impl NostrSigner for NsignerSigner {
}
fn nip04_decrypt(&self, peer_pubkey: &PublicKey, ciphertext: &str) -> NostrResult<String> {
let params = serde_json::json!({
let params = self.with_role_path(serde_json::json!({
"role": self.role,
"pubkey": peer_pubkey.to_hex(),
"ciphertext": ciphertext,
});
}));
let response = self.client.call("nip04_decrypt", params)?;
response
.get("result")
@@ -382,11 +838,11 @@ impl NostrSigner for NsignerSigner {
}
fn nip44_encrypt(&self, peer_pubkey: &PublicKey, plaintext: &str) -> NostrResult<Vec<u8>> {
let params = serde_json::json!({
let params = self.with_role_path(serde_json::json!({
"role": self.role,
"pubkey": peer_pubkey.to_hex(),
"plaintext": plaintext,
});
}));
let response = self.client.call("nip44_encrypt", params)?;
let result_b64 = response
.get("result")
@@ -397,11 +853,11 @@ impl NostrSigner for NsignerSigner {
fn nip44_decrypt(&self, peer_pubkey: &PublicKey, ciphertext: &[u8]) -> NostrResult<Vec<u8>> {
let ciphertext_b64 = nostr_core::util::base64_encode(ciphertext);
let params = serde_json::json!({
let params = self.with_role_path(serde_json::json!({
"role": self.role,
"pubkey": peer_pubkey.to_hex(),
"ciphertext": ciphertext_b64,
});
}));
let response = self.client.call("nip44_decrypt", params)?;
let result_b64 = response
.get("result")
@@ -421,21 +877,93 @@ impl NostrSigner for NsignerSigner {
}
}
// ── Mock Transport (for testing) ─────────────────────────────────────────────
/// A mock transport that records sent messages and returns pre-set responses.
pub struct MockTransport {
sent: Mutex<Vec<String>>,
responses: Mutex<Vec<String>>,
}
impl MockTransport {
pub fn new() -> Self {
MockTransport {
sent: Mutex::new(Vec::new()),
responses: Mutex::new(Vec::new()),
}
}
/// Queue a canned JSON response to be returned by the next `recv_framed`.
pub fn enqueue_response(&self, response: serde_json::Value) {
self.responses
.lock()
.unwrap()
.push(response.to_string());
}
/// Get a copy of all messages sent so far.
pub fn sent_messages(&self) -> Vec<String> {
self.sent.lock().unwrap().clone()
}
}
impl Default for MockTransport {
fn default() -> Self {
Self::new()
}
}
impl NsignerTransport for MockTransport {
fn send_framed(&self, json: &str) -> NostrResult<()> {
self.sent.lock().unwrap().push(json.to_string());
Ok(())
}
fn recv_framed(&self) -> NostrResult<String> {
self.responses
.lock()
.unwrap()
.pop()
.ok_or(NostrError::NetworkFailed)
}
fn reconnect(&self) -> NostrResult<()> {
Ok(())
}
fn close(&self) {}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_unix_transport_creation() {
let transport = UnixTransport::new("/tmp/nsigner.sock", 5000);
let _transport = UnixTransport::new("/tmp/nsigner.sock", 5000);
// Just verify creation doesn't panic
assert!(true);
}
#[test]
fn test_tcp_transport_creation() {
let transport = TcpTransport::new("127.0.0.1", 8080, 5000);
assert!(true);
let _transport = TcpTransport::new("127.0.0.1", 8080, 5000);
}
#[test]
fn test_serial_transport_creation() {
let _transport = SerialTransport::new("/dev/ttyACM0", 115200, 5000);
}
#[cfg(unix)]
#[test]
fn test_fd_transport_creation() {
let _transport = FdTransport::new(0, 1, 5000);
}
#[cfg(unix)]
#[test]
fn test_qrexec_transport_creation() {
let _transport = QrexecTransport::new("dom0", "nsigner.Sign", 5000);
}
#[test]
@@ -444,4 +972,262 @@ mod tests {
let client = NsignerClient::new(transport);
assert!(client.last_error().is_none());
}
// ── Mock-based tests ─────────────────────────────────────────────────────
fn signer_with_mock() -> (NsignerSigner, std::sync::Arc<MockTransport>) {
let mock = std::sync::Arc::new(MockTransport::new());
// Wrap the Arc<MockTransport> in a thin newtype that implements
// NsignerTransport by delegating to the inner MockTransport.
struct MockWrapper(std::sync::Arc<MockTransport>);
impl NsignerTransport for MockWrapper {
fn send_framed(&self, json: &str) -> NostrResult<()> {
self.0.send_framed(json)
}
fn recv_framed(&self) -> NostrResult<String> {
self.0.recv_framed()
}
fn reconnect(&self) -> NostrResult<()> {
self.0.reconnect()
}
fn close(&self) {
self.0.close()
}
}
let signer = NsignerSigner::from_transport(Box::new(MockWrapper(mock.clone())), "test");
(signer, mock)
}
fn last_sent_params(mock: &MockTransport) -> serde_json::Value {
let sent = mock.sent_messages();
let last = sent.last().expect("no message sent");
let outer: serde_json::Value = serde_json::from_str(last).unwrap();
// The outer may be an auth envelope {body: {...}} or a plain request.
if let Some(body) = outer.get("body") {
body.get("params").cloned().unwrap_or(serde_json::Value::Null)
} else {
outer.get("params").cloned().unwrap_or(serde_json::Value::Null)
}
}
fn last_sent_method(mock: &MockTransport) -> String {
let sent = mock.sent_messages();
let last = sent.last().expect("no message sent");
let outer: serde_json::Value = serde_json::from_str(last).unwrap();
let req = if let Some(body) = outer.get("body") {
body
} else {
&outer
};
req.get("method")
.and_then(|m| m.as_str())
.unwrap_or("")
.to_string()
}
#[test]
fn test_role_path_included_in_get_public_key() {
let (signer, mock) = signer_with_mock();
signer.set_role_path("m/44'/1237'/0'/0/0");
mock.enqueue_response(serde_json::json!({"result": "0000000000000000000000000000000000000000000000000000000000000001"}));
let _ = signer.get_public_key();
let params = last_sent_params(&mock);
assert_eq!(
params.get("role_path").and_then(|r| r.as_str()),
Some("m/44'/1237'/0'/0/0")
);
}
#[test]
fn test_role_path_omitted_when_unset() {
let (signer, mock) = signer_with_mock();
mock.enqueue_response(serde_json::json!({"result": "0000000000000000000000000000000000000000000000000000000000000001"}));
let _ = signer.get_public_key();
let params = last_sent_params(&mock);
assert!(params.get("role_path").is_none());
}
#[test]
fn test_role_path_included_in_nip04_encrypt() {
let (signer, mock) = signer_with_mock();
signer.set_role_path("m/44'/1237'/0'/0/0");
let peer: PublicKey = "0000000000000000000000000000000000000000000000000000000000000002"
.parse()
.unwrap();
mock.enqueue_response(serde_json::json!({"result": "ciphertext"}));
let _ = signer.nip04_encrypt(&peer, "hello");
let params = last_sent_params(&mock);
assert_eq!(
params.get("role_path").and_then(|r| r.as_str()),
Some("m/44'/1237'/0'/0/0")
);
}
#[test]
fn test_derive_hmac_uses_configured_algorithm() {
let (signer, mock) = signer_with_mock();
signer.set_derive_index(0);
signer.set_algorithm("ed25519");
mock.enqueue_response(serde_json::json!({"result": "deadbeef"}));
let _ = signer.derive_hmac("data");
let params = last_sent_params(&mock);
assert_eq!(
params.get("algorithm").and_then(|r| r.as_str()),
Some("ed25519")
);
}
#[test]
fn test_derive_hmac_defaults_to_secp256k1() {
let (signer, mock) = signer_with_mock();
signer.set_derive_index(0);
mock.enqueue_response(serde_json::json!({"result": "deadbeef"}));
let _ = signer.derive_hmac("data");
let params = last_sent_params(&mock);
assert_eq!(
params.get("algorithm").and_then(|r| r.as_str()),
Some("secp256k1")
);
}
#[test]
fn test_ed25519_sign_params() {
let (signer, mock) = signer_with_mock();
mock.enqueue_response(serde_json::json!({"result": "deadbeef"}));
let _ = signer.ed25519_sign(b"hello");
assert_eq!(last_sent_method(&mock), "sign");
let params = last_sent_params(&mock);
assert_eq!(
params.get("algorithm").and_then(|r| r.as_str()),
Some("ed25519")
);
assert_eq!(
params.get("data").and_then(|r| r.as_str()),
Some(hex::encode(b"hello")).as_deref()
);
}
#[test]
fn test_ed25519_get_public_key_params() {
let (signer, mock) = signer_with_mock();
let pk = [0u8; 32];
mock.enqueue_response(serde_json::json!({"result": hex::encode(pk)}));
let result = signer.ed25519_get_public_key().unwrap();
assert_eq!(result, pk);
assert_eq!(last_sent_method(&mock), "get_public_key");
let params = last_sent_params(&mock);
assert_eq!(
params.get("algorithm").and_then(|r| r.as_str()),
Some("ed25519")
);
}
#[test]
fn test_x25519_ecdh_params() {
let (signer, mock) = signer_with_mock();
let peer = [1u8; 32];
let ss = [2u8; 32];
mock.enqueue_response(serde_json::json!({"result": hex::encode(ss)}));
let result = signer.x25519_ecdh(&peer).unwrap();
assert_eq!(result, ss);
assert_eq!(last_sent_method(&mock), "ecdh");
let params = last_sent_params(&mock);
assert_eq!(
params.get("algorithm").and_then(|r| r.as_str()),
Some("x25519")
);
assert_eq!(
params.get("peer_pubkey").and_then(|r| r.as_str()),
Some(hex::encode(peer)).as_deref()
);
}
#[test]
fn test_ml_dsa_sign_params() {
let (signer, mock) = signer_with_mock();
mock.enqueue_response(serde_json::json!({"result": "deadbeef"}));
let _ = signer.ml_dsa_sign(b"data");
assert_eq!(last_sent_method(&mock), "sign");
let params = last_sent_params(&mock);
assert_eq!(
params.get("algorithm").and_then(|r| r.as_str()),
Some("ml-dsa-65")
);
}
#[test]
fn test_ml_dsa_verify_params() {
let (signer, mock) = signer_with_mock();
mock.enqueue_response(serde_json::json!({"result": true}));
let ok = signer.ml_dsa_verify(b"pk", b"data", b"sig").unwrap();
assert!(ok);
assert_eq!(last_sent_method(&mock), "verify");
let params = last_sent_params(&mock);
assert_eq!(
params.get("algorithm").and_then(|r| r.as_str()),
Some("ml-dsa-65")
);
assert!(params.get("pubkey").is_some());
assert!(params.get("data").is_some());
assert!(params.get("signature").is_some());
}
#[test]
fn test_ml_kem_encapsulate_params() {
let (signer, mock) = signer_with_mock();
let ct = vec![1u8; 16];
let ss = vec![2u8; 16];
mock.enqueue_response(serde_json::json!({
"result": {
"ciphertext": hex::encode(&ct),
"shared_secret": hex::encode(&ss),
}
}));
let (got_ct, got_ss) = signer.ml_kem_encapsulate(b"pk").unwrap();
assert_eq!(got_ct, ct);
assert_eq!(got_ss, ss);
assert_eq!(last_sent_method(&mock), "encapsulate");
let params = last_sent_params(&mock);
assert_eq!(
params.get("algorithm").and_then(|r| r.as_str()),
Some("ml-kem-768")
);
}
#[test]
fn test_ml_kem_decapsulate_params() {
let (signer, mock) = signer_with_mock();
let ss = vec![2u8; 16];
mock.enqueue_response(serde_json::json!({"result": hex::encode(&ss)}));
let got = signer.ml_kem_decapsulate(b"ct").unwrap();
assert_eq!(got, ss);
assert_eq!(last_sent_method(&mock), "decapsulate");
let params = last_sent_params(&mock);
assert_eq!(
params.get("algorithm").and_then(|r| r.as_str()),
Some("ml-kem-768")
);
assert!(params.get("ciphertext").is_some());
}
#[test]
fn test_otp_encrypt_decrypt_params() {
let (signer, mock) = signer_with_mock();
let ct = vec![9u8; 8];
mock.enqueue_response(serde_json::json!({"result": hex::encode(&ct)}));
let got_ct = signer.otp_encrypt(b"plain").unwrap();
assert_eq!(got_ct, ct);
assert_eq!(last_sent_method(&mock), "otp_encrypt");
let params = last_sent_params(&mock);
assert_eq!(
params.get("data").and_then(|r| r.as_str()),
Some(hex::encode(b"plain")).as_deref()
);
let pt = vec![7u8; 8];
mock.enqueue_response(serde_json::json!({"result": hex::encode(&pt)}));
let got_pt = signer.otp_decrypt(&ct).unwrap();
assert_eq!(got_pt, pt);
assert_eq!(last_sent_method(&mock), "otp_decrypt");
}
}