11 Commits
Author SHA1 Message Date
Signer Developer 1e7e178c0f v0.0.23 - Verify SSH release push workflow 2026-08-30 16:01:18 -03:00
Signer Developer 892abe0ccf v0.0.22 - Build static musl binaries as signer and signer_client 2026-08-30 15:53:26 -03:00
Laan Tungir 782716c53b v0.0.21 - Activity log: show caller identity, role, curve, and actual requested key path 2026-08-22 07:57:19 -04:00
Laan Tungir b2f5d06570 v0.0.20 - Fixed blank activity log lines for algorithm-based verbs: all six algorithms now print method(algorithm,index path) with their standard derivation path; extracted standard_path() helper; added activity-format unit tests for all curves 2026-08-22 07:10:30 -04:00
Laan Tungir 03a977b774 v0.0.19 - Implemented Phase 13 PQ crypto: v2 FIPS seeded derivation (BIP-32 for PQ coin types 102003'-102005') with ML-DSA-65, SLH-DSA-128s (SHA2), and ML-KEM-768 keygen/sign/verify/encaps/decaps; fixed sign-verb key truncation; added encapsulate/decapsulate verbs; cross-implementation parity verified against nostr_quantum_preparation v2 vectors 2026-08-21 16:45:46 -04:00
Laan Tungir 9c762edbd2 v0.0.18 - Fix sign-event JSON escaping and variable-path role key caching (per-path re-derivation) 2026-08-20 18:34:14 -04:00
Laan Tungir b5b58ecb87 v0.0.17 - Fix qrexec bridge: server now consumes qrexec_source preamble frame before the JSON-RPC request 2026-08-20 18:27:45 -04:00
Laan Tungir a428d5e77b v0.0.16 - Release build with signer + signer-client binaries and updated install_signer.sh 2026-08-20 14:04:10 -04:00
Laan Tungir c685806aeb v0.0.15 - Updated install_signer.sh to pull from signer repo (was n_signer); installs signer + signer-client binaries 2026-08-20 13:55:40 -04:00
Laan Tungir a3e1d70fe8 v0.0.14 - Added common command examples to signer-client --help output 2026-08-20 13:49:57 -04:00
Laan Tungir 567985434d v0.0.13 - Renamed nsigner to signer throughout project; release build with signer + signer-client binaries 2026-08-20 11:28:57 -04:00
48 changed files with 4065 additions and 499 deletions
+5
View File
@@ -0,0 +1,5 @@
signer/target
signer/dist
signer/.git
nostr_core_lib_rust/target
nostr_core_lib_rust/.git
+1
View File
@@ -1,3 +1,4 @@
/target/
/dist/
*.log
*.tar.gz
Generated
+33 -33
View File
@@ -1446,7 +1446,7 @@ dependencies = [
[[package]]
name = "nostr-core"
version = "0.0.3"
version = "0.1.0"
dependencies = [
"aes",
"base64",
@@ -1468,7 +1468,7 @@ dependencies = [
[[package]]
name = "nostr-nips"
version = "0.0.3"
version = "0.1.0"
dependencies = [
"aes",
"block-modes",
@@ -1488,37 +1488,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "nsigner"
version = "0.0.12"
dependencies = [
"base64",
"chacha20poly1305",
"clap",
"crossterm 0.27.0",
"ed25519-dalek",
"hex",
"hmac 0.12.1",
"libc",
"ml-dsa",
"ml-kem",
"nostr-core",
"nostr-nips",
"rand",
"rand_core 0.6.4",
"ratatui",
"secp256k1",
"serde",
"serde_json",
"sha2 0.10.9",
"sha3 0.10.9",
"slh-dsa",
"tempfile",
"thiserror",
"x25519-dalek",
"zeroize",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -2236,6 +2205,37 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "signer"
version = "0.0.22"
dependencies = [
"base64",
"chacha20poly1305",
"clap",
"crossterm 0.27.0",
"ed25519-dalek",
"hex",
"hmac 0.12.1",
"libc",
"ml-dsa",
"ml-kem",
"nostr-core",
"nostr-nips",
"rand",
"rand_core 0.6.4",
"ratatui",
"secp256k1",
"serde",
"serde_json",
"sha2 0.10.9",
"sha3 0.10.9",
"slh-dsa",
"tempfile",
"thiserror",
"x25519-dalek",
"zeroize",
]
[[package]]
name = "slab"
version = "0.4.12"
+4 -4
View File
@@ -1,12 +1,12 @@
[package]
name = "nsigner"
version = "0.0.12"
name = "signer"
version = "0.0.23"
edition = "2021"
license = "MIT"
description = "Attended Nostr signing daemon — Rust port of n_signer"
[[bin]]
name = "nsigner"
name = "signer"
path = "src/main.rs"
[[bin]]
@@ -14,7 +14,7 @@ name = "signer-client"
path = "src/client/main.rs"
[lib]
name = "nsigner"
name = "signer"
path = "src/lib.rs"
[dependencies]
+28
View File
@@ -0,0 +1,28 @@
FROM rust:1.88.0-alpine3.20
RUN apk add --no-cache \
build-base \
binutils \
file \
musl-dev \
openssl-dev \
openssl-libs-static \
perl \
pkgconf \
linux-headers \
&& rustup target add x86_64-unknown-linux-musl
ENV CARGO_NET_OFFLINE=false \
RUSTFLAGS=-Ctarget-cpu=x86-64
WORKDIR /workspace/signer
COPY nostr_core_lib_rust /workspace/nostr_core_lib_rust
COPY signer /workspace/signer
RUN cargo build --release --target x86_64-unknown-linux-musl
RUN file target/x86_64-unknown-linux-musl/release/signer \
&& file target/x86_64-unknown-linux-musl/release/signer-client \
&& ! readelf -l target/x86_64-unknown-linux-musl/release/signer | grep -q 'INTERP'
CMD ["sh", "-c", "mkdir -p /out && cp target/x86_64-unknown-linux-musl/release/signer /out/signer && cp target/x86_64-unknown-linux-musl/release/signer-client /out/signer_client"]
+48 -13
View File
@@ -207,21 +207,21 @@ All keys derive deterministically from the loaded BIP-39 mnemonic. The caller se
#### 4.4.1 Algorithm table
| Algorithm | Key type | FIPS standard | Derivation path | Key sizes (priv / pub, bytes) |
|-----------------|-----------------|---------------|---------------------------------------|-------------------------------|
| `secp256k1` | Signature | — | `m/44'/1237'/<n>'/0/0` (NIP-06) | 32 / 32 |
| `ed25519` | Signature | — | `m/44'/102001'/<n>'/0/0'` (SLIP-0010) | 32 / 32 |
| `x25519` | Key agreement | — | `m/44'/102002'/<n>'/0/0'` (SLIP-0010) | 32 / 32 |
| `ml-dsa-65` | PQ signature | FIPS 204 | `m/44'/102003'/<n>'/0/0'` → DRBG | 4032 / 1952 |
| `slh-dsa-128s` | PQ signature | FIPS 205 | `m/44'/102004'/<n>'/0/0'` → DRBG | 64 / 32 |
| `ml-kem-768` | PQ KEM | FIPS 203 | `m/44'/102005'/<n>'/0/0'` → DRBG | 2400 / 1184 |
| `otp` | One-time pad | — | (no key — bound USB pad) | n/a |
| Algorithm | Key type | FIPS standard | Derivation path | Key sizes (priv / pub, bytes) |
|-----------------|-----------------|---------------|----------------------------------------|-------------------------------|
| `secp256k1` | Signature | — | `m/44'/1237'/<n>'/0/0` (NIP-06) | 32 / 32 |
| `ed25519` | Signature | — | `m/44'/102001'/<n>'/0/0'` (SLIP-0010) | 32 / 32 |
| `x25519` | Key agreement | — | `m/44'/102002'/<n>'/0/0'` (SLIP-0010) | 32 / 32 |
| `ml-dsa-65` | PQ signature | FIPS 204 | `m/44'/102003'/<n>'/0'/0'` (BIP-32) | 32 / 1952 |
| `slh-dsa-128s` | PQ signature | FIPS 205 | `m/44'/102004'/<n>'/0'/0'` (BIP-32) | 64 / 32 |
| `ml-kem-768` | PQ KEM | FIPS 203 | `m/44'/102005'/<n>'/0'/0'` (BIP-32) | 64 / 1184 |
| `otp` | One-time pad | — | (no key — bound USB pad) | n/a |
#### 4.4.2 Key derivation
- **secp256k1** uses standard BIP-32/NIP-06 derivation. The 32-byte path output is the private key scalar.
- **ed25519 / x25519** use SLIP-0010 HMAC-SHA512 derivation (all-hardened paths, as required by SLIP-0010 for ed25519). The 32-byte output is the private key.
- **PQ algorithms** (ML-DSA-65, SLH-DSA-128s, ML-KEM-768) use a two-stage approach: the mnemonic-derived 32-byte seed feeds a SHAKE-256 DRBG (NIST SP 800-90A style), which replaces the RNG during keygen. Same mnemonic, same index, same key pair every time. The PQ implementations are the pure-Rust crates [`ml-dsa`](https://crates.io/crates/ml-dsa), [`ml-kem`](https://crates.io/crates/ml-kem), and [`slh-dsa`](https://crates.io/crates/slh-dsa). The three post-quantum algorithms address the **harvest-now-decrypt-later** threat: an adversary recording encrypted traffic today to decrypt it once a quantum computer becomes available.
- **PQ algorithms** (ML-DSA-65, SLH-DSA-128s, ML-KEM-768) use the **v2 FIPS seeded derivation** (see [`plans/pq_seeded_derivation_plan.md`](plans/pq_seeded_derivation_plan.md)): BIP-32 child bytes at the exact seed length required by each algorithm feed the seeded keygen APIs directly — no DRBG expansion. ML-DSA-65 takes one 32-byte child; SLH-DSA-128s takes two children concatenated (first 48 of 64 bytes, split as sk.seed ∥ sk.prf ∥ pk.seed); ML-KEM-768 takes two children concatenated (all 64 bytes, split as d ∥ z). Same mnemonic, same index, same key pair every time — and the same keys as the nostr_quantum_preparation web app (verified against its pinned test vectors by `tests/pq_conformance.rs`). PQ private keys are stored in seed form. The PQ implementations are the pure-Rust crates [`ml-dsa`](https://crates.io/crates/ml-dsa), [`ml-kem`](https://crates.io/crates/ml-kem), and [`slh-dsa`](https://crates.io/crates/slh-dsa) (SLH-DSA uses the SHA2-128s parameter set). The three post-quantum algorithms address the **harvest-now-decrypt-later** threat: an adversary recording encrypted traffic today to decrypt it once a quantum computer becomes available.
- **otp** does not derive a key. A pad is bound at signer startup (`--otp-pad-dir` + `--otp-pad`); the pad offset advances monotonically across requests.
#### 4.4.3 OTP
@@ -728,12 +728,43 @@ git submodule update --init ratatui
### 8.2 Local dev build
Native builds are intended for local development and use the host Rust toolchain and libc:
```bash
cargo build
./target/debug/signer --version
```
### 8.3 Release build
### 8.3 Portable static musl release build
Portable release binaries are built in Docker for x86_64 Linux using the `x86_64-unknown-linux-musl` target. This avoids a runtime dependency on the target system's glibc version. The build expects the sibling `nostr_core_lib_rust` checkout described above.
```bash
./build_musl.sh
```
The artifacts are written directly to `dist/`:
- `signer`
- `signer_client`
Verify the output:
```bash
file dist/signer
ldd dist/signer
./dist/signer --version
```
`ldd` should report that the executable is not dynamically linked. Static musl removes the glibc runtime dependency, but the binaries still require a compatible Linux kernel, x86_64 CPU, terminal environment, Qubes/qrexec environment where applicable, and sufficient `RLIMIT_MEMLOCK` for locked secret memory.
To deploy the portable binaries locally:
```bash
./deploy_local.sh --musl
```
### 8.4 Native release build
The release profile is tuned for a small, optimized, stripped binary:
@@ -751,12 +782,14 @@ cargo build --release
./target/release/signer --version
```
### 8.4 Tests
### 8.5 Tests
```bash
cargo test
```
The portable build runs the release compilation and static-linkage checks inside Docker. Runtime smoke tests should be performed on the intended Qubes/Linux deployment environment, including Unix sockets, TCP/HTTP, qrexec, TUI startup, signing, and `mlock` behavior.
## 9. Project layout
| Path | Purpose |
@@ -769,10 +802,12 @@ cargo test
| [`src/role_table.rs`](src/role_table.rs:1) | Role registry, path-template parsing, purpose/curve enforcement |
| [`src/selector.rs`](src/selector.rs:1) | Role selector resolution (`role` + `role_path`) |
| [`src/enforcement.rs`](src/enforcement.rs:1) | Verb/algorithm/purpose/curve enforcement matrix |
| [`Dockerfile.musl`](Dockerfile.musl:1) | Reproducible Docker environment for static musl releases |
| [`build_musl.sh`](build_musl.sh:1) | Builds and validates portable x86_64 musl binaries |
| [`src/key_store.rs`](src/key_store.rs:1) | BIP-32 / SLIP-0010 key derivation and storage |
| [`src/mnemonic.rs`](src/mnemonic.rs:1) | BIP-39 mnemonic loading and seed derivation |
| [`src/pq_crypto.rs`](src/pq_crypto.rs:1) | Post-quantum keygen (ML-DSA-65, SLH-DSA-128s, ML-KEM-768) |
| [`src/pq_drbg.rs`](src/pq_drbg.rs:1) | SHAKE-256 DRBG for PQ keygen |
| [`src/pq_drbg.rs`](src/pq_drbg.rs:1) | SHAKE-256 DRBG (retained port; not used for PQ keygen — see the v2 seeded derivation) |
| [`src/alg_cache.rs`](src/alg_cache.rs:1) | Per-algorithm derived-key cache |
| [`src/otp_pad.rs`](src/otp_pad.rs:1) | One-time pad binding, offset tracking, encrypt/decrypt |
| [`src/miner.rs`](src/miner.rs:1) | NIP-13 proof-of-work mining for `nostr_mine_event` |
Executable
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -euo pipefail
# Build portable static x86_64 Linux binaries in a pinned musl container.
# The Docker build context is the parent directory because Cargo.toml uses
# ../nostr_core_lib_rust as a path dependency. Set SIGNER_NOSTR_CORE_DIR
# when the sibling checkout is stored elsewhere.
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_NAME="$(basename "${PROJECT_DIR}")"
PARENT_DIR="$(dirname "${PROJECT_DIR}")"
IMAGE_NAME="${SIGNER_MUSL_IMAGE:-signer-musl-build:rust-1.88.0-alpine3.20}"
OUTPUT_DIR="${SIGNER_MUSL_OUTPUT_DIR:-${PROJECT_DIR}/dist}"
DEPENDENCY_DIR="${SIGNER_NOSTR_CORE_DIR:-${PARENT_DIR}/nostr_core_lib_rust}"
if ! command -v docker >/dev/null 2>&1; then
echo "error: Docker is required for the musl build" >&2
exit 1
fi
if [[ ! -f "${PROJECT_DIR}/Cargo.toml" ]]; then
echo "error: Cargo.toml not found in ${PROJECT_DIR}" >&2
exit 1
fi
if [[ ! -f "${DEPENDENCY_DIR}/core/Cargo.toml" || ! -f "${DEPENDENCY_DIR}/nips/Cargo.toml" ]]; then
echo "error: invalid nostr_core_lib_rust checkout: ${DEPENDENCY_DIR}" >&2
echo "expected core/Cargo.toml and nips/Cargo.toml" >&2
echo "hint: clone the complete sibling checkout or set SIGNER_NOSTR_CORE_DIR" >&2
exit 1
fi
mkdir -p "${OUTPUT_DIR}"
rm -f "${OUTPUT_DIR}/signer" \
"${OUTPUT_DIR}/signer_client"
CONTAINER_NAME="signer-musl-build-$$-${RANDOM}"
cleanup() {
docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true
}
trap cleanup EXIT
printf '[INFO] Building musl image %s\n' "${IMAGE_NAME}"
if [[ "${DEPENDENCY_DIR}" != "${PARENT_DIR}/nostr_core_lib_rust" ]]; then
echo "error: SIGNER_NOSTR_CORE_DIR must point to the sibling ../nostr_core_lib_rust directory when using Docker" >&2
exit 1
fi
docker build \
--file "${PROJECT_DIR}/Dockerfile.musl" \
--tag "${IMAGE_NAME}" \
"${PARENT_DIR}"
printf '[INFO] Extracting portable binaries\n'
docker create --name "${CONTAINER_NAME}" "${IMAGE_NAME}" >/dev/null
docker cp "${CONTAINER_NAME}:/workspace/signer/target/x86_64-unknown-linux-musl/release/signer" \
"${OUTPUT_DIR}/signer"
docker cp "${CONTAINER_NAME}:/workspace/signer/target/x86_64-unknown-linux-musl/release/signer-client" \
"${OUTPUT_DIR}/signer_client"
chmod 0755 "${OUTPUT_DIR}/signer" \
"${OUTPUT_DIR}/signer_client"
for binary in \
"${OUTPUT_DIR}/signer" \
"${OUTPUT_DIR}/signer_client"; do
file "${binary}"
if ! file "${binary}" | grep -Eq 'ELF 64-bit LSB (pie )?executable, x86-64'; then
echo "error: ${binary} is not an x86_64 ELF executable" >&2
exit 1
fi
if ldd "${binary}" 2>&1 | grep -Eqi 'not a dynamic executable|statically linked'; then
:
else
echo "error: ${binary} appears to be dynamically linked" >&2
ldd "${binary}" 2>&1 || true
exit 1
fi
"${binary}" --version
done
printf '[SUCCESS] Portable binaries written to %s\n' "${OUTPUT_DIR}"
+143
View File
@@ -0,0 +1,143 @@
#!/bin/bash
set -e
# signer (Rust) — Local Deploy Script
#
# Builds binaries and installs them to /usr/local/bin/.
#
# USAGE:
# ./deploy_local.sh # native release build + install
# ./deploy_local.sh --musl # portable static musl release + install
# ./deploy_local.sh --debug # native debug build + install
# ./deploy_local.sh -h, --help
#
# Installs:
# /usr/local/bin/signer (the signing daemon)
# /usr/local/bin/signer-client (the CLI client)
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
print_status() { echo -e "${BLUE}[INFO]${NC} $1" >&2; }
print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1" >&2; }
print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1" >&2; }
print_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
PROFILE="release"
CARGO_FLAG="--release"
TARGET_DIR="target/release"
BUILD_KIND="native"
show_usage() {
echo "signer (Rust) Local Deploy Script"
echo ""
echo "USAGE:"
echo " $0 [OPTIONS]"
echo ""
echo "OPTIONS:"
echo " --musl Build static x86_64 musl binaries in Docker"
echo " --debug Build native debug profile instead of release"
echo " -h, --help Show this help message"
echo ""
echo "Installs to /usr/local/bin/:"
echo " signer (the signing daemon)"
echo " signer-client (the CLI client)"
}
while [[ $# -gt 0 ]]; do
case $1 in
--musl)
BUILD_KIND="musl"
PROFILE="release"
CARGO_FLAG=""
TARGET_DIR="dist"
shift
;;
--debug)
if [[ "$BUILD_KIND" == "musl" ]]; then
print_error "--musl currently supports release builds only"
exit 1
fi
PROFILE="debug"
CARGO_FLAG=""
TARGET_DIR="target/debug"
shift
;;
-h|--help)
show_usage
exit 0
;;
*)
print_error "Unknown option: $1"
show_usage
exit 1
;;
esac
done
# Check we're in the project root (Cargo.toml present)
if [[ ! -f "Cargo.toml" ]]; then
print_error "Cargo.toml not found. Run this script from the project root."
exit 1
fi
# Check the binaries we expect are declared
if ! grep -q 'name = "signer"' Cargo.toml || ! grep -q 'name = "signer-client"' Cargo.toml; then
print_error "Expected binaries 'signer' and 'signer-client' not found in Cargo.toml"
exit 1
fi
# Build
if [[ "$BUILD_KIND" == "musl" ]]; then
print_status "Building static musl release binaries in Docker..."
./build_musl.sh
SIGNER_BIN="${TARGET_DIR}/signer-linux-x86_64-musl"
CLIENT_BIN="${TARGET_DIR}/signer-client-linux-x86_64-musl"
else
print_status "Building ${PROFILE} binaries (cargo build ${CARGO_FLAG})..."
cargo build ${CARGO_FLAG} 2>&1 | tail -5 || {
print_error "Build failed"
exit 1
}
SIGNER_BIN="${TARGET_DIR}/signer"
CLIENT_BIN="${TARGET_DIR}/signer-client"
fi
for bin in "$SIGNER_BIN" "$CLIENT_BIN"; do
if [[ ! -f "$bin" ]]; then
print_error "Built binary not found: $bin"
exit 1
fi
done
print_success "Binaries built: $SIGNER_BIN, $CLIENT_BIN"
# Install to /usr/local/bin
DEST_DIR="/usr/local/bin"
if [[ ! -d "$DEST_DIR" ]]; then
print_status "Creating $DEST_DIR..."
sudo mkdir -p "$DEST_DIR"
fi
install_binary() {
local src="$1"
local name="$2"
print_status "Installing $name to $DEST_DIR/..."
sudo install -m 0755 "$src" "$DEST_DIR/$name"
print_success "Installed: $DEST_DIR/$name"
}
install_binary "$SIGNER_BIN" "signer"
install_binary "$CLIENT_BIN" "signer-client"
# Verify
print_status "Verification:"
"$DEST_DIR/signer" --version || true
"$DEST_DIR/signer-client" --version || true
print_success "Local deploy completed (${PROFILE} ${BUILD_KIND} profile)"
+27 -8
View File
@@ -1,7 +1,7 @@
#!/bin/bash
set -e
# nsigner (Rust) — Increment and Push Script
# signer (Rust) — Increment and Push Script
#
# Increments the version (patch/minor/major), updates Cargo.toml and
# src/lib.rs, commits, tags, and pushes. Optionally creates a release
@@ -34,7 +34,7 @@ RELEASE_MODE=false
VERSION_INCREMENT_TYPE="patch"
show_usage() {
echo "nsigner (Rust) Increment and Push Script"
echo "signer (Rust) Increment and Push Script"
echo ""
echo "USAGE:"
echo " $0 [OPTIONS] \"commit message\""
@@ -169,18 +169,24 @@ verify_binary_version() {
}
build_release_binary() {
print_status "Building release binary (cargo build --release)..."
cargo build --release 2>&1 | tail -5 || return 1
print_status "Building static musl release binaries..."
./build_musl.sh || return 1
local bin_path="target/release/nsigner"
local bin_path="dist/signer"
verify_binary_version "$bin_path" "$NEW_VERSION" || return 1
print_success "Release binary built: $bin_path"
local client_path="dist/signer_client"
if [[ -f "$client_path" ]]; then
verify_binary_version "$client_path" "$NEW_VERSION" || return 1
print_success "Portable client binary built: $client_path"
fi
print_success "Portable signer binary built: $bin_path"
return 0
}
create_source_tarball() {
local tarball_name="nsigner-${NEW_VERSION#v}.tar.gz"
local tarball_name="signer-${NEW_VERSION#v}.tar.gz"
if tar -czf "$tarball_name" \
--exclude='target/*' \
@@ -279,7 +285,8 @@ main() {
git_commit_and_push
local binary_path="target/release/nsigner"
local binary_path="dist/signer"
local client_path="dist/signer_client"
local tarball_path=""
tarball_path=$(create_source_tarball || true)
@@ -288,6 +295,18 @@ main() {
if [[ -n "$release_id" ]]; then
upload_release_assets "$release_id" "$binary_path" "$tarball_path"
# Also upload the signer-client binary if it exists
if [[ -f "$client_path" ]]; then
local token
token=$(cat "$HOME/.gitea_token" | tr -d '\n\r')
local api_url="https://git.laantungir.net/api/v1/repos/laantungir/signer"
local assets_url="$api_url/releases/$release_id/assets"
print_status "Uploading signer-client..."
curl -s -X POST "$assets_url" \
-H "Authorization: token $token" \
-F "attachment=@$client_path;filename=signer_client" \
-F "name=signer_client" > /dev/null
fi
fi
print_success "Release flow completed: $NEW_VERSION"
+299
View File
@@ -0,0 +1,299 @@
#!/usr/bin/env bash
set -euo pipefail
# User-only installer for Qubes AppVM persistence model.
# Nothing is written to /usr, /etc, or other root-owned paths.
#
# Installs into $HOME:
# - signer -> ~/.local/bin/signer
# - signer-client -> ~/.local/bin/signer-client
# - startup helper -> ~/start_signer.sh
#
# Usage:
# bash install_signer.sh
# bash install_signer.sh --help
#
# Optional env vars:
# SIGNER_VERSION=vX.Y.Z # optional override; default is latest release tag
# SIGNER_GITEA_TOKEN=<token> # if signer release assets are private
# SIGNER_BINARY_URL=<direct url to signer binary>
# SIGNER_CLIENT_BINARY_URL=<direct url to signer-client binary>
# SIGNER_BINARY_ASSET=<asset name> # optional release asset override
# SIGNER_CLIENT_BINARY_ASSET=<asset name> # optional release asset override
SIGNER_VERSION="${SIGNER_VERSION:-}"
SIGNER_BINARY_ASSET="${SIGNER_BINARY_ASSET:-signer}"
SIGNER_CLIENT_BINARY_ASSET="${SIGNER_CLIENT_BINARY_ASSET:-signer_client}"
PREFIX_BIN="${HOME}/.local/bin"
log() { printf "\033[1;34m[INFO]\033[0m %s\n" "$*"; }
warn() { printf "\033[1;33m[WARN]\033[0m %s\n" "$*"; }
err() { printf "\033[1;31m[ERR ]\033[0m %s\n" "$*"; }
show_help() {
cat <<EOF
Usage: bash install_signer.sh [options]
User-only install (Qubes AppVM friendly):
- signer ${SIGNER_VERSION:-(latest)}
- signer-client ${SIGNER_VERSION:-(latest)}
- signer startup helper script
Options:
-h, --help Show this help and exit
Optional env vars:
SIGNER_VERSION=vX.Y.Z # optional override; default is latest release tag
SIGNER_GITEA_TOKEN=<token> # required if signer release assets are private
SIGNER_BINARY_URL=<direct url to signer binary>
SIGNER_CLIENT_BINARY_URL=<direct url to signer-client binary>
SIGNER_BINARY_ASSET=<asset name> # defaults to signer-linux-x86_64-musl
SIGNER_CLIENT_BINARY_ASSET=<asset name> # defaults to signer-client-linux-x86_64-musl
Install paths:
~/.local/bin/signer
~/.local/bin/signer-client
~/start_signer.sh
EOF
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || {
err "Missing command: $1"
exit 1
}
}
install_runtime_deps() {
if command -v apt-get >/dev/null 2>&1; then
log "Installing runtime dependencies via apt"
sudo apt-get update
sudo apt-get install -y ca-certificates curl jq
elif command -v dnf >/dev/null 2>&1; then
log "Installing runtime dependencies via dnf"
sudo dnf install -y ca-certificates curl jq
else
err "Unsupported distro: need apt-get or dnf to install runtime dependencies"
exit 1
fi
}
prepare_dirs() {
mkdir -p "${PREFIX_BIN}"
}
resolve_signer_version() {
local headers=()
local latest_tag=""
if [[ -n "${SIGNER_VERSION}" ]]; then
return 0
fi
if [[ -n "${SIGNER_GITEA_TOKEN:-}" ]]; then
headers=(-H "Authorization: token ${SIGNER_GITEA_TOKEN}")
fi
latest_tag="$(curl -fsSL "${headers[@]}" "https://git.laantungir.net/api/v1/repos/laantungir/signer/releases" \
| jq -r '.[0].tag_name // empty' || true)"
if [[ -z "${latest_tag}" ]]; then
err "Could not resolve latest signer release tag from API."
err "Set SIGNER_VERSION explicitly (e.g. SIGNER_VERSION=v0.0.14)."
exit 1
fi
SIGNER_VERSION="${latest_tag}"
}
# Resolve a release asset URL by asset name suffix.
# $1 = asset name to match exactly (e.g. "signer-linux-x86_64-musl")
download_signer_asset_url() {
local asset_name="$1"
local headers=()
local api_tag_url="https://git.laantungir.net/api/v1/repos/laantungir/signer/releases/tags/${SIGNER_VERSION}"
if [[ -n "${SIGNER_GITEA_TOKEN:-}" ]]; then
headers=(-H "Authorization: token ${SIGNER_GITEA_TOKEN}")
fi
curl -fsSL "${headers[@]}" "${api_tag_url}" \
| jq -r '.assets[]?.browser_download_url // empty' \
| grep -E "/${asset_name}$" \
| head -n1 || true
}
verify_installed_version() {
local expected="$1"
local bin_path="$2"
local got_line=""
local got_ver=""
if [[ ! -x "${bin_path}" ]]; then
err "Installed binary missing: ${bin_path}"
exit 1
fi
got_line="$("${bin_path}" --version 2>/dev/null || true)"
got_ver="$(printf '%s\n' "${got_line}" | awk '{print $2}')"
if [[ -z "${got_ver}" ]]; then
err "Could not determine installed signer version from: ${got_line}"
exit 1
fi
if [[ "${got_ver}" != "${expected}" ]]; then
err "Downloaded binary version mismatch: expected ${expected}, got ${got_ver}"
err "Release asset appears stale or mislabeled."
err "Use SIGNER_BINARY_URL to pin a known-good binary, or wait for a rebuilt release artifact."
exit 1
fi
}
# Download a single binary asset.
# $1 = asset name (for URL resolution fallback)
# $2 = override URL env var name (e.g. SIGNER_BINARY_URL)
# $3 = output path
download_binary() {
local asset_name="$1"
local override_env="$2"
local out_path="$3"
local asset_url=""
# Resolve override from env var
eval "asset_url=\"\${${override_env}:-}\""
if [[ -z "${asset_url}" ]]; then
asset_url="$(download_signer_asset_url "${asset_name}")"
fi
# Older releases used unqualified asset names. Keep that fallback while
# preferring explicit portable musl assets for new releases.
if [[ -z "${asset_url}" && "${asset_name}" == *-linux-x86_64-musl ]]; then
asset_url="$(download_signer_asset_url "${asset_name%-linux-x86_64-musl}")"
fi
if [[ -z "${asset_url}" ]]; then
err "Could not find downloadable ${asset_name} release binary for ${SIGNER_VERSION}."
err "Provide ${override_env} or SIGNER_GITEA_TOKEN so the release asset can be resolved."
exit 1
fi
log "Using ${asset_name} binary URL: ${asset_url}"
if [[ -n "${SIGNER_GITEA_TOKEN:-}" ]]; then
curl -fL -H "Authorization: token ${SIGNER_GITEA_TOKEN}" -o "${out_path}" "${asset_url}"
else
curl -fL -o "${out_path}" "${asset_url}"
fi
chmod 0755 "${out_path}"
}
install_signer() {
local release_page=""
resolve_signer_version
release_page="https://git.laantungir.net/laantungir/signer/releases/tag/${SIGNER_VERSION}"
log "Installing signer ${SIGNER_VERSION}"
log "Release page: ${release_page}"
# Prefer the statically linked musl artifact; fall back to legacy names.
download_binary "${SIGNER_BINARY_ASSET}" "SIGNER_BINARY_URL" "${PREFIX_BIN}/signer"
verify_installed_version "${SIGNER_VERSION}" "${PREFIX_BIN}/signer"
log "Installed ${PREFIX_BIN}/signer from release binary"
# Install the signer-client CLI binary (best-effort: older releases may not have it)
if [[ -z "${SIGNER_CLIENT_BINARY_URL:-}" ]] && ! download_signer_asset_url "${SIGNER_CLIENT_BINARY_ASSET}" >/dev/null 2>&1 && ! download_signer_asset_url "signer-client" >/dev/null 2>&1; then
warn "No signer-client asset found for ${SIGNER_VERSION}; skipping client install."
else
download_binary "${SIGNER_CLIENT_BINARY_ASSET}" "SIGNER_CLIENT_BINARY_URL" "${PREFIX_BIN}/signer-client"
log "Installed ${PREFIX_BIN}/signer-client from release binary"
fi
}
write_signer_start_script() {
local script_path="${HOME}/start_signer.sh"
cat >"${script_path}" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
export PATH="$HOME/.local/bin:$PATH"
LISTEN_TARGET="${SIGNER_LISTEN_TARGET:-tcp:[::]:8080}"
echo "=== signer startup ==="
echo "listen target: ${LISTEN_TARGET}"
# Optional: print current FIPS identity info if fipsctl is available.
if command -v fipsctl >/dev/null 2>&1; then
if fipsctl show status >/dev/null 2>&1; then
STATUS_JSON="$(fipsctl show status)"
elif sudo -n fipsctl show status >/dev/null 2>&1; then
STATUS_JSON="$(sudo -n fipsctl show status)"
else
STATUS_JSON=""
fi
if [[ -n "${STATUS_JSON}" ]]; then
FIPS_IPV6="$(printf '%s\n' "${STATUS_JSON}" | sed -n 's/.*"ipv6_addr": "\([^"]*\)".*/\1/p')"
FIPS_NPUB="$(printf '%s\n' "${STATUS_JSON}" | sed -n 's/.*"npub": "\([^"]*\)".*/\1/p')"
LISTEN_PORT="$(printf '%s\n' "${LISTEN_TARGET}" | sed -n 's/.*:\([0-9][0-9]*\)$/\1/p')"
[[ -n "${FIPS_IPV6}" ]] && echo "fips ipv6: ${FIPS_IPV6}"
[[ -n "${FIPS_NPUB}" ]] && echo "fips npub: ${FIPS_NPUB}"
if [[ -n "${FIPS_NPUB}" && -n "${LISTEN_PORT}" ]]; then
echo "fips address: http://${FIPS_NPUB}.fips:${LISTEN_PORT}"
fi
else
echo "fips status: unavailable (run as user in fips group or with sudo)"
fi
fi
echo
echo "Starting signer..."
echo "On first remote request, approve in prompt with [y] or [a]."
exec "$HOME/.local/bin/signer" --listen "${LISTEN_TARGET}"
EOF
chmod 0755 "${script_path}"
log "Wrote ${script_path}"
}
post_checks() {
export PATH="${PREFIX_BIN}:${PATH}"
log "Running post-install checks"
require_cmd signer
signer --version || true
if [[ -x "${PREFIX_BIN}/signer-client" ]]; then
signer-client --version || true
fi
log "User binaries installed in: ${PREFIX_BIN}"
log "If needed, add to shell PATH: export PATH=\"${PREFIX_BIN}:\$PATH\""
}
main() {
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
show_help
exit 0
fi
if [[ $# -gt 0 ]]; then
err "Unknown option: $1"
show_help
exit 1
fi
install_runtime_deps
prepare_dirs
install_signer
write_signer_start_script
post_checks
log "Completed user-only install of signer"
log "Start signer with: ~/start_signer.sh"
}
main "$@"
+2 -2
View File
@@ -1,7 +1,7 @@
# Menu Gap Analysis: C `main.c` vs Rust `signer`
**Source of truth:** the C code in [`src/main.c`](../n_signer/src/main.c:1), NOT
[`documents/nsigner_menus.md`](../n_signer/documents/nsigner_menus.md:1) (which is
[`documents/signer_menus.md`](../n_signer/documents/signer_menus.md:1) (which is
stale — e.g. it claims the wizard prompts `Require interactive approval? [Y/n]`, but
the actual C code hardcodes `requires_approval = 0` and never prompts).
@@ -31,7 +31,7 @@ Legend: ✅ matches, ⚠️ partial, ❌ missing/divergent.
- Success: `Seed phrase is valid and accepted.`
**Rust** ([`load_mnemonic_tui`](../signer/src/main.rs:549)):
- Frame: `nsigner v<ver> > Unlock`, title `"Enter mnemonic phrase"`
- Frame: `signer v<ver> > Unlock`, title `"Enter mnemonic phrase"`
- Prompt: `Enter your BIP-39 mnemonic phrase, or 'g' to generate a new one.`
- `g`/`G` → generate, numbered, warning ✅
- Otherwise → load as mnemonic (paste works implicitly) ⚠️
+152
View File
@@ -0,0 +1,152 @@
# Static musl portability plan
## Objective
Produce portable x86_64 Linux release binaries that do not depend on the target machine's glibc version. The primary release artifacts will be statically linked against musl and will remain compatible with the project's Linux, Qubes, Unix-socket, qrexec, TCP/HTTP, terminal, and secure-memory requirements.
## Current findings
- The development host is x86_64 Ubuntu 22.04 with glibc 2.35.
- Rust 1.80.1 and Cargo 1.80.1 are installed, but `rustup` is unavailable.
- Docker is available, so the build can be isolated from the host toolchain and libc.
- The project directly uses conventional Linux APIs through `libc`: `mlock`, `munlock`, `AF_UNIX`, `SO_PEERCRED`, `getuid`, `close`, `listen`, and `localtime_r`.
- Cryptography is implemented through Rust crates; no mandatory OpenSSL dependency was found in this repository.
- The sibling `nostr_core_lib_rust` checkout is a path dependency declared by `Cargo.toml` and must be present in the container build context.
- The vendored `ratatui` submodule and its crossterm backend must compile for the musl target.
- Existing release scripts build directly with the host Cargo installation and therefore do not guarantee libc portability.
## Compatibility policy
- Primary portable target: `x86_64-unknown-linux-musl`.
- Release artifacts are intended for x86_64 Linux systems regardless of the installed glibc version, subject to Linux kernel, CPU, terminal, qrexec, and resource-limit requirements.
- Do not use `-C target-cpu=native`; build for a conservative x86_64 baseline.
- Keep the normal host-native build for development and debugging.
- Treat `RLIMIT_MEMLOCK` separately from libc portability; static linking does not remove the need for appropriate memory-lock limits.
## Implementation phases
### 1. Add a reproducible musl build image
Create a pinned Docker build definition, preferably using a stable musl-based Rust image or a pinned Alpine image with an explicitly installed Rust toolchain.
The image must provide:
- Rust and Cargo versions compatible with the project's `Cargo.toml` and lockfile.
- The `x86_64-unknown-linux-musl` target.
- A C compiler/linker suitable for musl.
- Required build utilities and certificate configuration.
- No dependency on the host's Rust installation or glibc-linked build output.
Ensure the container can access both the project and the sibling `nostr_core_lib_rust` path dependency. The build must initialize or use the existing `ratatui` submodule consistently with normal project setup.
### 2. Add a portable build entry point
Create `build_musl.sh` with explicit behavior:
1. Validate that Docker is available.
2. Validate that the project root and required sibling path dependency exist.
3. Build both `signer` and `signer-client` for `x86_64-unknown-linux-musl` in release mode.
4. Use a dedicated output directory such as `dist/musl-x86_64`.
5. Copy the binaries using explicit names:
- `signer-linux-x86_64-musl`
- `signer-client-linux-x86_64-musl`
6. Avoid copying host-native binaries into the portable output.
7. Run binary validation and version checks before reporting success.
8. Return a nonzero status for missing dependencies, failed builds, invalid ELF output, dynamic linkage, or version mismatch.
Keep the script usable from the repository root and make its Docker invocation safe for ordinary user development.
### 3. Validate static linkage and runtime behavior
Add validation to the build flow using tools available in the container:
- `file` must identify x86_64 ELF executables.
- `readelf` must show the expected musl/static characteristics and no glibc loader requirement.
- `ldd` must not identify unresolved dynamic runtime dependencies.
- `signer --version` and `signer-client --version` must report the Cargo package version.
- Run `cargo test --target x86_64-unknown-linux-musl` where the test environment supports it.
Perform smoke checks for:
- stdio framing;
- Unix abstract socket bind/connect and peer identity;
- TCP and HTTP startup;
- qrexec subprocess integration where the host provides qrexec;
- mnemonic input and key derivation;
- representative signing and verification operations;
- TUI initialization in a terminal;
- `mlock` success or the documented unlocked-memory fallback behavior.
If a dependency cannot support musl, record the failure and determine whether it is optional, can be feature-gated, or requires retaining a glibc artifact.
### 4. Integrate release automation
Update `increment_and_push.sh` so release mode invokes the portable musl builder rather than the host-native `cargo build --release` path.
Preserve:
- version incrementing;
- source version updates;
- binary version verification;
- tagging and pushing;
- Gitea release creation;
- source tarball generation.
Extend asset upload handling to upload both musl binaries with their platform-specific names. Do not silently publish a host-linked binary under an ambiguous name.
Decide whether the existing unqualified assets remain as compatibility aliases. The safer default is to publish explicit musl names and retain legacy fallback handling only for older releases.
### 5. Update local deployment
Extend `deploy_local.sh` with an explicit portable option, such as `--musl`.
Suggested behavior:
- default development/debug builds remain native;
- `--musl` invokes the Docker build and installs the resulting portable binaries;
- release deployment should clearly report whether the installed binary is native or musl;
- installation continues to use `/usr/local/bin/` only when the user has the required privileges.
Add checks so the script does not mistake a musl artifact for a native build or install an absent/stale binary.
### 6. Update the installer
Update `install_signer.sh` to prefer the explicit musl release assets:
- `signer-linux-x86_64-musl`
- `signer-client-linux-x86_64-musl`
Retain support for existing older releases whose assets are named simply `signer` or `signer-client`. Add an override for users who need a different asset URL or a native glibc build.
Continue verifying the installed program version, and add target/artifact validation where practical so a mislabeled asset is rejected.
### 7. Update documentation
Update the build and platform sections of `README.md` to explain:
- native builds are for local development;
- portable release builds use static musl;
- the portable target is x86_64 Linux;
- the artifact names and output directory;
- how to verify static linkage with `file`, `readelf`, and `ldd`;
- static musl removes the glibc runtime dependency but not Linux kernel, CPU, terminal, qrexec, or `RLIMIT_MEMLOCK` requirements;
- Qubes users should use the musl release artifact unless a native glibc build is specifically required.
Update the project layout section with the new build definition and script.
## Acceptance criteria
- A clean Docker invocation builds both release binaries without using the host Rust compiler or host glibc.
- The resulting binaries are x86_64 and statically linked against musl.
- Neither binary requires the glibc dynamic loader or a minimum GLIBC symbol version.
- Both binaries pass version checks and the relevant test suite.
- Representative Unix, stdio, TCP/HTTP, crypto, TUI, and secure-memory paths are validated.
- Release automation uploads unambiguous musl artifacts.
- The installer can select the musl artifacts and remains compatible with older release naming.
- Documentation explains how to build, verify, install, and deploy the portable binaries.
## Deferred options
- Publishing a glibc-2.17-compatible artifact can be added later if a target integration requires glibc behavior.
- Additional architectures can be added later, but must have separate build images, artifact names, and validation.
- Musl should not be made the only local development target until the smoke tests demonstrate that all supported transports and terminal behavior are equivalent for the project's deployment environments.
+3 -3
View File
@@ -2,7 +2,7 @@
## Problem
The Rust `nsigner` library modules are complete and tested (92 tests pass), but the server loop in [`server.rs`](src/server.rs:117) accepts connections, reads requests, dispatches them, and sends responses **without any policy enforcement**. The `policy: &mut PolicyTable` parameter is accepted but never used. This means the daemon would sign anything for anyone without prompting — it is not an "attended signer."
The Rust `signer` library modules are complete and tested (92 tests pass), but the server loop in [`server.rs`](src/server.rs:117) accepts connections, reads requests, dispatches them, and sends responses **without any policy enforcement**. The `policy: &mut PolicyTable` parameter is accepted but never used. This means the daemon would sign anything for anyone without prompting — it is not an "attended signer."
## What the C version does (server.c)
@@ -148,10 +148,10 @@ Add methods to insert session grants:
```rust
impl PolicyTable {
/// Insert a session grant for caller+role+verb.
pub fn insert_session_grant(&mut self, caller: &str, verb: &str, role: &str) -> Result<(), NsignerError>;
pub fn insert_session_grant(&mut self, caller: &str, verb: &str, role: &str) -> Result<(), SignerError>;
/// Insert a session grant for caller+role (all verbs).
pub fn insert_session_grant_all(&mut self, caller: &str, role: &str) -> Result<(), NsignerError>;
pub fn insert_session_grant_all(&mut self, caller: &str, role: &str) -> Result<(), SignerError>;
}
```
+4 -4
View File
@@ -101,7 +101,7 @@ graph TB
### 4. Error Handling
- **C**: Integer error codes + string messages
- **Rust**: `thiserror`-based `NsignerError` enum. The JSON-RPC error codes are preserved exactly for wire compatibility.
- **Rust**: `thiserror`-based `SignerError` enum. The JSON-RPC error codes are preserved exactly for wire compatibility.
### 5. Transport
- **C**: Raw syscalls (`socket`, `bind`, `accept`, `SO_PEERCRED`)
@@ -323,7 +323,7 @@ signer/
│ ├── otp_pad.rs # One-time pad encryption
│ ├── socket_name.rs # Abstract socket naming
│ ├── tui.rs # Terminal UI
│ └── error.rs # NsignerError enum
│ └── error.rs # SignerError enum
├── tests/
│ ├── integration_test.rs
│ ├── algorithm_test.rs
@@ -337,7 +337,7 @@ signer/
```toml
[package]
name = "nsigner"
name = "signer"
version = "0.1.0"
edition = "2021"
@@ -372,7 +372,7 @@ tempfile = "3"
3. **Derivation paths**: BIP-32/SLIP-0010 paths must produce identical keys from the same mnemonic
4. **Socket protocol**: Length-prefixed framing (4-byte BE) must be compatible
5. **HTTP**: Same minimal HTTP/1.1 POST-only parser behavior
6. **Abstract socket names**: `@nsigner_<word1>_<word2>` format preserved
6. **Abstract socket names**: `@signer_<word1>_<word2>` format preserved
## Open Questions
+152
View File
@@ -0,0 +1,152 @@
# PQ Seeded Derivation Migration Plan (signer)
## Status
**Implemented** (signer). Companion to the v2 hardened-derivation design in
`nostr_quantum_preparation/plans/v2-hardened-derivation.md`. That project is the
only one with users; it keeps a v1→v2 migration path. signer (and n_signer)
have no users, so we are free to align to the FIPS seeded interface directly.
Implementation notes (divergences from the original proposal, all consistent
with its intent):
- `derive_pq_seed(mnemonic, coin, indices)` was realized as
`derive_pq_seed_from_path(mnemonic, path, seed_len)` — the sibling child is
derived by incrementing the last path level, so callers pass a single path.
- PQ private keys are stored in seed form (ML-DSA-65 32 B, ML-KEM-768 64 B,
SLH-DSA-128s 64 B sk serialization); `CryptoAlg::sizes()` reflects this.
- SLH-DSA-128s uses the SHA2 parameter set (`slh_dsa::Sha2_128s`), matching
the web app's `slh_dsa_sha2_128s`.
- The v2 conformance test (`tests/pq_conformance.rs`) reproduces
`seed-to-pubkeys.v2.json` exactly for all three algorithms.
- The dispatcher's `encapsulate`/`decapsulate` verbs and the sign-verb
private-key truncation bug were fixed as part of this work.
- n_signer (C) still needs the same migration — filed separately there.
## Background
signer is the Rust port of n_signer and inherited two PQ derivation choices:
1. **SHAKE-256 DRBG pipeline** ([`src/pq_drbg.rs`](../src/pq_drbg.rs)): the
BIP-44-derived 32-byte seed feeds a SHAKE-256 DRBG that stands in for
PQClean's `randombytes()` callback during keygen. This was an API artifact
of the C PQClean integration, not a cryptographic choice.
2. **SLIP-0010 derivation for PQ paths**
([`src/pq_crypto.rs:90`](../src/pq_crypto.rs)): `derive_seed_from_mnemonic()`
branches on the path prefix — BIP-32 for `m/44'/1237'`, SLIP-0010 for
everything else (ed25519, x25519, PQ).
The nostr_quantum_preparation web app (the project with actual users) has
standardized v2 on:
- **Per-algorithm coin types** in the unregistered SLIP-44 `102XXX'` range
(102003' ML-DSA-65, 102004' SLH-DSA-128s, 102005' ML-KEM-768 — matching
signer's existing allocations — plus new 102006' ML-DSA-44 and 102007'
Falcon-512).
- **FIPS seeded keygen**: BIP32 child bytes (exact length: 32 B ML-DSA,
48 B SLH-DSA-128s, 64 B ML-KEM) → `keygen(seed)`. No DRBG.
- **BIP32 (not SLIP-0010)** for the PQ paths, via `@scure/bip32` `HDKey`.
## Problem
Two divergences prevent cross-project key parity (same mnemonic + same path →
same PQ keys):
| Divergence | signer today | nostr_quantum_preparation v2 |
|---|---|---|
| Seed expansion | SHAKE-256 DRBG → RNG-fed keygen | exact-length seed → seeded keygen |
| Derivation function for PQ paths | SLIP-0010 | BIP32 |
SLIP-0010 and BIP32 produce different master keys (different HMAC keys) and
different child derivation, so even identical paths yield unrelated seeds.
The DRBG pipeline additionally means signer can never reproduce FIPS-seeded
keys regardless of derivation function.
Neither divergence is a security weakness — both are deterministic expansions
of secret material — but parity matters operationally: a user should be able
to derive the same PQ identity in the web app and on signer hardware from one
mnemonic.
## Solution
Migrate signer's PQ keygen to the FIPS seeded interface and PQ path derivation
to BIP32, keeping ed25519/x25519 on SLIP-0010 (correct for those curves).
### Target pipeline
```
mnemonic → BIP39 seed → BIP32 master → m/44'/<coin>'/0'/0'/<n>' → child bytes
→ concatenate/truncate to algorithm seed length → seeded keygen
```
| Algorithm | Coin type | Path | Seed len | RustCrypto API |
|---|---|---|---|---|
| ML-DSA-65 | `102003'` | `m/44'/102003'/0'/0'/0'` | 32 B | `ml_dsa::SigningKey::from_seed(&[u8; 32])` |
| SLH-DSA-128s | `102004'` | `m/44'/102004'/0'/0'/0'` + `/1'` | 48 B | `slh_dsa` seeded keygen (verify exact API at rc version in use) |
| ML-KEM-768 | `102005'` | `m/44'/102005'/0'/0'/0'` + `/1'` | 64 B | `ml_kem::DecapsulationKey::from_seed(&[u8; 64])` |
| ML-DSA-44 | `102006'` | `m/44'/102006'/0'/0'/0'` | 32 B | future — add with `ml-dsa` crate |
| Falcon-512 | `102007'` | `m/44'/102007'/0'/0'/0'` + `/1'` | 48 B | future — no stable RustCrypto crate; rejection sampling makes cross-library determinism impossible anyway |
48/64-byte seeds come from two hardened children concatenated (first 48 of 64
used where 48 B is required) — matching the web app's construction exactly.
### Falcon caveat
Falcon keygen is rejection-sampling-based with no universally implemented seed
interface. Even with identical seeds, implementations disagree. The web app
pins noble's behavior in test vectors and flags Falcon as per-library in its
NIP proposal; signer should do the same when Falcon support lands, and should
not promise parity for it.
## Changes
### 1. `src/pq_crypto.rs`
- `derive_seed_from_mnemonic()`: route PQ coin types (102003'102007') through
BIP-32 (same branch as `m/44'/1237'`), keeping SLIP-0010 only for
ed25519/x25519 (102001'/102002').
- Add `derive_pq_seed(mnemonic, coin_type, indices) -> Vec<u8>` implementing
the concatenate/truncate-to-length construction.
- Replace DRBG-fed keygen call sites with the seeded APIs above.
### 2. `src/pq_drbg.rs`
- Keep the module (it is a faithful port and may serve future PQClean-style
integrations) but remove it from the PQ keygen path. Mark as not-used-for-
derivation in the module doc.
### 3. `src/alg_cache.rs`, `src/role_table.rs`, `src/tui.rs`, `src/main.rs`
- No path changes needed for 102003'102005' (already correct).
- Add `MlDsa44` (`102006'`) to `CryptoAlg`, path formatting, purpose mapping
(`PqSig`), and TUI presets when the `ml-dsa` crate's ML-DSA-44 variant is
wired in. Falcon (`102007'`) waits on a viable crate.
### 4. Tests
- Cross-implementation conformance: reproduce the web app's
`seed-to-pubkeys.v2.json` vector (same fixed mnemonic) for ML-DSA-65,
SLH-DSA-128s, and ML-KEM-768. This is the acceptance test for parity.
- Regression: DRBG removal does not change ed25519/x25519 derivation.
- Unit: 48/64-byte seed construction matches the two-children concatenation.
### 5. Docs
- `README.md` / `documents/` equivalent: document the seeded pipeline, the
BIP32-for-PQ decision, the coin-type registry (102003'102005' existing,
102006'102007' reserved), and the Falcon caveat.
- Note for n_signer (C): same migration applies; file it there separately.
## What we are explicitly NOT doing
- Not changing secp256k1 NIP-06 derivation (`m/44'/1237'/n'/0/0`, BIP-32).
- Not changing ed25519/x25519 SLIP-0010 derivation (correct for those curves).
- Not preserving DRBG-derived PQ keys (no users; clean break is the point).
- Not implementing Falcon now (no stable crate; determinism caveat).
## Acceptance criteria
1. `cargo test` passes with the seeded pipeline.
2. The web app's v2 vector reproduces exactly for ML-DSA-65, SLH-DSA-128s,
ML-KEM-768 (same mnemonic → same pubkeys in Rust and JS).
3. ed25519/x25519 pubkeys unchanged from pre-migration for the same mnemonic.
+3 -3
View File
@@ -33,8 +33,8 @@ full width at the bottom.
├──────────────────────────────────────┬───────────────────────────────┤
│ Information │ Activity (latest first) ▲ │
│ session=unlocked (12 words) │ 2026-08-18 08:00:15 req… │
│ signer=nsigner01 derived=2 │ 2026-08-18 08:00:01 start │
│ socket=@nsigner01 transport=unix │ │
│ signer=signer01 derived=2 │ 2026-08-18 08:00:01 start │
│ socket=@signer01 transport=unix │ │
│ OTP pad: chksum=abc… offset=128 │ │
├──────────────────────────────────────┤ │
│ Roles │ │
@@ -273,7 +273,7 @@ Each setup screen has its own `draw` function and event handler. The
`tui_continuous.rs` entirely.
9. **Test**`cargo test` (unit tests don't touch the TUI). Manual test:
start signer, verify setup screens work with InputField, verify 4-section
main screen renders, press `d`/`l`/`r`/`q`, connect with `nsigner_client`.
main screen renders, press `d`/`l`/`r`/`q`, connect with `signer_client`.
## What stays the same
+21 -21
View File
@@ -1,14 +1,14 @@
# Plan: `signer-client` — Rust CLI for the nsigner daemon
# Plan: `signer-client` — Rust CLI for the signer daemon
## Goal
A standalone Rust command-line client `signer-client` that connects to a running
`nsigner` process over its framed transports (Unix abstract socket, TCP, serial,
`signer` process over its framed transports (Unix abstract socket, TCP, serial,
qrexec) and exposes the full JSON-RPC verb surface over stdin/stdout so that
signed events can be piped directly into `nak publish`.
This is a **Rust port** of the C [`n_signer_client.c`](../n_signer/client/n_signer_client.c:1)
(~855 lines). It reuses the existing `nsigner` library crate for transport
(~855 lines). It reuses the existing `signer` library crate for transport
framing, socket discovery, and verb/error constants — the new code is the
typed-verb client layer + CLI parsing + non-Unix transports.
@@ -24,8 +24,8 @@ typed-verb client layer + CLI parsing + non-Unix transports.
- [`src/client/main.rs`](src/client/main.rs:1) — entry point, CLI parse, dispatch
- [`src/client/cli.rs`](src/client/cli.rs:1) — clap `Cli` / `Verb` structs + usage text
- [`src/client/transport.rs`](src/client/transport.rs:1) — `ClientTransport` enum (Unix/Tcp/Serial/Qrexec) + open/connect helpers
- [`src/client/rpc.rs`](src/client/rpc.rs:1) — low-level `NsignerClient` (send framed JSON-RPC, recv, parse result/error)
- [`src/client/signer.rs`](src/client/signer.rs:1) — high-level `NsignerSigner` typed-verb wrappers (mirrors C `nostr_signer_t`)
- [`src/client/rpc.rs`](src/client/rpc.rs:1) — low-level `SignerClient` (send framed JSON-RPC, recv, parse result/error)
- [`src/client/signer.rs`](src/client/signer.rs:1) — high-level `SignerSigner` typed-verb wrappers (mirrors C `nostr_signer_t`)
- [`src/client/auth.rs`](src/client/auth.rs:1) — client-side auth envelope builder for TCP/qrexec
- New doc: [`src/client/README.md`](src/client/README.md:1) — usage, verbs, pipe-to-nak recipes (port of `n_signer_client_README.md`).
- The existing `client` subcommand in [`src/main.rs`](src/main.rs:276) stays as a thin raw-passthrough convenience; it is **not** removed.
@@ -48,23 +48,23 @@ typed-verb client layer + CLI parsing + non-Unix transports.
options, selector options, algorithm options, mine-event options, and a
`Verb` enum.
2. **`ClientTransport`** — enum wrapping the four connection types behind a
unified `send`/`recv` interface (the C `nsigner_transport_t` vtable).
unified `send`/`recv` interface (the C `signer_transport_t` vtable).
- Unix: `connect_abstract_unix` (already in crate).
- TCP: `std::net::TcpStream` + framed I/O (server already speaks framed
JSON over TCP per [`server.rs`](src/server.rs:108)).
- Serial: `std::fs::OpenOptions` on `/dev/ttyACM*` + framed I/O over the
file handle (matches C `nsigner_transport_open_serial`).
file handle (matches C `signer_transport_open_serial`).
- Qrexec: spawn `qrexec-client-vm <qube> <service>` via `std::process`,
pipe framed JSON over its stdin/stdout (matches C
`nsigner_transport_open_qrexec`).
3. **`NsignerClient`** — low-level RPC caller: builds `{"id","method","params"}`
`signer_transport_open_qrexec`).
3. **`SignerClient`** — low-level RPC caller: builds `{"id","method","params"}`
JSON, sends framed, receives framed, splits `result` vs `error`, holds
`last_error`. Mirrors C `nsigner_client_t` / `nsigner_client_call`.
4. **`NsignerSigner`** — high-level typed-verb layer. Holds a `NsignerClient`
`last_error`. Mirrors C `signer_client_t` / `signer_client_call`.
4. **`SignerSigner`** — high-level typed-verb layer. Holds a `SignerClient`
plus the resolved selector (`role` + `role_path`) and auth state. One
method per verb, each building the correct `params` array + options object
and parsing the typed result. Mirrors C `nostr_signer_t` /
`nostr_signer_nsigner_from_client`.
`nostr_signer_signer_from_client`.
5. **Client-side auth envelope builder** — for TCP/qrexec: construct a
NIP-42 kind-22242 auth event from the `--auth-privkey`, sign it, and
prepend it to the request frame. The server-side verifier in
@@ -125,7 +125,7 @@ and the verb table in [`enforcement.rs`](src/enforcement.rs:22).
| Verb | stdout |
|---|---|
| `list` | Running nsigner abstract sockets (one per line) |
| `list` | Running signer abstract sockets (one per line) |
### Metadata
@@ -229,9 +229,9 @@ flowchart TD
Tr -->|tcp| Tcp[TcpStream + auth.rs]
Tr -->|serial| Serial[OpenOptions /dev/ttyACM]
Tr -->|qrexec| Qrexec[spawn qrexec-client-vm]
Tr --> Rpc[rpc.rs<br/>NsignerClient]
Rpc --> Signer[signer.rs<br/>NsignerSigner typed verbs]
Signer -->|build params| Disp[nsigner daemon<br/>dispatcher.rs]
Tr --> Rpc[rpc.rs<br/>SignerClient]
Rpc --> Signer[signer.rs<br/>SignerSigner typed verbs]
Signer -->|build params| Disp[signer daemon<br/>dispatcher.rs]
Disp -->|result/error| Signer
Signer -->|one line| Stdout[stdout]
```
@@ -246,10 +246,10 @@ flowchart TD
`open_tcp`, `open_serial`, `open_qrexec`; unified `send`/`recv` via the
existing `send_framed`/`recv_framed`. Include `parse_host_port` and
`parse_qube_service` helpers.
4. Implement `rpc.rs`: `NsignerClient` struct holding the transport,
4. Implement `rpc.rs`: `SignerClient` struct holding the transport,
`call(method, params) -> Result<Value, String>`, `last_error`, and
framed send/recv using `serde_json`.
5. Implement `signer.rs`: `NsignerSigner` with selector state + one method
5. Implement `signer.rs`: `SignerSigner` with selector state + one method
per verb (get_info, get_public_key, sign_event, mine_event, nip04/44
encrypt/decrypt, sign, verify, derive, encapsulate, decapsulate,
derive_shared_secret, otp encrypt/decrypt). Each builds the `params`
@@ -269,8 +269,8 @@ flowchart TD
- **Unit tests** in `rpc.rs` / `signer.rs`: build-params correctness using
`serde_json::json!` assertions (no socket needed).
- **Integration test** `tests/client_smoke.rs`: spawn `nsigner
--mnemonic-stdin --listen unix --socket-name nsigner_test` with a fixed
- **Integration test** `tests/client_smoke.rs`: spawn `signer
--mnemonic-stdin --listen unix --socket-name signer_test` with a fixed
test mnemonic in a thread, then run the client verbs against it and
assert stdout shape + exit codes. Tear down the server.
- Manual pipe-to-`nak` check for `sign-event`.
@@ -278,7 +278,7 @@ flowchart TD
## Out of scope
- No TUI, no approval UI — the human attendant lives in the running
`nsigner` process; the client is a thin wire caller.
`signer` process; the client is a thin wire caller.
- No key storage, no mnemonic handling.
- No HTTP-listener client (HTTP is a server-side listener mode; the client
uses the framed transports).
+5 -5
View File
@@ -34,7 +34,7 @@ underlined (e.g. "Quit" with Q underlined, not "Q quit").
The user asked whether "client name" should be renamed since the
signer is more a server than a client. **Decision: rename to "signer
name".** The field shows the socket name (e.g. `nsigner01`), which is
name".** The field shows the socket name (e.g. `signer01`), which is
the name clients use to connect. Calling it "signer name" is clearer
than "client name" and consistent with the program name.
@@ -124,9 +124,9 @@ the C-format log entries (newest first).
├──────────────────────────────────────────┬──────────────────────────────────────────┤
│ Information │ Activity │
│ │ │
│ signer name: nsigner01 │ 2026-08-18 15:05:42 unix:1000 │
│ signer name: signer01 │ 2026-08-18 15:05:42 unix:1000 │
│ Unix address: │ secp256k1 m/44'/1237'/0'/0/0 │
nsigner01 │ 2026-08-18 15:05:30 unix:1000 │
│ signer01 │ 2026-08-18 15:05:30 unix:1000 │
│ Qube address: │ - - │
│ (inactive) │ 2026-08-18 15:04:55 unix:1000 │
│ FIPS address: │ secp256k1 m/44'/1237'/0'/0/0 │
@@ -142,7 +142,7 @@ the C-format log entries (newest first).
│ [ ] Qube b̲ridge │ secp256k1 m/44'/1237'/0'/0/0 │
│ [ ] F̲IPS │ 2026-08-18 15:00:22 unix:1000 │
│ [ ] H̲TTP │ secp256k1 m/44'/1237'/0'/0/0 │
│ │ 2026-08-18 15:00:10 nsigner started │
│ │ 2026-08-18 15:00:10 signer started │
├──────────────────────────────────────────┤ │
│ Roles │ │
│ │ │
@@ -575,7 +575,7 @@ and deleted directly in the Roles section.
verify AddRole popup preset menu flow, verify Transport toggle
(4 lines, tab navigation), verify Help screen is scrollable and
shows app description + key commands, verify activity log shows
detailed request info, connect with `nsigner_client`.
detailed request info, connect with `signer_client`.
## What stays the same
Executable
+84
View File
@@ -0,0 +1,84 @@
#!/bin/bash
# qdiag.sh — read-only Qubes dom0 diagnostics for SignerRpc qrexec issues.
# Run as root in dom0: sudo bash qdiag.sh
# Output: qdiag.txt in the current directory. No system changes are made.
OUT="qdiag.txt"
: >"$OUT"
sec() { printf '\n===== %s =====\n' "$1" >>"$OUT"; }
run() { printf '\n$ %s\n' "$*" >>"$OUT"; "$@" >>"$OUT" 2>&1 || true; }
runsh() { printf '\n$ %s\n' "$1" >>"$OUT"; bash -c "$1" >>"$OUT" 2>&1 || true; }
sec "1. Identity / versions"
run hostname
run id
run uname -a
run cat /etc/qubes-release
run qubesctl --version
run rpm -q qubes-core-admin-linux qubes-core-qrexec 2>/dev/null
sec "2. Qubes version detail"
run qvm-ls --raw-data --fields NAME,STATE,CLASS,TEMPLATE 2>/dev/null
run qvm-ls --running 2>/dev/null
sec "3. Policy directories inventory"
run ls -la /etc/qubes/policy.d/
run ls -la /etc/qubes-rpc/policy/ 2>/dev/null
run ls -la /usr/share/qubes/policy.d/ 2>/dev/null
run ls -la /etc/qubes-rpc/ 2>/dev/null
sec "4. Policy file ownership/permissions"
runsh 'find /etc/qubes/policy.d /etc/qubes-rpc/policy /usr/share/qubes/policy.d -maxdepth 1 -type f -printf "%M %u:%g %s %p\n" 2>/dev/null | sort'
sec "5. Symlinks in policy dirs"
runsh 'find /etc/qubes/policy.d /etc/qubes-rpc /etc/qubes-rpc/policy -maxdepth 2 -type l -printf "%p -> %l\n" 2>/dev/null'
sec "6. Search for SignerRpc / nsigner / signer policy files"
runsh 'find / -xdev \( -path /proc -o -path /sys -o -path /dev \) -prune -o -iname "*signer*" -print 2>/dev/null | grep -vi "^/home" | head -50'
runsh 'grep -RIl "SignerRpc\|NsignerRpc\|nsigner" /etc/qubes /usr/share/qubes 2>/dev/null | head -30'
sec "7. Policy contents (signer-related)"
runsh 'for f in $(grep -RIl "SignerRpc\|NsignerRpc\|nsigner\|signer" /etc/qubes/policy.d /etc/qubes-rpc/policy /usr/share/qubes/policy.d 2>/dev/null); do echo "--- $f ---"; cat "$f"; echo; done'
sec "8. Clipboard policy contents"
runsh 'for f in $(grep -RIl "ClipboardPaste" /etc/qubes/policy.d /etc/qubes-rpc/policy /usr/share/qubes/policy.d 2>/dev/null); do echo "--- $f ---"; cat "$f"; echo; done'
sec "9. Full policy.d listing with contents (all files)"
runsh 'for f in /etc/qubes/policy.d/*; do echo "--- $f ---"; cat "$f" 2>/dev/null; echo; done'
sec "10. Effective policy query (if tools exist)"
runsh 'command -v qrexec-policy-graph && qrexec-policy-graph --include-ask 2>&1 | grep -iE "signer|clipboard" | head -30'
runsh 'command -v qvm-tags && qvm-tags dom0 2>/dev/null | head -20'
sec "11. qrexec policy daemon status"
run systemctl status qrexec-policy-daemon --no-pager -l 2>&1
run systemctl is-active qrexec-policy-daemon 2>&1
sec "12. Target qube info (nostr_signer)"
run qvm-ls --raw-data --fields NAME,STATE,CLASS,TEMPLATE,NETVM nostr_signer 2>/dev/null
run qvm-features nostr_signer 2>/dev/null
run qvm-prefs nostr_signer 2>/dev/null
sec "13. Caller qube info (ai)"
run qvm-ls --raw-data --fields NAME,STATE,CLASS,TEMPLATE,NETVM ai 2>/dev/null
run qvm-features ai 2>/dev/null
sec "14. Recent qrexec/policy journal errors"
runsh 'journalctl -b --no-pager 2>/dev/null | grep -iE "qrexec|policy|SignerRpc|NsignerRpc|clipboard" | tail -80'
sec "15. qrexec service definitions in target qube (via qvm-run, read-only)"
runsh 'qvm-run -p nostr_signer "ls -la /etc/qubes-rpc/ 2>/dev/null; echo ---; ls -la /rw/config/qubes-rpc/ 2>/dev/null; echo ---; cat /rw/config/rc.local 2>/dev/null" 2>&1 | head -60'
sec "16. Test qrexec call to nostr_signer (read-only get_info)"
runsh 'echo "{\"id\":\"diag\",\"method\":\"get_info\",\"params\":[]}" | timeout 10 qrexec-client-vm nostr_signer qubes.SignerRpc 2>&1; echo "exit=$?"'
runsh 'echo "{\"id\":\"diag\",\"method\":\"get_info\",\"params\":[]}" | timeout 10 qrexec-client-vm nostr_signer qubes.NsignerRpc 2>&1; echo "exit=$?"'
sec "17. Qubes global config files"
runsh 'ls -la /etc/qubes/ | head -30'
runsh 'cat /etc/qubes/policy.d/50-config-input.policy 2>/dev/null'
runsh 'cat /etc/qubes/policy.d/50-config-updates.policy 2>/dev/null'
sec "DONE"
printf 'Diagnostics complete. Output saved to %s\n' "$OUT"
exit 0
+1445
View File
File diff suppressed because it is too large Load Diff
Executable
+84
View File
@@ -0,0 +1,84 @@
#!/bin/bash
# qfix.sh — repair dom0 qrexec policy + signer handler. Run as root in dom0:
# sudo bash qfix.sh
# Fixes (with backups saved to ~/qfix-backup-<ts>/):
# 1. Removes invalid old-format file /etc/qubes-rpc/policy/qubes.SignerRpc
# (its "* * * allow" content breaks ALL qrexec policy loading)
# 2. Rewrites /etc/qubes/policy.d/45-signer.policy with direct targets
# 3. Sets the signer-signer tag on nostr_signer (optional, for tag rules)
# 4. Creates /etc/qubes-rpc/qubes.SignerRpc handler inside nostr_signer
# (modeled on the existing qubes.NsignerRpc handler)
# 5. Verifies policy loads cleanly
set -u
TS=$(date +%Y%m%d-%H%M%S)
BK=~/qfix-backup-$TS
mkdir -p "$BK"
ok() { printf '\033[1;32m[OK]\033[0m %s\n' "$*"; }
info(){ printf '\033[1;34m[..]\033[0m %s\n' "$*"; }
err() { printf '\033[1;31m[ERR]\033[0m %s\n' "$*"; }
[[ $EUID -eq 0 ]] || { err "Run as root: sudo bash qfix.sh"; exit 1; }
# ── 1. Remove the invalid old-format policy file ─────────────────────
if [[ -f /etc/qubes-rpc/policy/qubes.SignerRpc ]]; then
cp /etc/qubes-rpc/policy/qubes.SignerRpc "$BK/" 2>/dev/null
rm -f /etc/qubes-rpc/policy/qubes.SignerRpc
ok "removed invalid /etc/qubes-rpc/policy/qubes.SignerRpc (backed up)"
else
ok "no invalid old-format file present"
fi
# ── 2. Rewrite 45-signer.policy with direct target rules ─────────────
SIGNER_POLICY=/etc/qubes/policy.d/45-signer.policy
if [[ -f "$SIGNER_POLICY" ]]; then
cp "$SIGNER_POLICY" "$BK/45-signer.policy"
fi
cat > "$SIGNER_POLICY" <<'EOF'
# Qubes OS qrexec policy for signer (qubes.SignerRpc)
# Direct rules: caller -> nostr_signer
qubes.SignerRpc * ai nostr_signer allow
qubes.SignerRpc * nostr nostr_signer allow
qubes.SignerRpc * @anyvm @anyvm ask default_target=nostr_signer
EOF
chown root:root "$SIGNER_POLICY"
chmod 0644 "$SIGNER_POLICY"
ok "rewrote $SIGNER_POLICY"
# ── 3. Tag nostr_signer (enables @tag:signer-signer rules if ever used) ──
qvm-tags nostr_signer add signer-signer 2>/dev/null \
&& ok "tagged nostr_signer with signer-signer" \
|| info "tag set skipped (non-fatal)"
# ── 4. Create the qrexec handler inside nostr_signer ─────────────────
info "inspecting existing handlers in nostr_signer..."
qvm-run -p nostr_signer 'cat /etc/qubes-rpc/qubes.NsignerRpc 2>/dev/null' || true
# Detect signer binary location in the target qube
BIN=$(qvm-run -p nostr_signer \
'for b in $HOME/.local/bin/signer /usr/local/bin/signer; do [ -x "$b" ] && echo "$b" && break; done' 2>/dev/null | tr -d '\r')
[[ -n "$BIN" ]] || { err "signer binary not found in nostr_signer (run install_signer.sh there first)"; BIN="/home/user/.local/bin/signer"; }
info "signer binary in nostr_signer: $BIN"
qvm-run -u root -p nostr_signer "printf '#!/bin/sh\nexec $BIN bridge\n' > /etc/qubes-rpc/qubes.SignerRpc && chmod 0755 /etc/qubes-rpc/qubes.SignerRpc" \
&& ok "created /etc/qubes-rpc/qubes.SignerRpc in nostr_signer" \
|| err "handler creation failed (create manually)"
# Show what we created
qvm-run -p nostr_signer 'ls -la /etc/qubes-rpc/qubes.SignerRpc; cat /etc/qubes-rpc/qubes.SignerRpc' || true
# ── 5. Verify policy loads cleanly ───────────────────────────────────
info "verifying policy syntax..."
if qrexec-policy-graph --include-ask >/dev/null 2>"$BK/policy-graph.err"; then
ok "policy loads cleanly (no syntax errors)"
else
err "policy still has errors:"
cat "$BK/policy-graph.err"
fi
echo
ok "repair complete. Backups in $BK"
echo "Now test from the ai qube:"
echo " signer-client --qrexec nostr_signer:qubes.SignerRpc --role main --path \"m/44'/1237'/0'/0/0\" get-public-key"
echo "Clipboard (dom0 -> ai) should also work again: Ctrl+Shift+C in dom0, Ctrl+Shift+V in ai"
+93 -27
View File
@@ -6,7 +6,7 @@
use crate::mnemonic::MnemonicState;
use crate::pq_crypto::CryptoAlg;
use crate::secure_mem::SecureBuf;
use crate::NsignerError;
use crate::SignerError;
pub const ALG_KEY_CACHE_MAX: usize = 32;
@@ -47,9 +47,9 @@ impl AlgorithmKeyCache {
mnemonic: &MnemonicState,
alg: CryptoAlg,
index: i32,
) -> Result<(), NsignerError> {
) -> Result<(), SignerError> {
if !mnemonic.is_loaded() {
return Err(NsignerError::MnemonicNotLoaded);
return Err(SignerError::MnemonicNotLoaded);
}
// Already cached?
@@ -62,19 +62,9 @@ impl AlgorithmKeyCache {
self.entries.remove(0);
}
let phrase = mnemonic.phrase().ok_or(NsignerError::MnemonicNotLoaded)?;
// Build the standard derivation path for this algorithm
let path = match alg {
CryptoAlg::Secp256k1 => format!("m/44'/1237'/{}'/0/0", index),
CryptoAlg::Ed25519 => format!("m/44'/102001'/{}'/0'/0'", index),
CryptoAlg::X25519 => format!("m/44'/102002'/{}'/0'/0'", index),
CryptoAlg::MlDsa65 => format!("m/44'/102003'/{}'/0'/0'", index),
CryptoAlg::SlhDsa128s => format!("m/44'/102004'/{}'/0'/0'", index),
CryptoAlg::MlKem768 => format!("m/44'/102005'/{}'/0'/0'", index),
CryptoAlg::Unknown => return Err(NsignerError::InvalidInput),
};
let phrase = mnemonic.phrase().ok_or(SignerError::MnemonicNotLoaded)?;
let path = standard_path(alg, index)?;
let entry = derive_alg_key(phrase, &path, alg, index)?;
self.entries.push(entry);
Ok(())
@@ -92,18 +82,48 @@ impl Default for AlgorithmKeyCache {
}
}
/// The standard derivation path for an algorithm at a given index.
///
/// secp256k1 uses the NIP-06 path; ed25519/x25519/PQ use their
/// per-algorithm SLIP-44 coin types (102001'102005').
pub fn standard_path(alg: CryptoAlg, index: i32) -> Result<String, SignerError> {
match alg {
CryptoAlg::Secp256k1 => Ok(format!("m/44'/1237'/{}'/0/0", index)),
CryptoAlg::Ed25519 => Ok(format!("m/44'/102001'/{}'/0'/0'", index)),
CryptoAlg::X25519 => Ok(format!("m/44'/102002'/{}'/0'/0'", index)),
CryptoAlg::MlDsa65 => Ok(format!("m/44'/102003'/{}'/0'/0'", index)),
CryptoAlg::SlhDsa128s => Ok(format!("m/44'/102004'/{}'/0'/0'", index)),
CryptoAlg::MlKem768 => Ok(format!("m/44'/102005'/{}'/0'/0'", index)),
CryptoAlg::Unknown => Err(SignerError::InvalidInput),
}
}
/// Derive a single algorithm key entry.
fn derive_alg_key(
mnemonic_phrase: &str,
path: &str,
alg: CryptoAlg,
index: i32,
) -> Result<AlgKeyEntry, NsignerError> {
) -> Result<AlgKeyEntry, SignerError> {
let sizes = alg
.sizes()
.ok_or(NsignerError::KeyDerivationFailed)?;
.ok_or(SignerError::KeyDerivationFailed)?;
let seed = crate::pq_crypto::derive_seed_from_mnemonic(mnemonic_phrase, path)?;
// PQ algorithms use the v2 seeded derivation: exact-length seed
// (32/48/64 B) from BIP-32 children, fed to the seeded keygen APIs.
// Classical algorithms use the plain 32-byte derived seed.
let seed = match alg {
CryptoAlg::MlDsa65 => {
crate::pq_crypto::derive_pq_seed_from_path(mnemonic_phrase, path, 32)?
}
CryptoAlg::SlhDsa128s => {
crate::pq_crypto::derive_pq_seed_from_path(mnemonic_phrase, path, 48)?
}
CryptoAlg::MlKem768 => {
crate::pq_crypto::derive_pq_seed_from_path(mnemonic_phrase, path, 64)?
}
_ => crate::pq_crypto::derive_seed_from_mnemonic(mnemonic_phrase, path)?.to_vec(),
};
match alg {
CryptoAlg::Secp256k1 => {
@@ -111,19 +131,19 @@ fn derive_alg_key(
let bip39_seed = nips::nip006::mnemonic_to_seed(mnemonic_phrase, "");
let (master_key, master_chain_code) = nips::nip006::bip32_master_key(&bip39_seed);
let path_indices = nips::nip006::parse_bip44_path(path)
.map_err(|_| NsignerError::KeyDerivationFailed)?;
.map_err(|_| SignerError::KeyDerivationFailed)?;
let (derived_key, _) = nips::nip006::bip32_derive_path(
&master_key,
&master_chain_code,
&path_indices,
)
.map_err(|_| NsignerError::KeyDerivationFailed)?;
.map_err(|_| SignerError::KeyDerivationFailed)?;
let mut priv_arr = [0u8; 32];
priv_arr.copy_from_slice(&derived_key);
let sk = nostr_core::types::SecretKey::from_bytes(priv_arr);
let pk = nostr_core::crypto::keys::public_key_from_secret_key(&sk)
.map_err(|_| NsignerError::CryptoFailed)?;
.map_err(|_| SignerError::CryptoFailed)?;
let pubkey_hex = hex::encode(pk.as_bytes());
let key_id = if pubkey_hex.len() >= 16 {
@@ -149,7 +169,9 @@ fn derive_alg_key(
})
}
CryptoAlg::Ed25519 => {
let (priv_bytes, pub_bytes) = crate::pq_crypto::ed25519_keygen_from_seed(&seed);
let seed_arr: [u8; 32] =
seed.as_slice().try_into().map_err(|_| SignerError::KeyDerivationFailed)?;
let (priv_bytes, pub_bytes) = crate::pq_crypto::ed25519_keygen_from_seed(&seed_arr);
let pubkey_hex = hex::encode(&pub_bytes);
let key_id = if pubkey_hex.len() >= 16 {
pubkey_hex[..16].to_string()
@@ -174,7 +196,9 @@ fn derive_alg_key(
})
}
CryptoAlg::X25519 => {
let (priv_bytes, pub_bytes) = crate::pq_crypto::x25519_keygen_from_seed(&seed);
let seed_arr: [u8; 32] =
seed.as_slice().try_into().map_err(|_| SignerError::KeyDerivationFailed)?;
let (priv_bytes, pub_bytes) = crate::pq_crypto::x25519_keygen_from_seed(&seed_arr);
let pubkey_hex = hex::encode(&pub_bytes);
let key_id = if pubkey_hex.len() >= 16 {
pubkey_hex[..16].to_string()
@@ -198,10 +222,52 @@ fn derive_alg_key(
valid: true,
})
}
CryptoAlg::MlDsa65 | CryptoAlg::SlhDsa128s | CryptoAlg::MlKem768 => {
// PQ algorithms — TODO: Phase 13
Err(NsignerError::NotYetImplemented)
CryptoAlg::MlDsa65 => {
let seed_arr: [u8; 32] =
seed.as_slice().try_into().map_err(|_| SignerError::KeyDerivationFailed)?;
let (priv_bytes, pub_bytes) = crate::pq_crypto::ml_dsa_65_keygen_from_seed(&seed_arr)?;
finish_pq_entry(alg, index, sizes, priv_bytes, pub_bytes)
}
CryptoAlg::Unknown => Err(NsignerError::InvalidInput),
CryptoAlg::SlhDsa128s => {
let (priv_bytes, pub_bytes) = crate::pq_crypto::slh_dsa_128s_keygen_from_seed(&seed)?;
finish_pq_entry(alg, index, sizes, priv_bytes, pub_bytes)
}
CryptoAlg::MlKem768 => {
let (priv_bytes, pub_bytes) = crate::pq_crypto::ml_kem_768_keygen_from_seed(&seed)?;
finish_pq_entry(alg, index, sizes, priv_bytes, pub_bytes)
}
CryptoAlg::Unknown => Err(SignerError::InvalidInput),
}
}
/// Build an `AlgKeyEntry` from PQ keygen output (seed-form private key).
fn finish_pq_entry(
alg: CryptoAlg,
index: i32,
sizes: crate::pq_crypto::CryptoAlgSizes,
priv_bytes: Vec<u8>,
pub_bytes: Vec<u8>,
) -> Result<AlgKeyEntry, SignerError> {
let pubkey_hex = hex::encode(&pub_bytes);
let key_id = if pubkey_hex.len() >= 16 {
pubkey_hex[..16].to_string()
} else {
pubkey_hex.clone()
};
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
priv_buf.copy_from(&priv_bytes);
let mut pub_buf = SecureBuf::alloc(sizes.pub_key_len)?;
pub_buf.copy_from(&pub_bytes);
Ok(AlgKeyEntry {
alg,
index,
private_key: priv_buf,
public_key: pub_buf,
pubkey_hex,
key_id,
valid: true,
})
}
+4 -4
View File
@@ -132,17 +132,17 @@ pub fn verify_request(
return Err((AUTH_ERR_KIND_INVALID, "auth_kind_invalid"));
}
// Extract tags: nsigner_rpc, nsigner_method, nsigner_body_hash
// Extract tags: signer_rpc, signer_method, signer_body_hash
let mut tag_rpc: Option<String> = None;
let mut tag_method: Option<String> = None;
let mut tag_body_hash: Option<String> = None;
for tag in &auth_event.tags {
if tag.kind() == "nsigner_rpc" {
if tag.kind() == "signer_rpc" {
tag_rpc = tag.get(1).map(|s| s.to_string());
} else if tag.kind() == "nsigner_method" {
} else if tag.kind() == "signer_method" {
tag_method = tag.get(1).map(|s| s.to_string());
} else if tag.kind() == "nsigner_body_hash" {
} else if tag.kind() == "signer_body_hash" {
tag_body_hash = tag.get(1).map(|s| s.to_string());
}
}
+8 -8
View File
@@ -1,6 +1,6 @@
# `signer-client` — Rust CLI for nsigner
# `signer-client` — Rust CLI for signer
A standalone command-line client that connects to a running [`nsigner`](../..)
A standalone command-line client that connects to a running [`signer`](../..)
process and calls its JSON-RPC verbs over stdin/stdout. Designed for
pipe-to-`nak` workflows. Rust port of the C
[`n_signer_client.c`](../../n_signer/client/n_signer_client.c).
@@ -62,7 +62,7 @@ signer-client [global options] <verb> [verb args...]
| Verb | stdout |
|------|--------|
| `list` | Lists running nsigner abstract sockets (one per line) |
| `list` | Lists running signer abstract sockets (one per line) |
### Metadata
@@ -140,10 +140,10 @@ signer-client get-info
| Transport | Flag | Notes |
|-----------|------|-------|
| UNIX abstract socket | `--socket-name <name>` or auto-discover | Default. Auto-discovers if exactly one `nsigner*` socket exists. |
| UNIX abstract socket | `--socket-name <name>` or auto-discover | Default. Auto-discovers if exactly one `signer*` socket exists. |
| TCP | `--tcp <host:port>` | Requires `--auth-privkey` for auth envelope. |
| Serial (USB CDC-ACM) | `--serial <device>` | e.g. `--serial /dev/ttyACM0` |
| Qubes qrexec | `--qrexec <qube:service>` | e.g. `--qrexec sys-signer:qubes.NsignerRpc` |
| Qubes qrexec | `--qrexec <qube:service>` | e.g. `--qrexec sys-signer:qubes.SignerRpc` |
## Exit codes
@@ -170,11 +170,11 @@ signer-client get-info
| [`main.rs`](main.rs:1) | Entry point, CLI parse, verb dispatch, stdin/stdout |
| [`cli.rs`](cli.rs:1) | clap `Cli` / `Verb` structs + usage text |
| [`transport.rs`](transport.rs:1) | `ClientTransport` enum (Unix/Tcp/Serial/Qrexec) |
| [`rpc.rs`](rpc.rs:1) | Low-level `NsignerClient` (framed JSON-RPC send/recv) |
| [`signer.rs`](signer.rs:1) | High-level `NsignerSigner` typed-verb wrappers |
| [`rpc.rs`](rpc.rs:1) | Low-level `SignerClient` (framed JSON-RPC send/recv) |
| [`signer.rs`](signer.rs:1) | High-level `SignerSigner` typed-verb wrappers |
| [`auth.rs`](auth.rs:1) | Client-side auth envelope builder (NIP-42 kind 22242) |
## See also
- [`plans/signer_client_plan.md`](../../plans/signer_client_plan.md:1) — implementation plan
- [`README.md`](../../README.md:1) — nsigner main documentation
- [`README.md`](../../README.md:1) — signer main documentation
+7 -7
View File
@@ -1,13 +1,13 @@
//! Client-side auth envelope builder for TCP/qrexec transports.
//!
//! Builds a NIP-42 kind-22242 auth event matching the wire shape verified by
//! [`nsigner::auth_envelope::verify_request`]. The event is attached as a
//! [`signer::auth_envelope::verify_request`]. The event is attached as a
//! top-level `"auth"` field on the JSON-RPC request.
//!
//! Tag contract (must match the server verifier):
//! - `["nsigner_rpc", <request id>]`
//! - `["nsigner_method", <method>]`
//! - `["nsigner_body_hash", <hex sha256 of compact params JSON>]`
//! - `["signer_rpc", <request id>]`
//! - `["signer_method", <method>]`
//! - `["signer_body_hash", <hex sha256 of compact params JSON>]`
//! - `content` = auth label
use nostr_core::types::{Event, Kind, SecretKey, Tag};
@@ -46,9 +46,9 @@ pub fn build_auth_event(
// Tags (two-element: kind + value)
let tags = vec![
Tag::with_value("nsigner_rpc", request_id),
Tag::with_value("nsigner_method", method),
Tag::with_value("nsigner_body_hash", &body_hash_hex),
Tag::with_value("signer_rpc", request_id),
Tag::with_value("signer_method", method),
Tag::with_value("signer_body_hash", &body_hash_hex),
];
let created_at = std::time::SystemTime::now()
+54 -4
View File
@@ -6,8 +6,58 @@ use clap::{Parser, Subcommand};
#[derive(Parser, Debug)]
#[command(
name = "signer-client",
version = nsigner::VERSION,
about = "Standalone CLI for the nsigner JSON-RPC API"
version = ::signer::VERSION,
about = "Standalone CLI for the signer JSON-RPC API",
after_help = "EXAMPLES:
# List running signer sockets
signer-client list
# Get signer metadata (auto-discovers the single running socket)
signer-client get-info
# Get a Nostr public key by role + path
signer-client --role main --path \"m/44'/1237'/0'/0/0\" get-public-key
# Get a public key by algorithm + index
signer-client --algorithm ed25519 --index 0 get-public-key
# Sign a raw message (hex) with ed25519
signer-client --algorithm ed25519 --index 0 sign 68656c6c6f
# Verify a signature (exit 0 = valid, 1 = invalid)
signer-client --algorithm ed25519 --index 0 verify <msg-hex> <sig-hex>
# Sign a Nostr event from stdin and pipe to nak for publishing
echo '{\"kind\":1,\"content\":\"hello nostr\",\"tags\":[],\"created_at\":1700000000}' \\
| signer-client --role main --path \"m/44'/1237'/0'/0/0\" sign-event \\
| nak publish
# NIP-44 encrypt a message to a peer
signer-client --role main --path \"m/44'/1237'/0'/0/0\" nip44-encrypt <peer-pubkey> 'secret'
# NIP-44 decrypt
signer-client --role main --path \"m/44'/1237'/0'/0/0\" nip44-decrypt <peer-pubkey> <ciphertext>
# Derive an HMAC digest (secp256k1)
signer-client --algorithm secp256k1 --index 0 derive testdata
# Raw JSON-RPC passthrough
echo '[]' | signer-client call get_info
# Explicit socket name
signer-client -n signer01 get-info
# TCP transport (requires auth envelope privkey)
signer-client --tcp host:port --auth-privkey <64-hex> get-info
# Serial transport (USB CDC-ACM)
signer-client --serial /dev/ttyACM0 get-info
# Qubes qrexec transport
signer-client --qrexec sys-signer:qubes.SignerRpc get-info
Run 'signer-client <verb> --help' for verb-specific details."
)]
pub struct Cli {
/// Abstract socket name (without @ prefix). Default: auto-discover.
@@ -89,7 +139,7 @@ pub struct Cli {
/// Verb subcommands.
#[derive(Subcommand, Debug)]
pub enum Verb {
/// List running nsigner abstract sockets.
/// List running signer abstract sockets.
List,
/// Get signer metadata.
@@ -227,7 +277,7 @@ pub fn print_usage(prog: &str) {
\x20 --timeout-sec <N> Mining timeout in seconds\n\
\n\
Verbs:\n\
\x20 list List running nsigner sockets\n\
\x20 list List running signer sockets\n\
\x20 get-info\n\
\x20 get-public-key\n\
\x20 sign-event\n\
+6 -6
View File
@@ -1,7 +1,7 @@
//! signer-client — standalone CLI for the nsigner JSON-RPC API.
//! signer-client — standalone CLI for the signer JSON-RPC API.
//!
//! Port of the C [`n_signer_client.c`](../../n_signer/client/n_signer_client.c).
//! Connects to a running `nsigner` process over its framed transports
//! Connects to a running `signer` process over its framed transports
//! (Unix / TCP / serial / qrexec) and exposes the full verb surface over
//! stdin/stdout so that signed events can be piped directly into `nak publish`.
@@ -13,7 +13,7 @@ mod transport;
use clap::Parser;
use cli::{Cli, Verb};
use signer::{result_to_line, AlgOptions, NsignerSigner};
use crate::signer::{result_to_line, AlgOptions, SignerSigner};
fn main() {
let cli = match Cli::try_parse() {
@@ -31,9 +31,9 @@ fn main() {
fn run(cli: Cli) -> i32 {
// ── list verb (no connection needed) ──────────────────────────────
if matches!(cli.verb, Verb::List) {
let sockets = nsigner::socket_name::list_sockets();
let sockets = ::signer::socket_name::list_sockets();
if sockets.is_empty() {
println!("no nsigner sockets found");
println!("no signer sockets found");
} else {
for name in &sockets {
println!("{}", name);
@@ -92,7 +92,7 @@ fn run(cli: Cli) -> i32 {
return 2;
}
let mut signer = NsignerSigner::new(rpc::NsignerClient::new(transport));
let mut signer = SignerSigner::new(rpc::SignerClient::new(transport));
// ── set selector for nostr verbs ──────────────────────────────────
if is_nostr_verb && !is_algorithm_verb {
+7 -7
View File
@@ -1,6 +1,6 @@
//! Low-level JSON-RPC 2.0 client — framed send/recv over a transport.
//!
//! Port of the C `nsigner_client_t` / `nsigner_client_call`. Builds a
//! Port of the C `signer_client_t` / `signer_client_call`. Builds a
//! `{"id","method","params"}` request, sends it framed, receives the framed
//! response, and splits `result` vs `error`.
@@ -8,17 +8,17 @@ use serde_json::{json, Value};
use super::transport::ClientTransport;
/// Low-level nsigner RPC client. Owns the transport.
pub struct NsignerClient {
/// Low-level signer RPC client. Owns the transport.
pub struct SignerClient {
transport: ClientTransport,
last_error: String,
next_id: u64,
}
impl NsignerClient {
impl SignerClient {
/// Wrap an open transport.
pub fn new(transport: ClientTransport) -> Self {
NsignerClient {
SignerClient {
transport,
last_error: String::new(),
next_id: 1,
@@ -115,7 +115,7 @@ mod tests {
#[test]
fn test_extract_result_success() {
let mut client = NsignerClient {
let mut client = SignerClient {
transport: ClientTransport::Unix(
std::os::unix::net::UnixStream::pair().unwrap().0,
),
@@ -129,7 +129,7 @@ mod tests {
#[test]
fn test_extract_result_error() {
let mut client = NsignerClient {
let mut client = SignerClient {
transport: ClientTransport::Unix(
std::os::unix::net::UnixStream::pair().unwrap().0,
),
+8 -8
View File
@@ -1,12 +1,12 @@
//! High-level typed-verb layer — one method per JSON-RPC verb.
//!
//! Port of the C `nostr_signer_t` wrappers. Each method builds the correct
//! `params` array + options object, calls the low-level [`NsignerClient`],
//! `params` array + options object, calls the low-level [`SignerClient`],
//! and parses the typed result.
use serde_json::{json, Value};
use super::rpc::NsignerClient;
use super::rpc::SignerClient;
/// Selector state for nostr verbs.
#[derive(Debug, Clone, Default)]
@@ -58,18 +58,18 @@ impl AlgOptions {
}
}
/// High-level nsigner signer. Wraps a low-level client and holds selector +
/// High-level signer signer. Wraps a low-level client and holds selector +
/// auth state used across typed verbs.
pub struct NsignerSigner {
pub client: NsignerClient,
pub struct SignerSigner {
pub client: SignerClient,
pub selector: Selector,
pub auth_privkey: Option<String>,
pub auth_label: String,
}
impl NsignerSigner {
pub fn new(client: NsignerClient) -> Self {
NsignerSigner {
impl SignerSigner {
pub fn new(client: SignerClient) -> Self {
SignerSigner {
client,
selector: Selector::default(),
auth_privkey: None,
+14 -14
View File
@@ -1,8 +1,8 @@
//! Client transport — unified framed I/O over Unix / TCP / Serial / Qrexec.
//!
//! Port of the C `nsigner_transport_t` vtable. All four transports share the
//! Port of the C `signer_transport_t` vtable. All four transports share the
//! same `send_framed` / `recv_framed` path after construction (defined in
//! [`nsigner::transport`]).
//! [`::signer::transport`]).
use std::io;
use std::process::{Child, ChildStdin, ChildStdout};
@@ -27,7 +27,7 @@ pub enum ClientTransport {
impl ClientTransport {
/// Open a Unix abstract-socket transport by name (without `@`).
pub fn open_unix(name: &str, _timeout_ms: u64) -> io::Result<Self> {
let stream = nsigner::transport::connect_abstract_unix(name)?;
let stream = ::signer::transport::connect_abstract_unix(name)?;
Ok(ClientTransport::Unix(stream))
}
@@ -76,11 +76,11 @@ impl ClientTransport {
/// Send a framed JSON message.
pub fn send(&mut self, payload: &str) -> io::Result<()> {
match self {
ClientTransport::Unix(s) => nsigner::transport::send_framed(s, payload),
ClientTransport::Tcp(s) => nsigner::transport::send_framed(s, payload),
ClientTransport::Serial(f) => nsigner::transport::send_framed(f, payload),
ClientTransport::Unix(s) => ::signer::transport::send_framed(s, payload),
ClientTransport::Tcp(s) => ::signer::transport::send_framed(s, payload),
ClientTransport::Serial(f) => ::signer::transport::send_framed(f, payload),
ClientTransport::Qrexec { stdin, .. } => {
nsigner::transport::send_framed(stdin, payload)
::signer::transport::send_framed(stdin, payload)
}
}
}
@@ -88,11 +88,11 @@ impl ClientTransport {
/// Receive a framed JSON message.
pub fn recv(&mut self) -> io::Result<String> {
match self {
ClientTransport::Unix(s) => nsigner::transport::recv_framed(s),
ClientTransport::Tcp(s) => nsigner::transport::recv_framed(s),
ClientTransport::Serial(f) => nsigner::transport::recv_framed(f),
ClientTransport::Unix(s) => ::signer::transport::recv_framed(s),
ClientTransport::Tcp(s) => ::signer::transport::recv_framed(s),
ClientTransport::Serial(f) => ::signer::transport::recv_framed(f),
ClientTransport::Qrexec { stdout, .. } => {
nsigner::transport::recv_framed(stdout)
::signer::transport::recv_framed(stdout)
}
}
}
@@ -179,13 +179,13 @@ pub fn open_from_cli(
}
// Auto-discover: enumerate abstract UNIX sockets.
let sockets = nsigner::socket_name::list_sockets();
let sockets = ::signer::socket_name::list_sockets();
if sockets.is_empty() {
return Err("no nsigner sockets found. Is nsigner running?".into());
return Err("no signer sockets found. Is signer running?".into());
}
if sockets.len() > 1 {
let mut msg = String::from(
"multiple nsigner sockets found. Use --socket-name to select one:\n",
"multiple signer sockets found. Use --socket-name to select one:\n",
);
for n in &sockets {
msg.push_str(&format!(" {}\n", n));
+236 -19
View File
@@ -11,7 +11,7 @@ use crate::mnemonic::MnemonicState;
use crate::pq_crypto::CryptoAlg;
use crate::role_table::RoleTable;
use crate::selector::{selector_resolve, SelectorRequest};
use crate::NsignerError;
use crate::SignerError;
use serde_json::{json, Value};
@@ -179,7 +179,7 @@ fn handle_algorithm_verb(
}
let priv_slice = key_entry.private_key.as_slice();
let sig = sign_with_alg(alg, &priv_slice[..32].try_into().unwrap(), &msg_bytes);
let sig = sign_with_alg(alg, priv_slice, &msg_bytes);
match sig {
Ok(s) => {
let sig_hex = hex::encode(&s);
@@ -286,13 +286,79 @@ fn handle_algorithm_verb(
}
enforcement::VERB_ENCAPSULATE => {
// ML-KEM-768 only — TODO: Phase 13
make_error_response(id, RpcError::NOT_YET_IMPLEMENTED)
// ML-KEM-768 only (enforced by enforce_verb_algorithm).
// params[0] = peer public key hex (1184 bytes → 2368 hex chars).
let pub_hex = match params.first().and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let pub_bytes = match hex::decode(pub_hex) {
Ok(b) => b,
Err(_) => return make_error_response(
id,
RpcError { code: -32602, message: "invalid_pubkey_hex" },
),
};
let sizes = alg.sizes().unwrap();
if pub_bytes.len() != sizes.pub_key_len {
return make_error_response(
id,
RpcError { code: -32602, message: "invalid_pubkey_length" },
);
}
match crate::pq_crypto::ml_kem_768_encaps(&pub_bytes) {
Ok((ct, ss)) => {
let result = json!({
"ciphertext": hex::encode(&ct),
"shared_secret": hex::encode(&ss),
"algorithm": "ml-kem-768",
});
make_success_response(id, &result.to_string())
}
Err(_) => make_error_response(
id,
RpcError { code: -32602, message: "encaps_failed" },
),
}
}
enforcement::VERB_DECAPSULATE => {
// ML-KEM-768 only — TODO: Phase 13
make_error_response(id, RpcError::NOT_YET_IMPLEMENTED)
// ML-KEM-768 only (enforced by enforce_verb_algorithm).
// params[0] = ciphertext hex (1088 bytes → 2176 hex chars).
let ct_hex = match params.first().and_then(|v| v.as_str()) {
Some(s) => s,
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
let ct_bytes = match hex::decode(ct_hex) {
Ok(b) => b,
Err(_) => return make_error_response(
id,
RpcError { code: -32602, message: "invalid_ciphertext_hex" },
),
};
let sizes = alg.sizes().unwrap();
if ct_bytes.len() != sizes.ciphertext_len {
return make_error_response(
id,
RpcError { code: -32602, message: "invalid_ciphertext_length" },
);
}
let priv_slice = key_entry.private_key.as_slice();
match crate::pq_crypto::ml_kem_768_decaps(priv_slice, &ct_bytes) {
Ok(ss) => {
let result = json!({
"shared_secret": hex::encode(&ss),
"algorithm": "ml-kem-768",
});
make_success_response(id, &result.to_string())
}
Err(_) => make_error_response(
id,
RpcError { code: -32602, message: "decaps_failed" },
),
}
}
_ => make_error_response(id, RpcError::METHOD_NOT_FOUND),
@@ -359,9 +425,16 @@ fn handle_nostr_verb(
// Ensure key is derived. For variable-path roles, use the concrete
// path supplied by the client; for fixed-path roles, use the stored
// template.
if !role.derived {
let has_variable = role.has_variable_path();
// template. Variable-path roles are re-derived whenever the requested
// path differs from the currently-derived one (the cache is per-path,
// not per-role).
let has_variable = role.has_variable_path();
let needs_derive = if has_variable && sel.has_role_path {
role.derived_path.as_deref() != Some(sel.role_path.as_str())
} else {
!role.derived
};
if needs_derive {
let result = if has_variable && sel.has_role_path {
ctx.key_store
.derive_one_with_path(ctx.role_table, ctx.mnemonic, role_index, &sel.role_path)
@@ -414,7 +487,12 @@ fn handle_nostr_verb(
None => return make_error_response(id, RpcError::INVALID_PARAMS),
};
match ctx.key_store.sign_event(role_index, event_json) {
Ok(signed) => make_success_response(id, &format!("\"{}\"", signed)),
// The signed event is itself JSON; serialize it as a proper
// JSON string value so embedded quotes are escaped.
Ok(signed) => {
let wrapped = serde_json::to_string(&signed).unwrap_or_else(|_| "\"\"".into());
make_success_response(id, &wrapped)
}
Err(_) => make_error_response(id, RpcError::INVALID_PARAMS),
}
}
@@ -551,23 +629,31 @@ fn is_nostr_verb(verb: &str) -> bool {
)
}
fn sign_with_alg(alg: CryptoAlg, priv_key: &[u8; 32], msg: &[u8]) -> Result<Vec<u8>, NsignerError> {
fn sign_with_alg(alg: CryptoAlg, priv_key: &[u8], msg: &[u8]) -> Result<Vec<u8>, SignerError> {
match alg {
CryptoAlg::Secp256k1 => {
if priv_key.len() != 32 {
return Err(SignerError::CryptoFailed);
}
let arr: [u8; 32] = priv_key.try_into().unwrap();
// Check for scheme option (schnorr default, ecdsa alternative)
// For now, default to schnorr
let sk = nostr_core::types::SecretKey::from_bytes(*priv_key);
let sk = nostr_core::types::SecretKey::from_bytes(arr);
let digest = nostr_core::crypto::sha256::sha256(msg);
let sig = nostr_core::crypto::keys::schnorr_sign(&sk, &digest)?;
Ok(sig.as_bytes().to_vec())
}
CryptoAlg::Ed25519 => {
let sig = crate::pq_crypto::ed25519_sign(priv_key, msg);
if priv_key.len() != 32 {
return Err(SignerError::CryptoFailed);
}
let arr: [u8; 32] = priv_key.try_into().unwrap();
let sig = crate::pq_crypto::ed25519_sign(&arr, msg);
Ok(sig.to_vec())
}
CryptoAlg::MlDsa65 => crate::pq_crypto::ml_dsa_65_sign(priv_key, msg),
CryptoAlg::SlhDsa128s => crate::pq_crypto::slh_dsa_128s_sign(priv_key, msg),
_ => Err(NsignerError::CryptoFailed),
_ => Err(SignerError::CryptoFailed),
}
}
@@ -726,11 +812,6 @@ mod tests {
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
let msg_hex = hex::encode(b"hello world");
let sign_req = format!(
r#"{{"id":"5","method":"sign","params":["{}"],{{"algorithm":"ed25519","index":0}}}}"#,
msg_hex
);
// Fix JSON format
let sign_req = format!(
r#"{{"id":"5","method":"sign","params":["{}",{{"algorithm":"ed25519","index":0}}]}}"#,
msg_hex
@@ -761,4 +842,140 @@ mod tests {
let resp = handle_request(&mut ctx, req);
assert!(resp.contains("\"error\""));
}
// ── PQ algorithm verbs (v2 seeded derivation) ────────────────────
#[test]
fn test_ml_dsa_65_sign_verify() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
// get_public_key must succeed (was key_derivation_failed before).
let req = r#"{"id":"p1","method":"get_public_key","params":[{"algorithm":"ml-dsa-65","index":0}]}"#;
let resp = handle_request(&mut ctx, req);
assert!(resp.contains("\"result\""), "get_public_key: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let pub_hex = resp_json["result"]["public_key"].as_str().unwrap();
assert_eq!(pub_hex.len(), 1952 * 2);
// sign
let msg_hex = hex::encode(b"hello world");
let sign_req = format!(
r#"{{"id":"p2","method":"sign","params":["{}",{{"algorithm":"ml-dsa-65","index":0}}]}}"#,
msg_hex
);
let resp = handle_request(&mut ctx, &sign_req);
assert!(resp.contains("\"result\""), "sign: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let sig_hex = resp_json["result"]["signature"].as_str().unwrap();
assert_eq!(sig_hex.len(), 3309 * 2);
// verify (valid)
let verify_req = format!(
r#"{{"id":"p3","method":"verify","params":["{}","{}",{{"algorithm":"ml-dsa-65","index":0}}]}}"#,
msg_hex, sig_hex
);
let resp = handle_request(&mut ctx, &verify_req);
assert!(resp.contains("\"valid\":true"), "verify: {}", resp);
// verify (wrong message)
let verify_req = format!(
r#"{{"id":"p4","method":"verify","params":["{}","{}",{{"algorithm":"ml-dsa-65","index":0}}]}}"#,
hex::encode(b"wrong message"),
sig_hex
);
let resp = handle_request(&mut ctx, &verify_req);
assert!(resp.contains("\"valid\":false"), "verify wrong: {}", resp);
}
#[test]
fn test_slh_dsa_128s_sign_verify() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
let req = r#"{"id":"s1","method":"get_public_key","params":[{"algorithm":"slh-dsa-128s","index":0}]}"#;
let resp = handle_request(&mut ctx, req);
assert!(resp.contains("\"result\""), "get_public_key: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let pub_hex = resp_json["result"]["public_key"].as_str().unwrap();
assert_eq!(pub_hex.len(), 32 * 2);
let msg_hex = hex::encode(b"hello world");
let sign_req = format!(
r#"{{"id":"s2","method":"sign","params":["{}",{{"algorithm":"slh-dsa-128s","index":0}}]}}"#,
msg_hex
);
let resp = handle_request(&mut ctx, &sign_req);
assert!(resp.contains("\"result\""), "sign: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let sig_hex = resp_json["result"]["signature"].as_str().unwrap();
assert_eq!(sig_hex.len(), 7856 * 2);
let verify_req = format!(
r#"{{"id":"s3","method":"verify","params":["{}","{}",{{"algorithm":"slh-dsa-128s","index":0}}]}}"#,
msg_hex, sig_hex
);
let resp = handle_request(&mut ctx, &verify_req);
assert!(resp.contains("\"valid\":true"), "verify: {}", resp);
}
#[test]
fn test_ml_kem_768_encaps_decaps() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
// Derive our ML-KEM keypair to get a public key to encapsulate to.
let req = r#"{"id":"k1","method":"get_public_key","params":[{"algorithm":"ml-kem-768","index":0}]}"#;
let resp = handle_request(&mut ctx, req);
assert!(resp.contains("\"result\""), "get_public_key: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let pub_hex = resp_json["result"]["public_key"].as_str().unwrap();
assert_eq!(pub_hex.len(), 1184 * 2);
// encapsulate
let enc_req = format!(
r#"{{"id":"k2","method":"encapsulate","params":["{}",{{"algorithm":"ml-kem-768","index":0}}]}}"#,
pub_hex
);
let resp = handle_request(&mut ctx, &enc_req);
assert!(resp.contains("\"result\""), "encapsulate: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let ct_hex = resp_json["result"]["ciphertext"].as_str().unwrap();
let ss_hex = resp_json["result"]["shared_secret"].as_str().unwrap();
assert_eq!(ct_hex.len(), 1088 * 2);
assert_eq!(ss_hex.len(), 32 * 2);
// decapsulate
let dec_req = format!(
r#"{{"id":"k3","method":"decapsulate","params":["{}",{{"algorithm":"ml-kem-768","index":0}}]}}"#,
ct_hex
);
let resp = handle_request(&mut ctx, &dec_req);
assert!(resp.contains("\"result\""), "decapsulate: {}", resp);
let resp_json: Value = serde_json::from_str(&resp).unwrap();
let ss2_hex = resp_json["result"]["shared_secret"].as_str().unwrap();
assert_eq!(ss_hex, ss2_hex, "shared secrets must match");
}
#[test]
fn test_ml_kem_768_encaps_bad_pubkey_length() {
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
let req = r#"{"id":"k4","method":"encapsulate","params":["00ff",{"algorithm":"ml-kem-768","index":0}]}"#;
let resp = handle_request(&mut ctx, req);
assert!(resp.contains("invalid_pubkey_length"), "resp: {}", resp);
}
#[test]
fn test_pq_key_derivation_deterministic() {
// Same mnemonic + index → same pubkey across dispatcher calls.
let (mut table, mnemonic, mut store, mut cache) = setup();
let mut ctx = make_ctx(&mut table, &mnemonic, &mut store, &mut cache);
let req = r#"{"id":"d1","method":"get_public_key","params":[{"algorithm":"ml-dsa-65","index":0}]}"#;
let resp1 = handle_request(&mut ctx, req);
let resp2 = handle_request(&mut ctx, req);
assert_eq!(resp1, resp2);
}
}
+9 -9
View File
@@ -1,13 +1,13 @@
//! Error types for nsigner.
//! Error types for signer.
//!
//! JSON-RPC error codes are preserved exactly for wire compatibility
//! with the C n_signer.
use thiserror::Error;
/// nsigner-specific errors (internal operations).
/// signer-specific errors (internal operations).
#[derive(Error, Debug, Clone)]
pub enum NsignerError {
pub enum SignerError {
#[error("invalid input")]
InvalidInput,
#[error("memory allocation failed (mlock)")]
@@ -32,13 +32,13 @@ pub enum NsignerError {
Internal(String),
}
impl From<nostr_core::error::NostrError> for NsignerError {
impl From<nostr_core::error::NostrError> for SignerError {
fn from(e: nostr_core::error::NostrError) -> Self {
// Map NostrError to NsignerError
// Map NostrError to SignerError
match e {
nostr_core::error::NostrError::InvalidInput => NsignerError::InvalidInput,
nostr_core::error::NostrError::CryptoFailed => NsignerError::CryptoFailed,
_ => NsignerError::CryptoFailed,
nostr_core::error::NostrError::InvalidInput => SignerError::InvalidInput,
nostr_core::error::NostrError::CryptoFailed => SignerError::CryptoFailed,
_ => SignerError::CryptoFailed,
}
}
}
@@ -60,7 +60,7 @@ impl RpcError {
pub const INVALID_PARAMS: Self = RpcError { code: -32602, message: "invalid_params" };
pub const INTERNAL_ERROR: Self = RpcError { code: -32603, message: "internal_error" };
// ── nsigner-specific errors ──────────────────────────────────────
// ── signer-specific errors ──────────────────────────────────────
pub const AMBIGUOUS_ROLE_SELECTOR: Self = RpcError { code: 1001, message: "ambiguous_role_selector" };
pub const UNKNOWN_ROLE: Self = RpcError { code: 1002, message: "unknown_role" };
pub const NO_DEFAULT_ROLE: Self = RpcError { code: 1003, message: "no_default_role" };
+1 -1
View File
@@ -1,4 +1,4 @@
//! Minimal HTTP/1.1 parser for nsigner's HTTP listener mode.
//! Minimal HTTP/1.1 parser for signer's HTTP listener mode.
//!
//! Port of `http_listener.c`. Only supports POST with a JSON body.
//! No chunked encoding, no keep-alive, one request per connection.
+118 -47
View File
@@ -7,7 +7,7 @@ use crate::mnemonic::MnemonicState;
use crate::pq_crypto::{self, CryptoAlg};
use crate::role_table::{self, RoleCurve, RoleEntry, RolePurpose, RoleTable};
use crate::secure_mem::SecureBuf;
use crate::NsignerError;
use crate::SignerError;
/// Per-role derived key material (stored in secure memory).
pub struct DerivedKey {
@@ -35,12 +35,12 @@ impl KeyStore {
&mut self,
table: &mut RoleTable,
mnemonic: &MnemonicState,
) -> Result<usize, NsignerError> {
) -> Result<usize, SignerError> {
if !mnemonic.is_loaded() {
return Err(NsignerError::MnemonicNotLoaded);
return Err(SignerError::MnemonicNotLoaded);
}
let phrase = mnemonic.phrase().ok_or(NsignerError::MnemonicNotLoaded)?;
let phrase = mnemonic.phrase().ok_or(SignerError::MnemonicNotLoaded)?;
self.keys.clear();
self.keys.resize_with(table.entries.len(), || None);
@@ -86,16 +86,16 @@ impl KeyStore {
table: &mut RoleTable,
mnemonic: &MnemonicState,
role_index: usize,
) -> Result<(), NsignerError> {
) -> Result<(), SignerError> {
if !mnemonic.is_loaded() {
return Err(NsignerError::MnemonicNotLoaded);
return Err(SignerError::MnemonicNotLoaded);
}
let phrase = mnemonic.phrase().ok_or(NsignerError::MnemonicNotLoaded)?;
let phrase = mnemonic.phrase().ok_or(SignerError::MnemonicNotLoaded)?;
let role = table
.entries
.get_mut(role_index)
.ok_or(NsignerError::InvalidInput)?;
.ok_or(SignerError::InvalidInput)?;
role.derived = false;
role.pubkey_hex.clear();
@@ -120,23 +120,25 @@ impl KeyStore {
mnemonic: &MnemonicState,
role_index: usize,
concrete_path: &str,
) -> Result<(), NsignerError> {
) -> Result<(), SignerError> {
if !mnemonic.is_loaded() {
return Err(NsignerError::MnemonicNotLoaded);
return Err(SignerError::MnemonicNotLoaded);
}
let phrase = mnemonic.phrase().ok_or(NsignerError::MnemonicNotLoaded)?;
let phrase = mnemonic.phrase().ok_or(SignerError::MnemonicNotLoaded)?;
let role = table
.entries
.get_mut(role_index)
.ok_or(NsignerError::InvalidInput)?;
.ok_or(SignerError::InvalidInput)?;
role.derived = false;
role.pubkey_hex.clear();
role.derived_path = None;
let dk = derive_for_role(concrete_path, role, phrase)?;
role.pubkey_hex = dk.pubkey_hex.clone();
role.derived = true;
role.derived_path = Some(concrete_path.to_string());
if self.keys.len() <= role_index {
self.keys.resize_with(role_index + 1, || None);
@@ -161,14 +163,14 @@ impl KeyStore {
&self,
role_index: usize,
event_json: &str,
) -> Result<String, NsignerError> {
) -> Result<String, SignerError> {
let priv_bytes = self
.get_private_key(role_index)
.ok_or(NsignerError::KeyDerivationFailed)?;
.ok_or(SignerError::KeyDerivationFailed)?;
// Parse the unsigned event from JSON
let event: nostr_core::types::Event =
serde_json::from_str(event_json).map_err(|_| NsignerError::InvalidInput)?;
serde_json::from_str(event_json).map_err(|_| SignerError::InvalidInput)?;
// Use NIP-01 to create and sign the event
let mut priv_arr = [0u8; 32];
@@ -182,9 +184,9 @@ impl KeyStore {
&sk,
event.created_at,
)
.map_err(|_| NsignerError::CryptoFailed)?;
.map_err(|_| SignerError::CryptoFailed)?;
serde_json::to_string(&signed).map_err(|_| NsignerError::InvalidInput)
serde_json::to_string(&signed).map_err(|_| SignerError::InvalidInput)
}
/// NIP-44 encrypt.
@@ -193,20 +195,20 @@ impl KeyStore {
role_index: usize,
recipient_pubkey_hex: &str,
plaintext: &str,
) -> Result<Vec<u8>, NsignerError> {
) -> Result<Vec<u8>, SignerError> {
let priv_bytes = self
.get_private_key(role_index)
.ok_or(NsignerError::KeyDerivationFailed)?;
.ok_or(SignerError::KeyDerivationFailed)?;
let recipient_pk: nostr_core::types::PublicKey = recipient_pubkey_hex
.parse()
.map_err(|_| NsignerError::InvalidInput)?;
.map_err(|_| SignerError::InvalidInput)?;
let mut priv_arr = [0u8; 32];
priv_arr.copy_from_slice(&priv_bytes[..32]);
let sk = nostr_core::types::SecretKey::from_bytes(priv_arr);
nostr_core::crypto::nip44::nip44_encrypt(&sk, &recipient_pk, plaintext.as_bytes())
.map_err(|_| NsignerError::CryptoFailed)
.map_err(|_| SignerError::CryptoFailed)
}
/// NIP-44 decrypt.
@@ -215,20 +217,20 @@ impl KeyStore {
role_index: usize,
sender_pubkey_hex: &str,
ciphertext: &[u8],
) -> Result<Vec<u8>, NsignerError> {
) -> Result<Vec<u8>, SignerError> {
let priv_bytes = self
.get_private_key(role_index)
.ok_or(NsignerError::KeyDerivationFailed)?;
.ok_or(SignerError::KeyDerivationFailed)?;
let sender_pk: nostr_core::types::PublicKey = sender_pubkey_hex
.parse()
.map_err(|_| NsignerError::InvalidInput)?;
.map_err(|_| SignerError::InvalidInput)?;
let mut priv_arr = [0u8; 32];
priv_arr.copy_from_slice(&priv_bytes[..32]);
let sk = nostr_core::types::SecretKey::from_bytes(priv_arr);
nostr_core::crypto::nip44::nip44_decrypt(&sk, &sender_pk, ciphertext)
.map_err(|_| NsignerError::CryptoFailed)
.map_err(|_| SignerError::CryptoFailed)
}
/// NIP-04 encrypt.
@@ -237,20 +239,20 @@ impl KeyStore {
role_index: usize,
recipient_pubkey_hex: &str,
plaintext: &str,
) -> Result<String, NsignerError> {
) -> Result<String, SignerError> {
let priv_bytes = self
.get_private_key(role_index)
.ok_or(NsignerError::KeyDerivationFailed)?;
.ok_or(SignerError::KeyDerivationFailed)?;
let recipient_pk: nostr_core::types::PublicKey = recipient_pubkey_hex
.parse()
.map_err(|_| NsignerError::InvalidInput)?;
.map_err(|_| SignerError::InvalidInput)?;
let mut priv_arr = [0u8; 32];
priv_arr.copy_from_slice(&priv_bytes[..32]);
let sk = nostr_core::types::SecretKey::from_bytes(priv_arr);
nips::nip004::nip04_encrypt(&sk, &recipient_pk, plaintext)
.map_err(|_| NsignerError::CryptoFailed)
.map_err(|_| SignerError::CryptoFailed)
}
/// NIP-04 decrypt.
@@ -259,20 +261,20 @@ impl KeyStore {
role_index: usize,
sender_pubkey_hex: &str,
ciphertext: &str,
) -> Result<String, NsignerError> {
) -> Result<String, SignerError> {
let priv_bytes = self
.get_private_key(role_index)
.ok_or(NsignerError::KeyDerivationFailed)?;
.ok_or(SignerError::KeyDerivationFailed)?;
let sender_pk: nostr_core::types::PublicKey = sender_pubkey_hex
.parse()
.map_err(|_| NsignerError::InvalidInput)?;
.map_err(|_| SignerError::InvalidInput)?;
let mut priv_arr = [0u8; 32];
priv_arr.copy_from_slice(&priv_bytes[..32]);
let sk = nostr_core::types::SecretKey::from_bytes(priv_arr);
nips::nip004::nip04_decrypt(&sk, &sender_pk, ciphertext)
.map_err(|_| NsignerError::CryptoFailed)
.map_err(|_| SignerError::CryptoFailed)
}
/// Zeroize all derived keys.
@@ -288,21 +290,28 @@ fn derive_for_role(
path: &str,
role: &RoleEntry,
mnemonic_phrase: &str,
) -> Result<DerivedKey, NsignerError> {
) -> Result<DerivedKey, SignerError> {
let alg = role_table::crypto_alg_from_role(role.curve, role.purpose);
// crypto_alg_from_role returns Unknown for OTP, but we skip OTP earlier
let alg = if alg == CryptoAlg::Unknown {
return Err(NsignerError::KeyDerivationFailed);
return Err(SignerError::KeyDerivationFailed);
} else {
alg
};
let sizes = alg
.sizes()
.ok_or(NsignerError::KeyDerivationFailed)?;
.ok_or(SignerError::KeyDerivationFailed)?;
// Derive the 32-byte seed from the mnemonic using the path
let seed = pq_crypto::derive_seed_from_mnemonic(mnemonic_phrase, path)?;
// PQ algorithms use the v2 seeded derivation: exact-length seed
// (32/48/64 B) from BIP-32 children, fed to the seeded keygen APIs.
// Classical algorithms use the plain 32-byte derived seed.
let seed = match alg {
CryptoAlg::MlDsa65 => pq_crypto::derive_pq_seed_from_path(mnemonic_phrase, path, 32)?,
CryptoAlg::SlhDsa128s => pq_crypto::derive_pq_seed_from_path(mnemonic_phrase, path, 48)?,
CryptoAlg::MlKem768 => pq_crypto::derive_pq_seed_from_path(mnemonic_phrase, path, 64)?,
_ => pq_crypto::derive_seed_from_mnemonic(mnemonic_phrase, path)?.to_vec(),
};
match alg {
CryptoAlg::Secp256k1 => {
@@ -310,19 +319,19 @@ fn derive_for_role(
let bip39_seed = nips::nip006::mnemonic_to_seed(mnemonic_phrase, "");
let (master_key, master_chain_code) = nips::nip006::bip32_master_key(&bip39_seed);
let path_indices = nips::nip006::parse_bip44_path(path)
.map_err(|_| NsignerError::KeyDerivationFailed)?;
.map_err(|_| SignerError::KeyDerivationFailed)?;
let (derived_key, _) = nips::nip006::bip32_derive_path(
&master_key,
&master_chain_code,
&path_indices,
)
.map_err(|_| NsignerError::KeyDerivationFailed)?;
.map_err(|_| SignerError::KeyDerivationFailed)?;
let mut priv_arr = [0u8; 32];
priv_arr.copy_from_slice(&derived_key);
let sk = nostr_core::types::SecretKey::from_bytes(priv_arr);
let pk = nostr_core::crypto::keys::public_key_from_secret_key(&sk)
.map_err(|_| NsignerError::CryptoFailed)?;
.map_err(|_| SignerError::CryptoFailed)?;
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
priv_buf.copy_from(&priv_arr);
@@ -343,7 +352,9 @@ fn derive_for_role(
})
}
CryptoAlg::Ed25519 => {
let (priv_bytes, pub_bytes) = pq_crypto::ed25519_keygen_from_seed(&seed);
let seed_arr: [u8; 32] =
seed.as_slice().try_into().map_err(|_| SignerError::KeyDerivationFailed)?;
let (priv_bytes, pub_bytes) = pq_crypto::ed25519_keygen_from_seed(&seed_arr);
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
priv_buf.copy_from(&priv_bytes);
@@ -363,7 +374,9 @@ fn derive_for_role(
})
}
CryptoAlg::X25519 => {
let (priv_bytes, pub_bytes) = pq_crypto::x25519_keygen_from_seed(&seed);
let seed_arr: [u8; 32] =
seed.as_slice().try_into().map_err(|_| SignerError::KeyDerivationFailed)?;
let (priv_bytes, pub_bytes) = pq_crypto::x25519_keygen_from_seed(&seed_arr);
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
priv_buf.copy_from(&priv_bytes);
@@ -382,11 +395,69 @@ fn derive_for_role(
valid: true,
})
}
CryptoAlg::MlDsa65 | CryptoAlg::SlhDsa128s | CryptoAlg::MlKem768 => {
// PQ algorithms — TODO: Phase 13
Err(NsignerError::NotYetImplemented)
CryptoAlg::MlDsa65 => {
let seed_arr: [u8; 32] =
seed.as_slice().try_into().map_err(|_| SignerError::KeyDerivationFailed)?;
let (priv_bytes, pub_bytes) = pq_crypto::ml_dsa_65_keygen_from_seed(&seed_arr)?;
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
priv_buf.copy_from(&priv_bytes);
let mut pub_buf = SecureBuf::alloc(sizes.pub_key_len)?;
pub_buf.copy_from(&pub_bytes);
let pubkey_hex = hex::encode(&pub_bytes);
Ok(DerivedKey {
private_key: priv_buf,
public_key: pub_buf,
pubkey_hex,
npub: String::new(),
alg,
valid: true,
})
}
CryptoAlg::Unknown => Err(NsignerError::KeyDerivationFailed),
CryptoAlg::SlhDsa128s => {
let (priv_bytes, pub_bytes) = pq_crypto::slh_dsa_128s_keygen_from_seed(&seed)?;
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
priv_buf.copy_from(&priv_bytes);
let mut pub_buf = SecureBuf::alloc(sizes.pub_key_len)?;
pub_buf.copy_from(&pub_bytes);
let pubkey_hex = hex::encode(&pub_bytes);
Ok(DerivedKey {
private_key: priv_buf,
public_key: pub_buf,
pubkey_hex,
npub: String::new(),
alg,
valid: true,
})
}
CryptoAlg::MlKem768 => {
let (priv_bytes, pub_bytes) = pq_crypto::ml_kem_768_keygen_from_seed(&seed)?;
let mut priv_buf = SecureBuf::alloc(sizes.priv_key_len)?;
priv_buf.copy_from(&priv_bytes);
let mut pub_buf = SecureBuf::alloc(sizes.pub_key_len)?;
pub_buf.copy_from(&pub_bytes);
let pubkey_hex = hex::encode(&pub_bytes);
Ok(DerivedKey {
private_key: priv_buf,
public_key: pub_buf,
pubkey_hex,
npub: String::new(),
alg,
valid: true,
})
}
CryptoAlg::Unknown => Err(SignerError::KeyDerivationFailed),
}
}
+3 -3
View File
@@ -1,4 +1,4 @@
//! # nsigner — Attended Nostr signing daemon
//! # signer — Attended Nostr signing daemon
//!
//! Rust port of the C-based `n_signer`. Holds signing key material in
//! locked memory and signs on request via a JSON-RPC 2.0 API over
@@ -28,7 +28,7 @@ pub mod socket_name;
pub mod tui;
pub mod error;
pub use error::NsignerError;
pub use error::SignerError;
/// Version string (matches C NSIGNER_VERSION).
pub const VERSION: &str = "v0.0.12";
pub const VERSION: &str = "v0.0.23";
+46 -42
View File
@@ -1,22 +1,22 @@
//! nsigner — attended Nostr signing daemon.
//! signer — attended Nostr signing daemon.
//!
//! Port of `main.c`. Single binary that holds signing key material in
//! locked memory and signs on request via JSON-RPC 2.0.
use clap::{Parser, Subcommand};
use nsigner::{
use signer::{
alg_cache::AlgorithmKeyCache,
dispatcher::DispatcherContext,
key_store::KeyStore,
mnemonic::MnemonicState,
role_table::{RoleCurve, RolePurpose, RoleTable},
server::{AuthMode, ListenMode, ServerContext},
NsignerError,
SignerError,
};
/// Command-line arguments.
#[derive(Parser, Debug)]
#[command(name = "nsigner", version = nsigner::VERSION, about = "Attended Nostr signing daemon")]
#[command(name = "signer", version = signer::VERSION, about = "Attended Nostr signing daemon")]
struct Cli {
/// Socket name (abstract namespace, without @ prefix)
#[arg(long, short = 'n', alias = "name")]
@@ -85,7 +85,7 @@ enum Commands {
to: Option<String>,
},
/// List running nsigner abstract sockets
/// List running signer abstract sockets
List,
}
@@ -111,7 +111,7 @@ fn main() {
match server_main(&cli) {
Ok(()) => {}
Err(e) => {
eprintln!("nsigner: {}", e);
eprintln!("signer: {}", e);
std::process::exit(1);
}
}
@@ -126,11 +126,11 @@ fn main() {
/// `--register-role` / `--listen`): mnemonic and roles are set up here,
/// then the App runs with `listen_override` so it goes straight to the
/// main screen. Headless modes (stdio/qrexec/tcp/http) never show a TUI.
fn server_main(cli: &Cli) -> Result<(), NsignerError> {
println!("nsigner {}", nsigner::VERSION);
fn server_main(cli: &Cli) -> Result<(), SignerError> {
println!("signer {}", signer::VERSION);
if cli.allow_unlocked_memory {
nsigner::secure_mem::allow_unlocked();
signer::secure_mem::allow_unlocked();
}
let interactive = !cli.mnemonic_stdin && cli.mnemonic_fd.is_none();
@@ -153,7 +153,7 @@ fn server_main(cli: &Cli) -> Result<(), NsignerError> {
.socket_name
.clone()
.unwrap_or_else(|| {
nsigner::socket_name::socket_name_random().unwrap_or_default()
signer::socket_name::socket_name_random().unwrap_or_default()
});
let auth_mode = parse_auth_mode(&cli.auth);
@@ -163,7 +163,7 @@ fn server_main(cli: &Cli) -> Result<(), NsignerError> {
// (Unix only — non-Unix modes are headless above), the transport
// is pre-selected on the main screen. Pass the raw --listen string
// so the App can adopt an explicit tcp:/http: bind address.
let mut app = nsigner::tui::App::new(
let mut app = signer::tui::App::new(
RoleTable::new(),
MnemonicState::new(),
KeyStore::new(),
@@ -176,7 +176,7 @@ fn server_main(cli: &Cli) -> Result<(), NsignerError> {
let mut terminal = ratatui::init();
let result = app.run(&mut terminal);
ratatui::restore();
result.map_err(|e| NsignerError::IoFailed(e.to_string()))
result.map_err(|e| SignerError::IoFailed(e.to_string()))
} else {
// Unreachable: non-interactive modes return headless above.
Ok(())
@@ -184,14 +184,14 @@ fn server_main(cli: &Cli) -> Result<(), NsignerError> {
}
/// Run a headless server (stdio, qrexec, tcp, http) — no TUI.
fn run_headless(cli: &Cli, listen_mode: ListenMode) -> Result<(), NsignerError> {
fn run_headless(cli: &Cli, listen_mode: ListenMode) -> Result<(), SignerError> {
let mut mnemonic = MnemonicState::new();
if cli.mnemonic_stdin {
let mut input = String::new();
std::io::stdin()
.read_line(&mut input)
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
let phrase = input.trim().to_string();
mnemonic.load(&phrase)?;
} else if let Some(fd) = cli.mnemonic_fd {
@@ -200,7 +200,7 @@ fn run_headless(cli: &Cli, listen_mode: ListenMode) -> Result<(), NsignerError>
let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
let mut input = String::new();
file.read_to_string(&mut input)
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
let phrase = input.trim().to_string();
mnemonic.load(&phrase)?;
}
@@ -219,7 +219,7 @@ fn run_headless(cli: &Cli, listen_mode: ListenMode) -> Result<(), NsignerError>
RoleCurve::Secp256k1,
-1, -1, -1, &[],
)
.map_err(|e| NsignerError::Internal(e.to_string()))?;
.map_err(|e| SignerError::Internal(e.to_string()))?;
}
let mut key_store = KeyStore::new();
@@ -241,7 +241,9 @@ fn run_headless(cli: &Cli, listen_mode: ListenMode) -> Result<(), NsignerError>
key_store: &mut key_store,
alg_key_cache: &mut alg_key_cache,
};
let _ = server.handle_one(&mut dispatcher);
if let Ok(Some(activity)) = server.handle_one(&mut dispatcher) {
println!("{}", activity);
}
server.stop();
} else {
// Tcp / Http poll loop
@@ -253,7 +255,9 @@ fn run_headless(cli: &Cli, listen_mode: ListenMode) -> Result<(), NsignerError>
alg_key_cache: &mut alg_key_cache,
};
match server.handle_one(&mut dispatcher) {
Ok(Some(_activity)) => {}
Ok(Some(activity)) => {
println!("{}", activity);
}
Ok(None) => {
std::thread::sleep(std::time::Duration::from_millis(50));
}
@@ -276,16 +280,16 @@ fn run_headless(cli: &Cli, listen_mode: ListenMode) -> Result<(), NsignerError>
fn client_main(request: &str, cli: &Cli) -> i32 {
use std::io::Read;
let socket_name = cli.socket_name.as_deref().unwrap_or("nsigner01");
let socket_name = cli.socket_name.as_deref().unwrap_or("signer01");
// Discover single socket if not explicit
let socket_name = if cli.socket_name.is_some() {
socket_name.to_string()
} else {
nsigner::socket_name::discover_single_socket().unwrap_or_else(|_| socket_name.to_string())
signer::socket_name::discover_single_socket().unwrap_or_else(|_| socket_name.to_string())
};
let mut stream = match nsigner::transport::connect_abstract_unix(&socket_name) {
let mut stream = match signer::transport::connect_abstract_unix(&socket_name) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to connect to {}: {}", socket_name, e);
@@ -306,13 +310,13 @@ fn client_main(request: &str, cli: &Cli) -> i32 {
};
// Send framed request
if nsigner::transport::send_framed(&mut stream, &request).is_err() {
if signer::transport::send_framed(&mut stream, &request).is_err() {
eprintln!("Failed to send request");
return 1;
}
// Receive framed response
match nsigner::transport::recv_framed(&mut stream) {
match signer::transport::recv_framed(&mut stream) {
Ok(response) => {
println!("{}", response);
0
@@ -328,18 +332,18 @@ fn client_main(request: &str, cli: &Cli) -> i32 {
fn bridge_main(to: Option<&str>, cli: &Cli) -> i32 {
let target = to.unwrap_or("nsigner01");
let target = to.unwrap_or("signer01");
let target = if cli.socket_name.is_some() {
target.to_string()
} else {
nsigner::socket_name::discover_single_socket().unwrap_or_else(|_| target.to_string())
signer::socket_name::discover_single_socket().unwrap_or_else(|_| target.to_string())
};
// Read source qube from qrexec environment
let source_qube = std::env::var("QREXEC_REMOTE_DOMAIN").unwrap_or_default();
// Connect to persistent signer via abstract socket
let mut stream = match nsigner::transport::connect_abstract_unix(&target) {
let mut stream = match signer::transport::connect_abstract_unix(&target) {
Ok(s) => s,
Err(e) => {
eprintln!("bridge: cannot connect to {}: {}", target, e);
@@ -349,14 +353,14 @@ fn bridge_main(to: Option<&str>, cli: &Cli) -> i32 {
// Send source-qube preamble
let preamble = format!(r#"{{"qrexec_source":"{}"}}"#, source_qube);
if nsigner::transport::send_framed(&mut stream, &preamble).is_err() {
if signer::transport::send_framed(&mut stream, &preamble).is_err() {
eprintln!("bridge: failed to send preamble");
return 1;
}
// Read one framed request from stdin and forward
let mut stdin = std::io::stdin();
let request = match nsigner::transport::recv_framed(&mut stdin) {
let request = match signer::transport::recv_framed(&mut stdin) {
Ok(r) => r,
Err(e) => {
eprintln!("bridge: failed to read request from stdin: {}", e);
@@ -364,17 +368,17 @@ fn bridge_main(to: Option<&str>, cli: &Cli) -> i32 {
}
};
if nsigner::transport::send_framed(&mut stream, &request).is_err() {
if signer::transport::send_framed(&mut stream, &request).is_err() {
eprintln!("bridge: failed to forward request");
return 1;
}
// Relay response to stdout
match nsigner::transport::recv_framed(&mut stream) {
match signer::transport::recv_framed(&mut stream) {
Ok(response) => {
let mut stdout = std::io::stdout();
if nsigner::transport::send_framed(&mut stdout, &response).is_err() {
if signer::transport::send_framed(&mut stdout, &response).is_err() {
eprintln!("bridge: failed to relay response");
return 1;
}
@@ -387,9 +391,9 @@ fn bridge_main(to: Option<&str>, cli: &Cli) -> i32 {
}
}
/// List subcommand: list running nsigner sockets.
/// List subcommand: list running signer sockets.
fn list_main() -> i32 {
let sockets = nsigner::socket_name::list_sockets();
let sockets = signer::socket_name::list_sockets();
if sockets.is_empty() {
println!("(none)");
} else {
@@ -404,10 +408,10 @@ fn list_main() -> i32 {
fn register_role_from_spec(
role_table: &mut RoleTable,
spec: &str,
) -> Result<(), NsignerError> {
) -> Result<(), SignerError> {
let parts: Vec<&str> = spec.splitn(3, ':').collect();
if parts.len() != 3 {
return Err(NsignerError::InvalidInput);
return Err(SignerError::InvalidInput);
}
let name = parts[0];
@@ -415,13 +419,13 @@ fn register_role_from_spec(
let path_token = parts[2];
if name.is_empty() || path_token.is_empty() {
return Err(NsignerError::InvalidInput);
return Err(SignerError::InvalidInput);
}
// Resolve curve
let curve = if curve_str.is_empty() {
// Auto-detect from path
match nsigner::role_table::purpose_from_path(path_token) {
match signer::role_table::purpose_from_path(path_token) {
RolePurpose::Ssh => RoleCurve::Ed25519,
RolePurpose::Age => RoleCurve::X25519,
RolePurpose::PqSig => {
@@ -439,15 +443,15 @@ fn register_role_from_spec(
};
if curve == RoleCurve::Unknown {
return Err(NsignerError::InvalidInput);
return Err(SignerError::InvalidInput);
}
let purpose = nsigner::role_table::purpose_from_path(path_token);
let purpose = signer::role_table::purpose_from_path(path_token);
// Parse path template
let (template, range_lo, range_hi, allowed_indices) =
nsigner::role_table::parse_path_template(path_token)
.map_err(|_| NsignerError::InvalidInput)?;
signer::role_table::parse_path_template(path_token)
.map_err(|_| SignerError::InvalidInput)?;
role_table
.register_role_path(
@@ -460,7 +464,7 @@ fn register_role_from_spec(
-1, // no default index
&allowed_indices,
)
.map_err(|e| NsignerError::Internal(e.to_string()))?;
.map_err(|e| SignerError::Internal(e.to_string()))?;
Ok(())
}
+6 -6
View File
@@ -44,14 +44,14 @@ pub fn miner_run(
target_difficulty: i32,
thread_count: i32,
timeout_sec: u64,
) -> Result<MineResult, crate::NsignerError> {
) -> Result<MineResult, crate::SignerError> {
let threads = thread_count.clamp(1, 32) as usize;
let timeout = if timeout_sec == 0 { 600 } else { timeout_sec };
let deadline = Instant::now() + Duration::from_secs(timeout);
// Parse the unsigned event
let mut event: Event =
serde_json::from_str(event_json).map_err(|_| crate::NsignerError::InvalidInput)?;
serde_json::from_str(event_json).map_err(|_| crate::SignerError::InvalidInput)?;
// Ensure there's a nonce tag (will be updated by workers)
let has_nonce = event.tags.iter().any(|t| t.kind() == "nonce");
@@ -96,9 +96,9 @@ pub fn miner_run(
}
let shared = Arc::try_unwrap(shared)
.map_err(|_| crate::NsignerError::Internal("mining thread still holds shared state".into()))?
.map_err(|_| crate::SignerError::Internal("mining thread still holds shared state".into()))?
.into_inner()
.map_err(|_| crate::NsignerError::Internal("mining shared state poisoned".into()))?;
.map_err(|_| crate::SignerError::Internal("mining shared state poisoned".into()))?;
let elapsed = start.elapsed().as_secs();
@@ -117,8 +117,8 @@ pub fn miner_run(
&sk,
event.created_at,
)
.map_err(|_| crate::NsignerError::CryptoFailed)?;
serde_json::to_string(&signed).map_err(|_| crate::NsignerError::InvalidInput)?
.map_err(|_| crate::SignerError::CryptoFailed)?;
serde_json::to_string(&signed).map_err(|_| crate::SignerError::InvalidInput)?
} else {
// No event mined — return the original unsigned event
event_json.to_string()
+7 -7
View File
@@ -4,7 +4,7 @@
//! generation, and seed conversion.
use crate::secure_mem::SecureBuf;
use crate::NsignerError;
use crate::SignerError;
/// Maximum mnemonic length: 24 words * ~10 chars + spaces + null.
pub const MNEMONIC_MAX_LEN: usize = 256;
@@ -32,18 +32,18 @@ impl MnemonicState {
///
/// Validates word count (12/15/18/21/24) and BIP-39 checksum.
/// Returns `InvalidInput` on invalid mnemonic, `MemoryFailed` on alloc error.
pub fn load(&mut self, phrase: &str) -> Result<(), NsignerError> {
pub fn load(&mut self, phrase: &str) -> Result<(), SignerError> {
let words: Vec<&str> = phrase.split_whitespace().collect();
let count = words.len();
// Validate word count
if ![12, 15, 18, 21, 24].contains(&count) {
return Err(NsignerError::InvalidInput);
return Err(SignerError::InvalidInput);
}
// Validate via nostr_core_lib_rust
if !nips::nip006::mnemonic_validate(phrase) {
return Err(NsignerError::InvalidInput);
return Err(SignerError::InvalidInput);
}
// Store in secure memory
@@ -62,14 +62,14 @@ impl MnemonicState {
/// Generate a new BIP-39 mnemonic phrase.
///
/// `word_count` must be 12, 15, 18, 21, or 24.
pub fn generate(&mut self, word_count: u8) -> Result<String, NsignerError> {
pub fn generate(&mut self, word_count: u8) -> Result<String, SignerError> {
let entropy_bytes = match word_count {
12 => 16,
15 => 20,
18 => 24,
21 => 28,
24 => 32,
_ => return Err(NsignerError::InvalidInput),
_ => return Err(SignerError::InvalidInput),
};
let mut entropy = vec![0u8; entropy_bytes];
@@ -77,7 +77,7 @@ impl MnemonicState {
rand::thread_rng().fill_bytes(&mut entropy);
let phrase = nips::nip006::mnemonic_from_bytes(&entropy)
.map_err(|_| NsignerError::CryptoFailed)?;
.map_err(|_| SignerError::CryptoFailed)?;
// Zeroize entropy
use zeroize::Zeroize;
+23 -23
View File
@@ -4,7 +4,7 @@
//! startup. Pad offset advances monotonically across requests.
use crate::secure_mem::SecureBuf;
use crate::NsignerError;
use crate::SignerError;
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
@@ -63,14 +63,14 @@ impl OtpPadState {
/// `dir` — directory containing .pad and .state files
/// `spec` — pad checksum (64 hex) or unique prefix
/// `allow_blkback` — allow pads on qvm-block devices (not for production)
pub fn bind(&mut self, dir: &str, spec: &str, _allow_blkback: bool) -> Result<(), NsignerError> {
pub fn bind(&mut self, dir: &str, spec: &str, _allow_blkback: bool) -> Result<(), SignerError> {
// Find the pad file matching the spec
let pad_filename = if spec.len() == 64 {
format!("{}/{}.pad", dir, spec)
} else {
// Prefix match — find a .pad file starting with spec
let entries = std::fs::read_dir(dir)
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
let mut found = None;
for entry in entries {
@@ -85,15 +85,15 @@ impl OtpPadState {
}
found
.map(|p| p.to_string_lossy().to_string())
.ok_or(NsignerError::InvalidInput)?
.ok_or(SignerError::InvalidInput)?
};
let file = File::open(&pad_filename)
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
let metadata = file
.metadata()
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
let size = metadata.len();
// Extract checksum from filename
@@ -113,7 +113,7 @@ impl OtpPadState {
// Allocate scratch buffer for XOR
let scratch = SecureBuf::alloc(4 * 1024 * 1024) // 4 MB max chunk
.map_err(|_| NsignerError::MemoryFailed)?;
.map_err(|_| SignerError::MemoryFailed)?;
self.bound = true;
self.pads_dir = dir.to_string();
@@ -144,9 +144,9 @@ impl OtpPadState {
&mut self,
plaintext: &[u8],
_encoding: Option<&str>,
) -> Result<(Vec<u8>, u64, u64), NsignerError> {
) -> Result<(Vec<u8>, u64, u64), SignerError> {
if !self.bound {
return Err(NsignerError::InvalidInput);
return Err(SignerError::InvalidInput);
}
let off_before = self.offset;
@@ -164,32 +164,32 @@ impl OtpPadState {
&mut self,
ciphertext: &[u8],
_encoding: Option<&str>,
) -> Result<Vec<u8>, NsignerError> {
) -> Result<Vec<u8>, SignerError> {
if !self.bound {
return Err(NsignerError::InvalidInput);
return Err(SignerError::InvalidInput);
}
self.xor_with_pad(ciphertext)
}
/// XOR data with pad bytes at the current offset, advancing the offset.
fn xor_with_pad(&mut self, data: &[u8]) -> Result<Vec<u8>, NsignerError> {
let file = self.pad_file.as_mut().ok_or(NsignerError::InvalidInput)?;
let scratch = self.scratch.as_mut().ok_or(NsignerError::InvalidInput)?;
fn xor_with_pad(&mut self, data: &[u8]) -> Result<Vec<u8>, SignerError> {
let file = self.pad_file.as_mut().ok_or(SignerError::InvalidInput)?;
let scratch = self.scratch.as_mut().ok_or(SignerError::InvalidInput)?;
let data_len = data.len();
if data_len > scratch.size() {
return Err(NsignerError::InvalidInput);
return Err(SignerError::InvalidInput);
}
// Seek to current offset
file.seek(SeekFrom::Start(self.offset))
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
// Read pad bytes
let pad_slice = &mut scratch.as_mut_slice()[..data_len];
file.read_exact(pad_slice)
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
// XOR
let result: Vec<u8> = data
@@ -231,11 +231,11 @@ static GLOBAL_OTP_PAD: Mutex<Option<OtpPadState>> = Mutex::new(None);
/// Bind the global OTP pad. Called from the role wizard (OTP preset) or from
/// `--otp-pad-dir` CLI handling. Replaces any previously bound pad.
pub fn bind_global(dir: &str, spec: &str, allow_blkback: bool) -> Result<(), NsignerError> {
pub fn bind_global(dir: &str, spec: &str, allow_blkback: bool) -> Result<(), SignerError> {
let mut pad = OtpPadState::new();
pad.bind(dir, spec, allow_blkback)?;
let mut guard = GLOBAL_OTP_PAD.lock().map_err(|e| {
NsignerError::Internal(format!("global otp pad lock poisoned: {}", e))
SignerError::Internal(format!("global otp pad lock poisoned: {}", e))
})?;
*guard = Some(pad);
Ok(())
@@ -287,7 +287,7 @@ mod tests {
#[test]
fn test_bind_and_encrypt() {
let dir = std::env::temp_dir().join("nsigner_otp_test_1");
let dir = std::env::temp_dir().join("signer_otp_test_1");
let _ = std::fs::remove_dir_all(&dir);
let chksum = make_test_pad(&dir, 1024);
@@ -313,7 +313,7 @@ mod tests {
#[test]
fn test_encrypt_decrypt_roundtrip() {
let dir = std::env::temp_dir().join("nsigner_otp_test_2");
let dir = std::env::temp_dir().join("signer_otp_test_2");
let _ = std::fs::remove_dir_all(&dir);
let chksum = make_test_pad(&dir, 1024);
@@ -346,7 +346,7 @@ mod tests {
#[test]
fn test_unbind() {
let dir = std::env::temp_dir().join("nsigner_otp_test_3");
let dir = std::env::temp_dir().join("signer_otp_test_3");
let _ = std::fs::remove_dir_all(&dir);
let chksum = make_test_pad(&dir, 1024);
@@ -363,7 +363,7 @@ mod tests {
#[test]
fn test_offset_advances() {
let dir = std::env::temp_dir().join("nsigner_otp_test_4");
let dir = std::env::temp_dir().join("signer_otp_test_4");
let _ = std::fs::remove_dir_all(&dir);
let chksum = make_test_pad(&dir, 1024);
+390 -67
View File
@@ -1,8 +1,13 @@
//! Post-quantum crypto algorithm registry.
//! Post-quantum crypto algorithm registry and operations.
//!
//! Port of `pq_crypto.c`. Provides the `CryptoAlg` enum and size
//! constants for all six algorithms. Actual crypto operations
//! (ed25519, x25519, PQ) are implemented in Phase 13.
//! Port of `pq_crypto.c`. Provides the `CryptoAlg` enum, size constants,
//! and crypto operations for all six algorithms.
//!
//! PQ keygen uses the v2 FIPS seeded derivation scheme (see
//! `plans/pq_seeded_derivation_plan.md`): BIP-32 child bytes at the exact
//! seed length required by each algorithm feed the seeded keygen APIs
//! directly — no DRBG expansion. This matches the nostr_quantum_preparation
//! web app byte-for-byte (same mnemonic + path → same pubkeys).
// ── Algorithm Identifiers ────────────────────────────────────────────────────
@@ -55,6 +60,13 @@ pub struct CryptoAlgSizes {
}
impl CryptoAlg {
/// Sizes for each algorithm.
///
/// PQ private keys are stored in **seed form** (the preferred
/// serialization of the RustCrypto crates): ML-DSA-65 as the 32-byte ξ
/// seed, ML-KEM-768 as the 64-byte d ∥ z seed, SLH-DSA-128s as the
/// 64-byte sk serialization (sk.seed ∥ sk.prf ∥ pk). Public key,
/// signature, and ciphertext lengths are the standard FIPS sizes.
pub fn sizes(&self) -> Option<CryptoAlgSizes> {
match self {
Self::Secp256k1 => Some(CryptoAlgSizes {
@@ -67,53 +79,126 @@ impl CryptoAlg {
priv_key_len: 32, pub_key_len: 32, sig_len: 0, ciphertext_len: 0, shared_secret_len: 32,
}),
Self::MlDsa65 => Some(CryptoAlgSizes {
priv_key_len: 4032, pub_key_len: 1952, sig_len: 3309, ciphertext_len: 0, shared_secret_len: 0,
priv_key_len: 32, pub_key_len: 1952, sig_len: 3309, ciphertext_len: 0, shared_secret_len: 0,
}),
Self::SlhDsa128s => Some(CryptoAlgSizes {
priv_key_len: 64, pub_key_len: 32, sig_len: 7856, ciphertext_len: 0, shared_secret_len: 0,
}),
Self::MlKem768 => Some(CryptoAlgSizes {
priv_key_len: 2400, pub_key_len: 1184, sig_len: 0, ciphertext_len: 1088, shared_secret_len: 32,
priv_key_len: 64, pub_key_len: 1184, sig_len: 0, ciphertext_len: 1088, shared_secret_len: 32,
}),
Self::Unknown => None,
}
}
}
// ── Crypto Operations (stubs — Phase 13) ─────────────────────────────────────
// ── Crypto Operations ────────────────────────────────────────────────────────
/// Derive a 32-byte seed from a mnemonic using a BIP-44 path (SLIP-0010).
/// BIP-32 path prefixes routed through BIP-32 derivation.
///
/// For secp256k1: uses BIP-32 derivation.
/// - `m/44'/1237'` — Nostr secp256k1 (NIP-06)
/// - `m/44'/102003'` … `m/44'/102007'` — PQ coin types (v2 seeded scheme;
/// 102003' ML-DSA-65, 102004' SLH-DSA-128s, 102005' ML-KEM-768,
/// 102006' ML-DSA-44 and 102007' Falcon-512 reserved)
///
/// Everything else — ed25519 (`102001'`) and x25519 (`102002'`) — uses
/// SLIP-0010, which is the correct derivation for those curves.
const BIP32_PATH_PREFIXES: &[&str] = &[
"m/44'/1237'",
"m/44'/102003'",
"m/44'/102004'",
"m/44'/102005'",
"m/44'/102006'",
"m/44'/102007'",
];
/// Derive a 32-byte seed from a mnemonic using a BIP-44 path.
///
/// For secp256k1 and PQ coin types: uses BIP-32 derivation.
/// For ed25519/x25519: uses SLIP-0010 (all-hardened).
/// For PQ: uses SLIP-0010 to get a 32-byte seed, then feeds DRBG for keygen.
///
/// Note: PQ keygen should use [`derive_pq_seed_from_path`] instead — it
/// produces the exact-length seed (32/48/64 bytes) required by the FIPS
/// seeded keygen APIs.
pub fn derive_seed_from_mnemonic(
mnemonic: &str,
path: &str,
) -> Result<[u8; 32], crate::NsignerError> {
) -> Result<[u8; 32], crate::SignerError> {
let seed = nips::nip006::mnemonic_to_seed(mnemonic, "");
// Parse the path
let path_indices = nips::nip006::parse_bip44_path(path)
.map_err(|_| crate::NsignerError::KeyDerivationFailed)?;
.map_err(|_| crate::SignerError::KeyDerivationFailed)?;
// Determine if this is a secp256k1 path (BIP-32) or ed25519/x25519 path (SLIP-0010)
// by checking the purpose prefix.
if path.starts_with("m/44'/1237'") {
// BIP-32 derivation for secp256k1
// BIP-32 for secp256k1 (NIP-06) and PQ coin types (v2 seeded scheme);
// SLIP-0010 for ed25519/x25519 (correct for those curves).
if BIP32_PATH_PREFIXES.iter().any(|p| path.starts_with(p)) {
let (master_key, master_chain_code) = nips::nip006::bip32_master_key(&seed);
let (derived_key, _) = nips::nip006::bip32_derive_path(&master_key, &master_chain_code, &path_indices)
.map_err(|_| crate::NsignerError::KeyDerivationFailed)?;
.map_err(|_| crate::SignerError::KeyDerivationFailed)?;
Ok(derived_key)
} else {
// SLIP-0010 derivation for ed25519/x25519/PQ
let (master_key, master_chain_code) = nips::nip006::slip10_master_key(&seed);
let (derived_key, _) = nips::nip006::slip10_derive_path(&master_key, &master_chain_code, &path_indices)
.map_err(|_| crate::NsignerError::KeyDerivationFailed)?;
.map_err(|_| crate::SignerError::KeyDerivationFailed)?;
Ok(derived_key)
}
}
/// Derive a PQ keygen seed of `seed_len` bytes (32/48/64) from a BIP-32 path.
///
/// v2 seeded construction (matches nostr_quantum_preparation exactly):
/// - 32-byte seeds: the child private key at `path`.
/// - 48/64-byte seeds: the children at `path` and at the sibling path (last
/// level incremented by 1, hardened bit preserved) concatenated to 64
/// bytes, then truncated to the FIRST `seed_len` bytes.
///
/// The truncation rule is normative: taking the last 48 bytes or
/// concatenating in the opposite order produces different keys and breaks
/// seed-phrase recoverability.
pub fn derive_pq_seed_from_path(
mnemonic: &str,
path: &str,
seed_len: usize,
) -> Result<Vec<u8>, crate::SignerError> {
if !matches!(seed_len, 32 | 48 | 64) {
return Err(crate::SignerError::InvalidInput);
}
let bip39_seed = nips::nip006::mnemonic_to_seed(mnemonic, "");
let (master_key, master_chain_code) = nips::nip006::bip32_master_key(&bip39_seed);
let indices = nips::nip006::parse_bip44_path(path)
.map_err(|_| crate::SignerError::KeyDerivationFailed)?;
if indices.is_empty() {
return Err(crate::SignerError::KeyDerivationFailed);
}
let (child0, _) = nips::nip006::bip32_derive_path(&master_key, &master_chain_code, &indices)
.map_err(|_| crate::SignerError::KeyDerivationFailed)?;
if seed_len == 32 {
return Ok(child0.to_vec());
}
// Sibling path: last level + 1 (a plain u32 increment preserves the
// hardened bit: 0x80000000 + 1 = 0x80000001, i.e. hardened 1').
let mut sibling_indices = indices.clone();
let last = sibling_indices
.last_mut()
.ok_or(crate::SignerError::KeyDerivationFailed)?;
*last = last.wrapping_add(1);
let (child1, _) =
nips::nip006::bip32_derive_path(&master_key, &master_chain_code, &sibling_indices)
.map_err(|_| crate::SignerError::KeyDerivationFailed)?;
let mut combined = [0u8; 64];
combined[..32].copy_from_slice(&child0);
combined[32..].copy_from_slice(&child1);
Ok(combined[..seed_len].to_vec())
}
/// ed25519: derive keypair from a 32-byte seed.
pub fn ed25519_keygen_from_seed(seed: &[u8; 32]) -> ([u8; 32], [u8; 32]) {
use ed25519_dalek::{SigningKey, VerifyingKey};
@@ -162,12 +247,12 @@ pub fn x25519_ecdh(our_priv: &[u8; 32], peer_pub: &[u8; 32]) -> [u8; 32] {
/// secp256k1 ECDSA sign arbitrary bytes.
/// Hashes the message with SHA-256 before signing.
/// Returns 64-byte compact signature (r || s).
pub fn secp256k1_ecdsa_sign(priv_key: &[u8; 32], msg: &[u8]) -> Result<[u8; 64], crate::NsignerError> {
pub fn secp256k1_ecdsa_sign(priv_key: &[u8; 32], msg: &[u8]) -> Result<[u8; 64], crate::SignerError> {
use secp256k1::{Message, Secp256k1, SecretKey};
let secp = Secp256k1::new();
let sk = SecretKey::from_slice(priv_key).map_err(|_| crate::NsignerError::CryptoFailed)?;
let sk = SecretKey::from_slice(priv_key).map_err(|_| crate::SignerError::CryptoFailed)?;
let hash = sha256(msg);
let msg = Message::from_digest_slice(&hash).map_err(|_| crate::NsignerError::CryptoFailed)?;
let msg = Message::from_digest_slice(&hash).map_err(|_| crate::SignerError::CryptoFailed)?;
let sig = secp.sign_ecdsa(&msg, &sk);
Ok(sig.serialize_compact())
}
@@ -200,69 +285,146 @@ pub fn secp256k1_ecdsa_verify(pub_key: &[u8; 32], msg: &[u8], sig: &[u8; 64]) ->
secp.verify_ecdsa(&msg, &signature, &pk).is_ok()
}
// ── PQ Crypto Stubs ──────────────────────────────────────────────────────────
// ── PQ Crypto (FIPS seeded keygen — v2 scheme) ───────────────────────────────
//
// The pure Rust crates (ml-dsa, ml-kem, slh-dsa) are included as dependencies
// for future implementation. Their APIs use `TryCryptoRng`, `KeyExport`, and
// other traits that require careful integration with the SHAKE-256 DRBG.
//
// TODO: Wire up the crate APIs for deterministic keygen from seed, sign, verify,
// encapsulate, and decapsulate operations.
// Keygen consumes the exact-length seed derived by `derive_pq_seed_from_path`
// (32 B ML-DSA, 48 B SLH-DSA, 64 B ML-KEM) via the RustCrypto seeded APIs.
// Private keys are stored in seed form (see `CryptoAlg::sizes`).
/// ML-DSA-65: generate keypair from a 32-byte seed (deterministic).
/// TODO: Wire up ml-dsa crate API.
pub fn ml_dsa_65_keygen_from_seed(_seed: &[u8; 32]) -> Result<(Vec<u8>, Vec<u8>), crate::NsignerError> {
Err(crate::NsignerError::NotYetImplemented)
///
/// Returns (private_key = 32-byte ξ seed, public_key = 1952 bytes).
pub fn ml_dsa_65_keygen_from_seed(seed: &[u8; 32]) -> Result<(Vec<u8>, Vec<u8>), crate::SignerError> {
use ml_dsa::{MlDsa65, SigningKey, signature::Keypair};
let sk = SigningKey::<MlDsa65>::from_seed(seed.into());
let vk = sk.verifying_key();
Ok((sk.to_seed().to_vec(), vk.encode().to_vec()))
}
/// ML-DSA-65: sign a message.
/// TODO: Wire up ml-dsa crate API.
pub fn ml_dsa_65_sign(_priv: &[u8], _msg: &[u8]) -> Result<Vec<u8>, crate::NsignerError> {
Err(crate::NsignerError::NotYetImplemented)
/// ML-DSA-65: sign a message. priv is the 32-byte ξ seed.
/// Returns the 3309-byte signature (deterministic FIPS 204 variant).
pub fn ml_dsa_65_sign(priv_key: &[u8], msg: &[u8]) -> Result<Vec<u8>, crate::SignerError> {
use ml_dsa::{MlDsa65, Seed, SigningKey, signature::Signer};
if priv_key.len() != 32 {
return Err(crate::SignerError::InvalidInput);
}
let seed: Seed = priv_key.try_into().map_err(|_| crate::SignerError::InvalidInput)?;
let sk = SigningKey::<MlDsa65>::from_seed(&seed);
let sig = sk.sign(msg);
Ok(sig.encode().to_vec())
}
/// ML-DSA-65: verify a signature.
/// TODO: Wire up ml-dsa crate API.
pub fn ml_dsa_65_verify(_pub: &[u8], _msg: &[u8], _sig: &[u8]) -> bool {
false
/// ML-DSA-65: verify a signature. pub is the 1952-byte public key.
pub fn ml_dsa_65_verify(pub_key: &[u8], msg: &[u8], sig: &[u8]) -> bool {
use ml_dsa::{MlDsa65, Signature, VerifyingKey, signature::Verifier};
if pub_key.len() != 1952 {
return false;
}
let enc: ml_dsa::EncodedVerifyingKey<MlDsa65> =
match pub_key.try_into() {
Ok(e) => e,
Err(_) => return false,
};
let vk = VerifyingKey::<MlDsa65>::decode(&enc);
let signature = match Signature::<MlDsa65>::try_from(sig) {
Ok(s) => s,
Err(_) => return false,
};
vk.verify(msg, &signature).is_ok()
}
/// SLH-DSA-128s: generate keypair from a 32-byte seed (deterministic).
/// TODO: Wire up slh-dsa crate API.
pub fn slh_dsa_128s_keygen_from_seed(_seed: &[u8; 32]) -> Result<(Vec<u8>, Vec<u8>), crate::NsignerError> {
Err(crate::NsignerError::NotYetImplemented)
/// SLH-DSA-128s (SHA2 small): generate keypair from a 48-byte seed.
///
/// The seed splits as sk.seed(16) ∥ sk.prf(16) ∥ pk.seed(16) — matching
/// noble's `slh_dsa_sha2_128s.keygen(seed)` exactly.
/// Returns (private_key = 64-byte sk serialization, public_key = 32 bytes).
pub fn slh_dsa_128s_keygen_from_seed(seed: &[u8]) -> Result<(Vec<u8>, Vec<u8>), crate::SignerError> {
use slh_dsa::{Sha2_128s, SigningKey, signature::Keypair};
if seed.len() != 48 {
return Err(crate::SignerError::InvalidInput);
}
let sk = SigningKey::<Sha2_128s>::slh_keygen_internal(&seed[..16], &seed[16..32], &seed[32..48]);
let vk = sk.verifying_key();
Ok((sk.to_bytes().to_vec(), vk.to_bytes().to_vec()))
}
/// SLH-DSA-128s: sign a message.
/// TODO: Wire up slh-dsa crate API.
pub fn slh_dsa_128s_sign(_priv: &[u8], _msg: &[u8]) -> Result<Vec<u8>, crate::NsignerError> {
Err(crate::NsignerError::NotYetImplemented)
/// SLH-DSA-128s: sign a message. priv is the 64-byte sk serialization.
/// Returns the 7856-byte signature (deterministic: opt_rand = pk.seed).
pub fn slh_dsa_128s_sign(priv_key: &[u8], msg: &[u8]) -> Result<Vec<u8>, crate::SignerError> {
use slh_dsa::{Sha2_128s, SigningKey, signature::Signer};
let sk = SigningKey::<Sha2_128s>::try_from(priv_key)
.map_err(|_| crate::SignerError::InvalidInput)?;
let sig = sk.sign(msg);
Ok(sig.to_vec())
}
/// SLH-DSA-128s: verify a signature.
/// TODO: Wire up slh-dsa crate API.
pub fn slh_dsa_128s_verify(_pub: &[u8], _msg: &[u8], _sig: &[u8]) -> bool {
false
/// SLH-DSA-128s: verify a signature. pub is the 32-byte public key.
pub fn slh_dsa_128s_verify(pub_key: &[u8], msg: &[u8], sig: &[u8]) -> bool {
use slh_dsa::{Sha2_128s, Signature, VerifyingKey, signature::Verifier};
let vk = match VerifyingKey::<Sha2_128s>::try_from(pub_key) {
Ok(k) => k,
Err(_) => return false,
};
let signature = match Signature::<Sha2_128s>::try_from(sig) {
Ok(s) => s,
Err(_) => return false,
};
vk.verify(msg, &signature).is_ok()
}
/// ML-KEM-768: generate keypair from a 32-byte seed (deterministic).
/// TODO: Wire up ml-kem crate API.
pub fn ml_kem_768_keygen_from_seed(_seed: &[u8; 32]) -> Result<(Vec<u8>, Vec<u8>), crate::NsignerError> {
Err(crate::NsignerError::NotYetImplemented)
/// ML-KEM-768: generate keypair from a 64-byte seed (deterministic).
///
/// The seed splits as d(32) ∥ z(32) — matching noble's `ml_kem768.keygen(seed)`.
/// Returns (private_key = 64-byte seed, public_key = 1184 bytes).
pub fn ml_kem_768_keygen_from_seed(seed: &[u8]) -> Result<(Vec<u8>, Vec<u8>), crate::SignerError> {
use ml_kem::ml_kem_768::DecapsulationKey;
use ml_kem::{KeyExport, Seed};
if seed.len() != 64 {
return Err(crate::SignerError::InvalidInput);
}
let seed: Seed = seed.try_into().map_err(|_| crate::SignerError::InvalidInput)?;
let dk = DecapsulationKey::from_seed(seed);
let ek = dk.encapsulation_key();
Ok((dk.to_seed().ok_or(crate::SignerError::CryptoFailed)?.to_vec(), ek.to_bytes().to_vec()))
}
/// ML-KEM-768: encapsulate. pub is 1184-byte public key.
/// Returns (ciphertext[1088], shared_secret[32]).
/// TODO: Wire up ml-kem crate API.
pub fn ml_kem_768_encaps(_pub: &[u8]) -> Result<(Vec<u8>, [u8; 32]), crate::NsignerError> {
Err(crate::NsignerError::NotYetImplemented)
/// ML-KEM-768: encapsulate. pub is the 1184-byte public key.
/// Returns (ciphertext[1088], shared_secret[32]). Uses OS randomness —
/// each encapsulation produces a different ciphertext, by design.
pub fn ml_kem_768_encaps(pub_key: &[u8]) -> Result<(Vec<u8>, [u8; 32]), crate::SignerError> {
use ml_kem::ml_kem_768::EncapsulationKey;
use ml_kem::kem::Encapsulate;
let ek = EncapsulationKey::new(
pub_key.try_into().map_err(|_| crate::SignerError::InvalidInput)?,
)
.map_err(|_| crate::SignerError::InvalidInput)?;
let (ct, ss) = ek.encapsulate();
Ok((ct.to_vec(), ss.into()))
}
/// ML-KEM-768: decapsulate. priv is 2400-byte secret key, ct is 1088-byte ciphertext.
/// Returns shared_secret[32].
/// TODO: Wire up ml-kem crate API.
pub fn ml_kem_768_decaps(_priv: &[u8], _ct: &[u8]) -> Result<[u8; 32], crate::NsignerError> {
Err(crate::NsignerError::NotYetImplemented)
/// ML-KEM-768: decapsulate. priv is the 64-byte seed, ct is the 1088-byte
/// ciphertext. Returns shared_secret[32].
pub fn ml_kem_768_decaps(priv_key: &[u8], ct: &[u8]) -> Result<[u8; 32], crate::SignerError> {
use ml_kem::ml_kem_768::DecapsulationKey;
use ml_kem::kem::Decapsulate;
use ml_kem::Seed;
if priv_key.len() != 64 {
return Err(crate::SignerError::InvalidInput);
}
let seed: Seed = priv_key.try_into().map_err(|_| crate::SignerError::InvalidInput)?;
let dk = DecapsulationKey::from_seed(seed);
let ct_arr = ct.try_into().map_err(|_| crate::SignerError::InvalidInput)?;
let ss = dk.decapsulate(&ct_arr);
Ok(ss.into())
}
// ── Helpers ─────────────────────────────────────────────────────────────────
@@ -276,6 +438,9 @@ fn sha256(data: &[u8]) -> [u8; 32] {
mod tests {
use super::*;
/// The fixed test mnemonic used by nostr_quantum_preparation's vectors.
const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
#[test]
fn test_alg_from_str() {
assert_eq!(CryptoAlg::from_str("secp256k1"), CryptoAlg::Secp256k1);
@@ -290,10 +455,22 @@ mod tests {
assert_eq!(s.priv_key_len, 32);
assert_eq!(s.pub_key_len, 32);
// PQ private keys are stored in seed form.
let s = CryptoAlg::MlDsa65.sizes().unwrap();
assert_eq!(s.priv_key_len, 32);
assert_eq!(s.pub_key_len, 1952);
assert_eq!(s.sig_len, 3309);
let s = CryptoAlg::SlhDsa128s.sizes().unwrap();
assert_eq!(s.priv_key_len, 64);
assert_eq!(s.pub_key_len, 32);
assert_eq!(s.sig_len, 7856);
let s = CryptoAlg::MlKem768.sizes().unwrap();
assert_eq!(s.priv_key_len, 2400);
assert_eq!(s.priv_key_len, 64);
assert_eq!(s.pub_key_len, 1184);
assert_eq!(s.ciphertext_len, 1088);
assert_eq!(s.shared_secret_len, 32);
}
#[test]
@@ -316,4 +493,150 @@ mod tests {
let shared_b = x25519_ecdh(&priv_b, &pub_a);
assert_eq!(shared_a, shared_b);
}
// ── v2 seeded derivation ──────────────────────────────────────────
#[test]
fn test_pq_seed_lengths() {
// 32-byte seed: single child.
let s32 = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102003'/0'/0'/0'", 32).unwrap();
assert_eq!(s32.len(), 32);
// 48-byte seed: two children concatenated, first 48 of 64.
let s48 = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102004'/0'/0'/0'", 48).unwrap();
assert_eq!(s48.len(), 48);
// 64-byte seed: two children concatenated, all 64.
let s64 = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102005'/0'/0'/0'", 64).unwrap();
assert_eq!(s64.len(), 64);
// The 48-byte seed is a prefix of the 64-byte seed only when the
// paths share the same coin type — here they differ, so just check
// prefix consistency within the same coin type.
let s48b = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102005'/0'/0'/0'", 48).unwrap();
assert_eq!(&s64[..48], s48b.as_slice());
}
#[test]
fn test_pq_seed_determinism() {
let a = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102003'/0'/0'/0'", 32).unwrap();
let b = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102003'/0'/0'/0'", 32).unwrap();
assert_eq!(a, b);
// Different index → different seed.
let c = derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102003'/0'/0'/1'", 32).unwrap();
assert_ne!(a, c);
}
#[test]
fn test_pq_seed_invalid_length() {
assert!(derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102003'/0'/0'/0'", 33).is_err());
}
#[test]
fn test_pq_paths_use_bip32() {
// PQ coin types must route through BIP-32 (v2 scheme), not SLIP-0010.
// The 32-byte PQ seed equals the BIP-32 child at the same path.
let pq_seed =
derive_pq_seed_from_path(TEST_MNEMONIC, "m/44'/102003'/0'/0'/0'", 32).unwrap();
let bip32_seed =
derive_seed_from_mnemonic(TEST_MNEMONIC, "m/44'/102003'/0'/0'/0'").unwrap();
assert_eq!(pq_seed, bip32_seed.to_vec());
}
#[test]
fn test_ed25519_path_still_slip10() {
// ed25519 (102001') must remain SLIP-0010 — regression guard.
let seed = derive_seed_from_mnemonic(TEST_MNEMONIC, "m/44'/102001'/0'/0'/0'").unwrap();
let bip39_seed = nips::nip006::mnemonic_to_seed(TEST_MNEMONIC, "");
let (master_key, master_chain_code) = nips::nip006::slip10_master_key(&bip39_seed);
let path_indices = nips::nip006::parse_bip44_path("m/44'/102001'/0'/0'/0'").unwrap();
let (expected, _) =
nips::nip006::slip10_derive_path(&master_key, &master_chain_code, &path_indices)
.unwrap();
assert_eq!(seed, expected);
}
// ── PQ keygen / sign / verify / KEM roundtrips ────────────────────
#[test]
fn test_ml_dsa_65_roundtrip() {
let seed = [0x42u8; 32];
let (priv_key, pub_key) = ml_dsa_65_keygen_from_seed(&seed).unwrap();
assert_eq!(priv_key.len(), 32);
assert_eq!(pub_key.len(), 1952);
// Determinism: same seed → same keypair.
let (priv2, pub2) = ml_dsa_65_keygen_from_seed(&seed).unwrap();
assert_eq!(priv_key, priv2);
assert_eq!(pub_key, pub2);
let msg = b"hello world";
let sig = ml_dsa_65_sign(&priv_key, msg).unwrap();
assert_eq!(sig.len(), 3309);
assert!(ml_dsa_65_verify(&pub_key, msg, &sig));
assert!(!ml_dsa_65_verify(&pub_key, b"wrong message", &sig));
// Different seed → different key → verify fails.
let (_, pub_other) = ml_dsa_65_keygen_from_seed(&[0x99u8; 32]).unwrap();
assert!(!ml_dsa_65_verify(&pub_other, msg, &sig));
}
#[test]
fn test_slh_dsa_128s_roundtrip() {
let seed = [0x42u8; 48];
let (priv_key, pub_key) = slh_dsa_128s_keygen_from_seed(&seed).unwrap();
assert_eq!(priv_key.len(), 64);
assert_eq!(pub_key.len(), 32);
// Determinism.
let (priv2, pub2) = slh_dsa_128s_keygen_from_seed(&seed).unwrap();
assert_eq!(priv_key, priv2);
assert_eq!(pub_key, pub2);
let msg = b"hello world";
let sig = slh_dsa_128s_sign(&priv_key, msg).unwrap();
assert_eq!(sig.len(), 7856);
assert!(slh_dsa_128s_verify(&pub_key, msg, &sig));
assert!(!slh_dsa_128s_verify(&pub_key, b"wrong message", &sig));
// Deterministic signing: same key + msg → same signature
// (opt_rand defaults to pk.seed).
let sig2 = slh_dsa_128s_sign(&priv_key, msg).unwrap();
assert_eq!(sig, sig2);
}
#[test]
fn test_ml_kem_768_roundtrip() {
let seed = [0x42u8; 64];
let (priv_key, pub_key) = ml_kem_768_keygen_from_seed(&seed).unwrap();
assert_eq!(priv_key.len(), 64);
assert_eq!(pub_key.len(), 1184);
// Determinism.
let (priv2, pub2) = ml_kem_768_keygen_from_seed(&seed).unwrap();
assert_eq!(priv_key, priv2);
assert_eq!(pub_key, pub2);
// Encaps/decaps roundtrip.
let (ct, ss_send) = ml_kem_768_encaps(&pub_key).unwrap();
assert_eq!(ct.len(), 1088);
assert_eq!(ss_send.len(), 32);
let ss_recv = ml_kem_768_decaps(&priv_key, &ct).unwrap();
assert_eq!(ss_send, ss_recv);
// Encapsulation is randomized: two calls → different ciphertexts.
let (ct2, ss2) = ml_kem_768_encaps(&pub_key).unwrap();
assert_ne!(ct, ct2);
assert_ne!(ss_send.to_vec(), ss2.to_vec());
assert_eq!(ml_kem_768_decaps(&priv_key, &ct2).unwrap(), ss2);
}
#[test]
fn test_ml_kem_768_invalid_inputs() {
assert!(ml_kem_768_keygen_from_seed(&[0u8; 32]).is_err());
assert!(ml_kem_768_decaps(&[0u8; 64], &[0u8; 1087]).is_err());
assert!(ml_kem_768_encaps(&[0u8; 1183]).is_err());
}
}
+9 -2
View File
@@ -1,7 +1,14 @@
//! Deterministic PRNG for post-quantum key generation.
//! Deterministic PRNG (NOT used for PQ key derivation).
//!
//! Port of `pq_drbg.c`. Implements a SHAKE-256-based deterministic PRNG
//! that replaces PQClean's `randombytes()` callback. Same seed → same output.
//! that replaced PQClean's `randombytes()` callback in the C n_signer.
//! Same seed → same output.
//!
//! **Not used for derivation**: PQ keygen now uses the v2 FIPS seeded
//! interface (see `plans/pq_seeded_derivation_plan.md`) — BIP-32 child
//! bytes at the exact seed length feed the seeded keygen APIs directly.
//! This module is retained as a faithful port for any future
//! PQClean-style integration that needs an RNG-fed keygen.
use sha3::{Shake256, digest::{Update, ExtendableOutput, XofReader}};
+18 -15
View File
@@ -4,7 +4,7 @@
//! (acting as an access token) to a BIP-44 derivation path template.
//! The template may contain a `%d` placeholder for a variable index.
use crate::NsignerError;
use crate::SignerError;
use std::collections::HashSet;
// ── Limits ───────────────────────────────────────────────────────────────────
@@ -137,6 +137,8 @@ pub struct RoleEntry {
pub pubkey_hex: String,
/// 1 if pubkey_hex has been populated.
pub derived: bool,
/// The concrete path the key was last derived for (variable-path roles).
pub derived_path: Option<String>,
/// Inclusive lower bound for %d; -1 = fixed path (no variable).
pub path_range_lo: i32,
/// Inclusive upper bound; == path_range_lo for single.
@@ -162,6 +164,7 @@ impl Default for RoleEntry {
role_path: String::new(),
pubkey_hex: String::new(),
derived: false,
derived_path: None,
path_range_lo: -1,
path_range_hi: -1,
path_default_index: -1,
@@ -264,12 +267,12 @@ impl RoleTable {
}
/// Add a role entry. Returns error if table full or name duplicate.
pub fn add(&mut self, entry: RoleEntry) -> Result<(), NsignerError> {
pub fn add(&mut self, entry: RoleEntry) -> Result<(), SignerError> {
if self.entries.len() >= ROLE_TABLE_MAX_ENTRIES {
return Err(NsignerError::Internal("role table full".into()));
return Err(SignerError::Internal("role table full".into()));
}
if self.find_by_name(&entry.name).is_some() {
return Err(NsignerError::Internal("duplicate role name".into()));
return Err(SignerError::Internal("duplicate role name".into()));
}
self.entries.push(entry);
Ok(())
@@ -303,7 +306,7 @@ impl RoleTable {
}
/// Register a nostr-index role if missing.
pub fn register_nostr_index(&mut self, nostr_index: i32) -> Result<(), NsignerError> {
pub fn register_nostr_index(&mut self, nostr_index: i32) -> Result<(), SignerError> {
if self.find_by_nostr_index(nostr_index).is_some() {
return Ok(());
}
@@ -336,7 +339,7 @@ impl RoleTable {
range_hi: i32,
default_index: i32,
allowed_indices: &[i32],
) -> Result<(), NsignerError> {
) -> Result<(), SignerError> {
let mut entry = RoleEntry::default();
entry.name = name.to_string();
entry.purpose = purpose;
@@ -431,10 +434,10 @@ pub fn role_path_extract_index(concrete: &str, template: &str) -> i32 {
#[allow(clippy::too_many_arguments)]
pub fn parse_path_template(
token: &str,
) -> Result<(String, i32, i32, Vec<i32>), NsignerError> {
) -> Result<(String, i32, i32, Vec<i32>), SignerError> {
let segs: Vec<&str> = token.split('/').collect();
if segs.is_empty() {
return Err(NsignerError::InvalidInput);
return Err(SignerError::InvalidInput);
}
let mut template_out = String::new();
@@ -490,18 +493,18 @@ pub fn parse_path_template(
let mut set = HashSet::new();
for tok in seg_clean.split('+') {
if let Some(dash) = tok.find('-') {
let lo: i32 = tok[..dash].parse().map_err(|_| NsignerError::InvalidInput)?;
let hi: i32 = tok[dash + 1..].parse().map_err(|_| NsignerError::InvalidInput)?;
let lo: i32 = tok[..dash].parse().map_err(|_| SignerError::InvalidInput)?;
let hi: i32 = tok[dash + 1..].parse().map_err(|_| SignerError::InvalidInput)?;
if lo < 0 || hi < 0 || lo > hi {
return Err(NsignerError::InvalidInput);
return Err(SignerError::InvalidInput);
}
for v in lo..=hi {
set.insert(v);
}
} else {
let val: i32 = tok.parse().map_err(|_| NsignerError::InvalidInput)?;
let val: i32 = tok.parse().map_err(|_| SignerError::InvalidInput)?;
if val < 0 {
return Err(NsignerError::InvalidInput);
return Err(SignerError::InvalidInput);
}
set.insert(val);
}
@@ -527,10 +530,10 @@ pub fn parse_path_template(
let dash_pos = seg_clean.find('-').unwrap();
let lo: i32 = seg_clean[..dash_pos]
.parse()
.map_err(|_| NsignerError::InvalidInput)?;
.map_err(|_| SignerError::InvalidInput)?;
let hi: i32 = seg_clean[dash_pos + 1..]
.parse()
.map_err(|_| NsignerError::InvalidInput)?;
.map_err(|_| SignerError::InvalidInput)?;
if lo < 0 || hi < 0 || lo > hi {
// Not a valid numeric range — treat as literal
template_out.push_str(seg);
+7 -7
View File
@@ -43,17 +43,17 @@ impl SecureBuf {
///
/// Returns `MemoryFailed` if allocation or mlock fails (unless
/// `allow_unlocked()` was called).
pub fn alloc(size: usize) -> Result<Self, crate::NsignerError> {
pub fn alloc(size: usize) -> Result<Self, crate::SignerError> {
if size == 0 {
return Err(crate::NsignerError::InvalidInput);
return Err(crate::SignerError::InvalidInput);
}
let layout = Layout::from_size_align(size, 1)
.map_err(|_| crate::NsignerError::MemoryFailed)?;
.map_err(|_| crate::SignerError::MemoryFailed)?;
let ptr = unsafe { alloc(layout) };
if ptr.is_null() {
return Err(crate::NsignerError::MemoryFailed);
return Err(crate::SignerError::MemoryFailed);
}
// Zero-initialize
@@ -64,7 +64,7 @@ impl SecureBuf {
if !locked && !is_unlocked_allowed() {
// mlock failed and unlocked mode not permitted — fail hard
unsafe { dealloc(ptr, layout) };
return Err(crate::NsignerError::MemoryFailed);
return Err(crate::SignerError::MemoryFailed);
}
Ok(SecureBuf { ptr, size, locked })
@@ -111,9 +111,9 @@ impl SecureBuf {
/// If `new_size` is 0, returns `InvalidInput`. If allocation of the
/// new buffer fails, the original buffer is left intact and an error
/// is returned.
pub fn resize(&mut self, new_size: usize) -> Result<(), crate::NsignerError> {
pub fn resize(&mut self, new_size: usize) -> Result<(), crate::SignerError> {
if new_size == 0 {
return Err(crate::NsignerError::InvalidInput);
return Err(crate::SignerError::InvalidInput);
}
if new_size == self.size {
return Ok(());
+221 -27
View File
@@ -10,7 +10,7 @@
use crate::auth_envelope::AuthNonceCache;
use crate::dispatcher::DispatcherContext;
use crate::selector::{selector_resolve, SelectorRequest};
use crate::NsignerError;
use crate::SignerError;
use std::net::TcpListener;
use std::os::unix::net::UnixListener;
@@ -62,6 +62,38 @@ impl CallerIdentity {
auth_label: String::new(),
}
}
/// Build a rich identity string for the activity log — as much as can be
/// identified about the caller.
///
/// - Unix socket: `uid:<n> gid:<n> pid:<n>`
/// - Qrexec: `qubes:<vm>`
/// - TCP/HTTP: `tcp:<addr>`
/// - Auth envelope verified: `pubkey:<hex> label:<label>` appended.
pub fn identity_str(&self) -> String {
let mut parts: Vec<String> = Vec::new();
match self.kind {
ListenMode::Unix => {
parts.push(format!("uid:{}", self.uid));
if self.gid != 0 {
parts.push(format!("gid:{}", self.gid));
}
if self.pid != 0 {
parts.push(format!("pid:{}", self.pid));
}
}
_ => {
parts.push(self.caller_id.clone());
}
}
if self.auth_present {
parts.push(format!("pubkey:{}", self.auth_pubkey_hex));
if !self.auth_label.is_empty() {
parts.push(format!("label:{}", self.auth_label));
}
}
parts.join(" ")
}
}
/// Server context.
@@ -93,14 +125,14 @@ impl ServerContext {
}
/// Start listening. Returns error on bind failure.
pub fn start(&mut self) -> Result<(), NsignerError> {
pub fn start(&mut self) -> Result<(), SignerError> {
match self.listen_mode {
ListenMode::Unix => {
// Abstract namespace: bind via libc (sun_path[0] = '\0')
let listener = bind_abstract_unix(&self.socket_name)?;
listener
.set_nonblocking(true)
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
self.listener = Some(listener);
self.running = true;
Ok(())
@@ -113,10 +145,10 @@ impl ServerContext {
.or_else(|| self.socket_name.strip_prefix("http:"))
.unwrap_or(&self.socket_name);
let listener = TcpListener::bind(addr)
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
listener
.set_nonblocking(true)
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
self.tcp_listener = Some(listener);
self.running = true;
Ok(())
@@ -142,7 +174,7 @@ impl ServerContext {
pub fn handle_one(
&mut self,
dispatcher: &mut DispatcherContext,
) -> Result<Option<String>, NsignerError> {
) -> Result<Option<String>, SignerError> {
if let Some(ref listener) = self.listener {
match listener.accept() {
Ok((stream, _)) => {
@@ -153,13 +185,13 @@ impl ServerContext {
let mut reader = stream
.try_clone()
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
let mut writer = stream;
// Read framed request. A connection with no data yet
// (WouldBlock) or an empty/closed probe is not a handled
// request — return Ok(None) so we don't log it as handled.
let request = match crate::transport::recv_framed(&mut reader) {
let mut request = match crate::transport::recv_framed(&mut reader) {
Ok(r) => r,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
return Ok(None);
@@ -168,7 +200,31 @@ impl ServerContext {
};
// Identify caller via SO_PEERCRED
let caller = identify_unix_caller(&reader);
let mut caller = identify_unix_caller(&reader);
// Bridge preamble: `signer bridge` (qrexec relay) sends a
// {"qrexec_source":"<qube>"} frame before the actual
// JSON-RPC request. Consume it, record the source qube,
// and read the real request that follows.
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&request) {
if v.get("qrexec_source").is_some() && v.get("method").is_none() {
caller.source_qube = v
.get("qrexec_source")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
if !caller.source_qube.is_empty() {
caller.caller_id = format!("qubes:{}", caller.source_qube);
}
request = match crate::transport::recv_framed(&mut reader) {
Ok(r) => r,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
return Ok(None);
}
Err(_) => return Ok(None),
};
}
}
// Process request (role-name-as-password model: no authorization)
let (response, activity) = self.process_request(dispatcher, &request, &caller);
@@ -182,7 +238,7 @@ impl ServerContext {
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
return Ok(None); // Nothing pending
}
Err(e) => return Err(NsignerError::IoFailed(e.to_string())),
Err(e) => return Err(SignerError::IoFailed(e.to_string())),
}
}
@@ -198,7 +254,7 @@ impl ServerContext {
let mut reader = stream
.try_clone()
.map_err(|e| NsignerError::IoFailed(e.to_string()))?;
.map_err(|e| SignerError::IoFailed(e.to_string()))?;
let mut writer = stream;
let request = if self.listen_mode == ListenMode::Http {
@@ -232,7 +288,7 @@ impl ServerContext {
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
return Ok(None);
}
Err(e) => return Err(NsignerError::IoFailed(e.to_string())),
Err(e) => return Err(SignerError::IoFailed(e.to_string())),
}
}
@@ -270,7 +326,7 @@ impl ServerContext {
Err((code, msg)) => {
if self.auth_mode == AuthMode::Required {
let response = make_auth_error(&request, code, msg);
let activity = format!("{} DENIED:{}", caller.caller_id, msg);
let activity = format!("{} DENIED:{}", caller.identity_str(), msg);
return (response, activity);
}
// Optional: continue without auth
@@ -284,7 +340,7 @@ impl ServerContext {
None => {
// Malformed request — let the dispatcher produce the error
let response = crate::dispatcher::handle_request(dispatcher, request);
let activity = format!("{} DENIED:malformed", caller.caller_id);
let activity = format!("{} DENIED:malformed", caller.identity_str());
return (response, activity);
}
};
@@ -292,21 +348,28 @@ impl ServerContext {
// get_info is metadata — no key material
if method == crate::enforcement::VERB_GET_INFO {
let response = crate::dispatcher::handle_request(dispatcher, request);
let activity = format!("{} - -", caller.caller_id);
let activity = format!("{} {}()", caller.identity_str(), method);
return (response, activity);
}
// Algorithm-based verbs (bypass role table) — no authorization
if crate::enforcement::is_algorithm_verb(&method) {
let response = self.process_algorithm_verb(dispatcher, request, &selector_req);
let activity = format!("{} - -", caller.caller_id);
// Activity: caller method(algorithm,index) — the algorithm's
// standard derivation path identifies the key material.
let (alg_name, path) = extract_algorithm_and_path(request);
let activity = match (alg_name, path) {
(Some(a), Some(p)) => format!("{} {}({},{} {})", caller.identity_str(), method, a, selector_req.index, p),
(Some(a), None) => format!("{} {}({})", caller.identity_str(), method, a),
_ => format!("{} {}()", caller.identity_str(), method),
};
return (response, activity);
}
// OTP verbs
if method == crate::enforcement::VERB_ENCRYPT || method == crate::enforcement::VERB_DECRYPT {
let response = crate::dispatcher::handle_request(dispatcher, request);
let activity = format!("{} - -", caller.caller_id);
let activity = format!("{} {}()", caller.identity_str(), method);
return (response, activity);
}
@@ -318,7 +381,7 @@ impl ServerContext {
let response = make_selector_error(&request, e);
let activity = format!(
"{} {}() DENIED:{}",
caller.caller_id,
caller.identity_str(),
method,
e.as_str()
);
@@ -327,15 +390,29 @@ impl ServerContext {
};
// Role entry from the resolved selector — used for the activity
// message (curve + key path).
// message (role name, curve, and the concrete key path requested).
let role_entry = &dispatcher.role_table.entries[role_index];
let role_name = role_entry.name.clone();
let curve = role_entry.curve_str.clone();
let path = role_entry.display_path();
// The actual key path the caller requested/accessed — the concrete
// role_path from the request when supplied, otherwise the role's
// fixed/derived path. (Not the allowed range.)
let actual_path = if selector_req.has_role_path {
selector_req.role_path.clone()
} else {
role_entry.display_path()
};
// ── Dispatch ───────────────────────────────────────────────
let response = crate::dispatcher::handle_request(dispatcher, request);
// Activity format: uid curve path (timestamp is added by the log).
let activity = format!("{} {} {}", caller.caller_id, curve, path);
// Activity format: uid role curve path (timestamp is added by the log).
let activity = format!(
"{} {} {} {}",
caller.identity_str(),
role_name,
curve,
actual_path
);
(response, activity)
}
@@ -358,16 +435,16 @@ impl ServerContext {
///
/// Rust's safe `UnixListener::bind` rejects paths containing null bytes,
/// so abstract sockets (sun_path[0] = '\0') must be bound via libc.
fn bind_abstract_unix(name: &str) -> Result<UnixListener, NsignerError> {
fn bind_abstract_unix(name: &str) -> Result<UnixListener, SignerError> {
use std::os::unix::io::FromRawFd;
if name.len() >= 107 {
return Err(NsignerError::InvalidInput);
return Err(SignerError::InvalidInput);
}
let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0) };
if fd < 0 {
return Err(NsignerError::IoFailed("socket() failed".into()));
return Err(SignerError::IoFailed("socket() failed".into()));
}
// Build sockaddr_un with abstract namespace (sun_path[0] = '\0')
@@ -391,14 +468,14 @@ fn bind_abstract_unix(name: &str) -> Result<UnixListener, NsignerError> {
if rc != 0 {
let err = std::io::Error::last_os_error();
unsafe { libc::close(fd) };
return Err(NsignerError::IoFailed(format!("bind: {}", err)));
return Err(SignerError::IoFailed(format!("bind: {}", err)));
}
let rc = unsafe { libc::listen(fd, 16) };
if rc != 0 {
let err = std::io::Error::last_os_error();
unsafe { libc::close(fd) };
return Err(NsignerError::IoFailed(format!("listen: {}", err)));
return Err(SignerError::IoFailed(format!("listen: {}", err)));
}
// Wrap the raw fd in a UnixListener
@@ -485,6 +562,40 @@ fn extract_method_and_selector(request: &str) -> Option<(String, SelectorRequest
Some((method, sel))
}
/// Extract the algorithm name and its standard derivation path from an
/// algorithm-verb request's options (for the activity log).
///
/// Returns `(algorithm_name, Some(path))` when the request carries a valid
/// algorithm; `(None, None)` otherwise.
fn extract_algorithm_and_path(request: &str) -> (Option<String>, Option<String>) {
let root: serde_json::Value = match serde_json::from_str(request) {
Ok(v) => v,
Err(_) => return (None, None),
};
let alg_str = root
.get("params")
.and_then(|p| p.as_array())
.and_then(|p| p.last())
.and_then(|o| o.get("algorithm"))
.and_then(|v| v.as_str());
let Some(alg_str) = alg_str else {
return (None, None);
};
let alg = crate::pq_crypto::CryptoAlg::from_str(alg_str);
if alg == crate::pq_crypto::CryptoAlg::Unknown {
return (Some(alg_str.to_string()), None);
}
let index = root
.get("params")
.and_then(|p| p.as_array())
.and_then(|p| p.last())
.and_then(|o| o.get("index"))
.and_then(|v| v.as_i64())
.unwrap_or(0) as i32;
let path = crate::alg_cache::standard_path(alg, index).ok();
(Some(alg.as_str().to_string()), path)
}
/// Build an auth error response.
fn make_auth_error(request: &str, code: i32, message: &str) -> String {
let id = extract_id(request);
@@ -527,3 +638,86 @@ fn extract_id(request: &str) -> String {
})
.unwrap_or_else(|| "null".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
/// Build the activity line for an algorithm-verb request, exactly as
/// `process_request` does.
fn activity_for(request: &str) -> String {
let (method, selector_req) =
extract_method_and_selector(request).expect("valid request");
assert!(crate::enforcement::is_algorithm_verb(&method));
let (alg_name, path) = extract_algorithm_and_path(request);
let caller_id = "uid:1000";
match (alg_name, path) {
(Some(a), Some(p)) => {
format!("{} {}({},{} {})", caller_id, method, a, selector_req.index, p)
}
(Some(a), None) => format!("{} {}({})", caller_id, method, a),
_ => format!("{} {}()", caller_id, method),
}
}
#[test]
fn test_activity_all_algorithms() {
// Every algorithm must produce a non-blank activity line with its
// curve name and standard derivation path.
let cases = [
("secp256k1", "m/44'/1237'/0'/0/0"),
("ed25519", "m/44'/102001'/0'/0'/0'"),
("x25519", "m/44'/102002'/0'/0'/0'"),
("ml-dsa-65", "m/44'/102003'/0'/0'/0'"),
("slh-dsa-128s", "m/44'/102004'/0'/0'/0'"),
("ml-kem-768", "m/44'/102005'/0'/0'/0'"),
];
for (alg, expected_path) in cases {
let req = format!(
r#"{{"id":"1","method":"get_public_key","params":[{{"algorithm":"{}","index":0}}]}}"#,
alg
);
let activity = activity_for(&req);
assert!(
activity.contains(alg),
"activity '{}' must contain algorithm '{}'",
activity,
alg
);
assert!(
activity.contains(expected_path),
"activity '{}' must contain path '{}'",
activity,
expected_path
);
assert!(
!activity.contains("- -"),
"activity '{}' must not contain the blank placeholder",
activity
);
}
}
#[test]
fn test_activity_index_substituted() {
let req = r#"{"id":"1","method":"sign","params":["00ff",{"algorithm":"ml-dsa-65","index":7}]}"#;
let activity = activity_for(req);
assert!(activity.contains("m/44'/102003'/7'/0'/0'"), "activity: {}", activity);
assert!(activity.contains("sign(ml-dsa-65,7"), "activity: {}", activity);
}
#[test]
fn test_activity_unknown_algorithm() {
let req = r#"{"id":"1","method":"sign","params":["00ff",{"algorithm":"bogus","index":0}]}"#;
let activity = activity_for(req);
// Unknown algorithm: name echoed, no path (dispatcher will reject).
assert!(activity.contains("sign(bogus)"), "activity: {}", activity);
}
#[test]
fn test_activity_missing_algorithm() {
let req = r#"{"id":"1","method":"sign","params":["00ff",{}]}"#;
let activity = activity_for(req);
assert!(activity.contains("sign()"), "activity: {}", activity);
}
}
+22 -22
View File
@@ -1,23 +1,23 @@
//! Socket naming — sequential abstract socket name generation.
//!
//! Generates names in the format `nsigner01`, `nsigner02`, … incrementing
//! Generates names in the format `signer01`, `signer02`, … incrementing
//! until an unused name is found (by checking /proc/net/unix).
//!
//! The `nsigner` prefix is required for compatibility with the C
//! `nsigner_client` / `nsigner_transport_list_unix`, which scans
//! /proc/net/unix for the literal prefix `@nsigner`.
//! The `signer` prefix is required for compatibility with the C
//! `signer_client` / `signer_transport_list_unix`, which scans
//! /proc/net/unix for the literal prefix `@signer`.
/// Prefix for generated socket names.
pub const SOCKET_NAME_PREFIX: &str = "nsigner";
pub const SOCKET_NAME_PREFIX: &str = "signer";
/// Generate a socket name: `nsigner01`, `nsigner02`, …
/// Generate a socket name: `signer01`, `signer02`, …
///
/// Scans /proc/net/unix for already-running nsigner sockets and picks the
/// Scans /proc/net/unix for already-running signer sockets and picks the
/// lowest unused number (starting at 1, zero-padded to 2 digits).
pub fn socket_name_random() -> Result<String, crate::NsignerError> {
pub fn socket_name_random() -> Result<String, crate::SignerError> {
let in_use = list_sockets();
// Try nsigner01, nsigner02, … up to nsigner99
// Try signer01, signer02, … up to signer99
for n in 1..=99u32 {
let candidate = format!("{}{:02}", SOCKET_NAME_PREFIX, n);
if !in_use.contains(&candidate) {
@@ -25,7 +25,7 @@ pub fn socket_name_random() -> Result<String, crate::NsignerError> {
}
}
// Fallback: nsigner100, nsigner101, … (no zero-padding beyond 99)
// Fallback: signer100, signer101, … (no zero-padding beyond 99)
for n in 100..=9999u32 {
let candidate = format!("{}{}", SOCKET_NAME_PREFIX, n);
if !in_use.contains(&candidate) {
@@ -33,22 +33,22 @@ pub fn socket_name_random() -> Result<String, crate::NsignerError> {
}
}
Err(crate::NsignerError::Internal(
"no available socket name (nsigner01..nsigner9999 all in use)".into(),
Err(crate::SignerError::Internal(
"no available socket name (signer01..signer9999 all in use)".into(),
))
}
/// List running nsigner abstract sockets by reading /proc/net/unix.
/// List running signer abstract sockets by reading /proc/net/unix.
///
/// Matches the C `nsigner_transport_list_unix` scan: looks for the literal
/// prefix `@nsigner` in the path column.
/// Matches the C `signer_transport_list_unix` scan: looks for the literal
/// prefix `@signer` in the path column.
pub fn list_sockets() -> Vec<String> {
let mut found = Vec::new();
if let Ok(content) = std::fs::read_to_string("/proc/net/unix") {
for line in content.lines() {
// Look for @nsigner prefix in the path column (matches C client scan)
if let Some(pos) = line.find("@nsigner") {
// Look for @signer prefix in the path column (matches C client scan)
if let Some(pos) = line.find("@signer") {
let rest = &line[pos + 1..]; // skip @
// Extract the name (up to whitespace or end of line)
let name: String = rest
@@ -65,14 +65,14 @@ pub fn list_sockets() -> Vec<String> {
found
}
/// Discover a single running nsigner socket.
/// Discover a single running signer socket.
/// Returns Ok(name) if exactly one is found, Err if zero or multiple.
pub fn discover_single_socket() -> Result<String, crate::NsignerError> {
pub fn discover_single_socket() -> Result<String, crate::SignerError> {
let sockets = list_sockets();
if sockets.len() == 1 {
Ok(sockets[0].clone())
} else {
Err(crate::NsignerError::NotFound)
Err(crate::SignerError::NotFound)
}
}
@@ -83,8 +83,8 @@ mod tests {
#[test]
fn test_socket_name_random() {
let name = socket_name_random().unwrap();
assert!(name.starts_with("nsigner"));
// Should be nsigner01..nsigner99 (8 chars) or nsigner100+ (9+ chars)
assert!(name.starts_with("signer"));
// Should be signer01..signer99 (8 chars) or signer100+ (9+ chars)
assert!(name.len() >= 8);
}
}
+6 -6
View File
@@ -10,7 +10,7 @@ use crate::key_store::KeyStore;
use crate::mnemonic::MnemonicState;
use crate::role_table::{RoleCurve, RolePurpose, RoleTable};
use crate::server::{AuthMode, ListenMode, ServerContext};
use crate::NsignerError;
use crate::SignerError;
use ratatui::layout::{Constraint, Layout, Rect, Spacing};
use ratatui::style::{Modifier, Style};
@@ -36,7 +36,7 @@ pub const TRANSPORT_HTTP: u8 = 0x08;
/// Qrexec service name used for the bridge transport.
/// Matches NSIGNER_QREXEC_SERVICE_NAME in n_signer/src/main.c.
pub const QREXEC_SERVICE_NAME: &str = "qubes.NsignerRpc";
pub const QREXEC_SERVICE_NAME: &str = "qubes.SignerRpc";
/// Check if a word is in the BIP-39 English wordlist.
fn is_valid_bip39_word(word: &str) -> bool {
@@ -535,7 +535,7 @@ impl App {
}
/// Start the server for the currently selected transport.
fn start_server(&mut self) -> Result<(), NsignerError> {
fn start_server(&mut self) -> Result<(), SignerError> {
// Pick the first active transport by priority (Unix > Qrexec > TCP > HTTP).
let (listen_mode, server_name) = if self.transport_toggles[0] {
(ListenMode::Unix, self.socket_name.clone())
@@ -652,7 +652,7 @@ impl App {
self.transport_mask_apply();
match self.start_server() {
Ok(()) => {
self.activity_log.add("nsigner started");
self.activity_log.add("signer started");
}
Err(e) => {
self.activity_log.add(&format!("server start failed: {}", e));
@@ -781,7 +781,7 @@ impl App {
}
match self.start_server() {
Ok(()) => {
self.activity_log.add("nsigner started");
self.activity_log.add("signer started");
}
Err(e) => {
self.activity_log.add(&format!("server start failed: {}", e));
@@ -1484,7 +1484,7 @@ impl App {
// client command that callers use to reach the signer,
// matching the connection info in n_signer's main.c.
lines.push(Line::from(format!(" qrexec service: {}", QREXEC_SERVICE_NAME)));
lines.push(Line::from(format!(" nsigner_client --qrexec <qube>:{}", QREXEC_SERVICE_NAME)));
lines.push(Line::from(format!(" signer_client --qrexec <qube>:{}", QREXEC_SERVICE_NAME)));
} else {
lines.push(Line::from(" (inactive)"));
}
+15 -15
View File
@@ -4,7 +4,7 @@
//! caller sends a request with a role name → selector resolution → dispatch.
//! No policy table, no approval prompt. Knowing a valid role name is sufficient.
use nsigner::{
use signer::{
alg_cache::AlgorithmKeyCache,
dispatcher::DispatcherContext,
key_store::KeyStore,
@@ -85,15 +85,15 @@ fn spawn_server_loop(
/// Send a framed request to a Unix socket and return the response.
fn send_request(socket_name: &str, request: &str) -> String {
let mut stream = nsigner::transport::connect_abstract_unix(socket_name).unwrap();
nsigner::transport::send_framed(&mut stream, request).unwrap();
nsigner::transport::recv_framed(&mut stream).unwrap()
let mut stream = signer::transport::connect_abstract_unix(socket_name).unwrap();
signer::transport::send_framed(&mut stream, request).unwrap();
signer::transport::recv_framed(&mut stream).unwrap()
}
/// Wait for the server socket to be ready.
fn wait_for_server(socket_name: &str, attempts: u32) {
for _ in 0..attempts {
if nsigner::transport::connect_abstract_unix(socket_name).is_ok() {
if signer::transport::connect_abstract_unix(socket_name).is_ok() {
return;
}
std::thread::sleep(Duration::from_millis(20));
@@ -103,7 +103,7 @@ fn wait_for_server(socket_name: &str, attempts: u32) {
#[test]
fn test_get_info_works() {
let socket_name = format!("nsigner_test_info_{}", std::process::id());
let socket_name = format!("signer_test_info_{}", std::process::id());
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache);
@@ -113,14 +113,14 @@ fn test_get_info_works() {
let resp = send_request(&socket_name, r#"{"id":"1","method":"get_info","params":[]}"#);
assert!(resp.contains("\"result\""), "get_info failed: {}", resp);
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
let _ = signer::transport::connect_abstract_unix(&socket_name);
stop.store(true, std::sync::atomic::Ordering::SeqCst);
handle.join().ok();
}
#[test]
fn test_role_as_password_allows_with_valid_role() {
let socket_name = format!("nsigner_test_allow_{}", std::process::id());
let socket_name = format!("signer_test_allow_{}", std::process::id());
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache);
@@ -136,14 +136,14 @@ fn test_role_as_password_allows_with_valid_role() {
assert!(resp.contains("e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f"),
"expected pubkey in result, got: {}", resp);
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
let _ = signer::transport::connect_abstract_unix(&socket_name);
stop.store(true, std::sync::atomic::Ordering::SeqCst);
handle.join().ok();
}
#[test]
fn test_unknown_role_returns_selector_error() {
let socket_name = format!("nsigner_test_unknown_{}", std::process::id());
let socket_name = format!("signer_test_unknown_{}", std::process::id());
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache);
@@ -156,14 +156,14 @@ fn test_unknown_role_returns_selector_error() {
);
assert!(resp.contains("unknown_role"), "expected unknown_role, got: {}", resp);
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
let _ = signer::transport::connect_abstract_unix(&socket_name);
stop.store(true, std::sync::atomic::Ordering::SeqCst);
handle.join().ok();
}
#[test]
fn test_ed25519_sign_allowed_no_authorization() {
let socket_name = format!("nsigner_test_alg_{}", std::process::id());
let socket_name = format!("signer_test_alg_{}", std::process::id());
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache);
@@ -179,14 +179,14 @@ fn test_ed25519_sign_allowed_no_authorization() {
assert!(resp.contains("\"result\""), "expected success, got: {}", resp);
assert!(resp.contains("signature"), "expected signature in result: {}", resp);
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
let _ = signer::transport::connect_abstract_unix(&socket_name);
stop.store(true, std::sync::atomic::Ordering::SeqCst);
handle.join().ok();
}
#[test]
fn test_repeated_requests_all_allowed() {
let socket_name = format!("nsigner_test_repeat_{}", std::process::id());
let socket_name = format!("signer_test_repeat_{}", std::process::id());
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache);
@@ -202,7 +202,7 @@ fn test_repeated_requests_all_allowed() {
assert!(resp.contains("\"result\""), "request {} failed: {}", i, resp);
}
let _ = nsigner::transport::connect_abstract_unix(&socket_name);
let _ = signer::transport::connect_abstract_unix(&socket_name);
stop.store(true, std::sync::atomic::Ordering::SeqCst);
handle.join().ok();
}
+104
View File
@@ -0,0 +1,104 @@
//! Cross-implementation PQ keygen conformance.
//!
//! Validates the v2 seeded derivation against the pinned vectors from
//! nostr_quantum_preparation (`test/vectors/seed-to-pubkeys.v2.json`):
//! the same fixed mnemonic must produce the same PQ public keys in the
//! web app (noble) and here (RustCrypto crates).
//!
//! The test is skipped (passes with a note) when the vector file does not
//! exist yet — coordinate generation with the web app project
//! (`test/vectors/generate-vectors.mjs`).
use signer::pq_crypto;
const VECTOR_PATH: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../nostr_quantum_preparation/test/vectors/seed-to-pubkeys.v2.json"
);
#[derive(serde::Deserialize)]
struct Vector {
mnemonic: String,
#[serde(rename = "derivedPublicKeys")]
derived_public_keys: DerivedPublicKeys,
}
#[derive(serde::Deserialize)]
struct DerivedPublicKeys {
#[serde(rename = "ml-dsa-65")]
ml_dsa_65: KeyEntry,
#[serde(rename = "slh-dsa-128s")]
slh_dsa_128s: KeyEntry,
#[serde(rename = "ml-kem-768")]
ml_kem_768: KeyEntry,
}
#[derive(serde::Deserialize)]
struct KeyEntry {
#[serde(rename = "derivationPath")]
derivation_path: String,
#[serde(rename = "publicKeyHex")]
public_key_hex: String,
}
/// Extract the base path (strip the trailing leaf index and any sibling
/// notation like "+ m/44'/102004'/0'/0'/1'").
fn base_path(full: &str) -> String {
full.split(" + ").next().unwrap_or(full).to_string()
}
#[test]
fn pq_pubkeys_match_web_app_v2_vectors() {
let data = match std::fs::read_to_string(VECTOR_PATH) {
Ok(d) => d,
Err(_) => {
eprintln!("SKIP: {VECTOR_PATH} not found — generate it in nostr_quantum_preparation");
return;
}
};
let vector: Vector = serde_json::from_str(&data)
.expect("valid v2 vector JSON");
// ML-DSA-65: 32-byte seed from one child.
let seed = pq_crypto::derive_pq_seed_from_path(
&vector.mnemonic,
&base_path(&vector.derived_public_keys.ml_dsa_65.derivation_path),
32,
)
.unwrap();
let seed_arr: [u8; 32] = seed.as_slice().try_into().unwrap();
let (_, pub_key) = pq_crypto::ml_dsa_65_keygen_from_seed(&seed_arr).unwrap();
assert_eq!(
hex::encode(&pub_key),
vector.derived_public_keys.ml_dsa_65.public_key_hex,
"ml-dsa-65 pubkey must match the web app vector"
);
// SLH-DSA-128s: 48-byte seed from two children (first 48 of 64).
let seed = pq_crypto::derive_pq_seed_from_path(
&vector.mnemonic,
&base_path(&vector.derived_public_keys.slh_dsa_128s.derivation_path),
48,
)
.unwrap();
let (_, pub_key) = pq_crypto::slh_dsa_128s_keygen_from_seed(&seed).unwrap();
assert_eq!(
hex::encode(&pub_key),
vector.derived_public_keys.slh_dsa_128s.public_key_hex,
"slh-dsa-128s pubkey must match the web app vector"
);
// ML-KEM-768: 64-byte seed from two children.
let seed = pq_crypto::derive_pq_seed_from_path(
&vector.mnemonic,
&base_path(&vector.derived_public_keys.ml_kem_768.derivation_path),
64,
)
.unwrap();
let (_, pub_key) = pq_crypto::ml_kem_768_keygen_from_seed(&seed).unwrap();
assert_eq!(
hex::encode(&pub_key),
vector.derived_public_keys.ml_kem_768.public_key_hex,
"ml-kem-768 pubkey must match the web app vector"
);
}