8 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
25 changed files with 3256 additions and 169 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
+3 -3
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",
@@ -2207,7 +2207,7 @@ dependencies = [
[[package]]
name = "signer"
version = "0.0.14"
version = "0.0.22"
dependencies = [
"base64",
"chacha20poly1305",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "signer"
version = "0.0.15"
version = "0.0.23"
edition = "2021"
license = "MIT"
description = "Attended Nostr signing daemon — Rust port of n_signer"
+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}"
+36 -17
View File
@@ -3,11 +3,12 @@ set -e
# signer (Rust) — Local Deploy Script
#
# Builds release binaries and installs them to /usr/local/bin/.
# Builds binaries and installs them to /usr/local/bin/.
#
# USAGE:
# ./deploy_local.sh # build release + install
# ./deploy_local.sh --debug # build debug + install
# ./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:
@@ -29,6 +30,7 @@ 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"
@@ -37,7 +39,8 @@ show_usage() {
echo " $0 [OPTIONS]"
echo ""
echo "OPTIONS:"
echo " --debug Build debug profile instead of release"
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/:"
@@ -47,7 +50,18 @@ show_usage() {
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"
@@ -78,14 +92,20 @@ if ! grep -q 'name = "signer"' Cargo.toml || ! grep -q 'name = "signer-client"'
fi
# Build
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"
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
@@ -106,19 +126,18 @@ fi
install_binary() {
local src="$1"
local name
name=$(basename "$src")
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"
install_binary "$CLIENT_BIN"
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} profile)"
print_success "Local deploy completed (${PROFILE} ${BUILD_KIND} profile)"
+11 -10
View File
@@ -169,18 +169,19 @@ verify_binary_version() {
}
build_release_binary() {
print_status "Building release binaries (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/signer"
local bin_path="dist/signer"
verify_binary_version "$bin_path" "$NEW_VERSION" || return 1
local client_path="target/release/signer-client"
local client_path="dist/signer_client"
if [[ -f "$client_path" ]]; then
print_success "Release binary built: $client_path"
verify_binary_version "$client_path" "$NEW_VERSION" || return 1
print_success "Portable client binary built: $client_path"
fi
print_success "Release binary built: $bin_path"
print_success "Portable signer binary built: $bin_path"
return 0
}
@@ -284,8 +285,8 @@ main() {
git_commit_and_push
local binary_path="target/release/signer"
local client_path="target/release/signer-client"
local binary_path="dist/signer"
local client_path="dist/signer_client"
local tarball_path=""
tarball_path=$(create_source_tarball || true)
@@ -303,8 +304,8 @@ main() {
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
-F "attachment=@$client_path;filename=signer_client" \
-F "name=signer_client" > /dev/null
fi
fi
+19 -7
View File
@@ -15,11 +15,15 @@ set -euo pipefail
#
# 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_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"
@@ -41,9 +45,11 @@ Options:
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_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
@@ -102,7 +108,7 @@ resolve_signer_version() {
}
# Resolve a release asset URL by asset name suffix.
# $1 = asset name to match exactly (e.g. "signer" or "signer-client")
# $1 = asset name to match exactly (e.g. "signer-linux-x86_64-musl")
download_signer_asset_url() {
local asset_name="$1"
local headers=()
@@ -162,6 +168,12 @@ download_binary() {
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."
@@ -186,16 +198,16 @@ install_signer() {
log "Installing signer ${SIGNER_VERSION}"
log "Release page: ${release_page}"
# Install the signer daemon binary
download_binary "signer" "SIGNER_BINARY_URL" "${PREFIX_BIN}/signer"
# 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" >/dev/null 2>&1; then
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" "SIGNER_CLIENT_BINARY_URL" "${PREFIX_BIN}/signer-client"
download_binary "${SIGNER_CLIENT_BINARY_ASSET}" "SIGNER_CLIENT_BINARY_URL" "${PREFIX_BIN}/signer-client"
log "Installed ${PREFIX_BIN}/signer-client from release binary"
fi
}
+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.
+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.
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"
+83 -17
View File
@@ -64,17 +64,7 @@ impl AlgorithmKeyCache {
let phrase = mnemonic.phrase().ok_or(SignerError::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(SignerError::InvalidInput),
};
let path = standard_path(alg, index)?;
let entry = derive_alg_key(phrase, &path, alg, index)?;
self.entries.push(entry);
Ok(())
@@ -92,6 +82,22 @@ 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,
@@ -103,7 +109,21 @@ fn derive_alg_key(
.sizes()
.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 => {
@@ -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(SignerError::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::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,
})
}
+234 -17
View File
@@ -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,18 +629,26 @@ fn is_nostr_verb(verb: &str) -> bool {
)
}
fn sign_with_alg(alg: CryptoAlg, priv_key: &[u8; 32], msg: &[u8]) -> Result<Vec<u8>, SignerError> {
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),
@@ -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);
}
}
+78 -7
View File
@@ -133,10 +133,12 @@ impl KeyStore {
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);
@@ -301,8 +303,15 @@ fn derive_for_role(
.sizes()
.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 => {
@@ -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,9 +395,67 @@ fn derive_for_role(
valid: true,
})
}
CryptoAlg::MlDsa65 | CryptoAlg::SlhDsa128s | CryptoAlg::MlKem768 => {
// PQ algorithms — TODO: Phase 13
Err(SignerError::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::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),
}
+1 -1
View File
@@ -31,4 +31,4 @@ pub mod error;
pub use error::SignerError;
/// Version string (matches C NSIGNER_VERSION).
pub const VERSION: &str = "v0.0.15";
pub const VERSION: &str = "v0.0.23";
+6 -2
View File
@@ -241,7 +241,9 @@ fn run_headless(cli: &Cli, listen_mode: ListenMode) -> Result<(), SignerError> {
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<(), SignerError> {
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));
}
+383 -60
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,26 +79,47 @@ 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,
@@ -97,16 +130,14 @@ pub fn derive_seed_from_mnemonic(
let path_indices = nips::nip006::parse_bip44_path(path)
.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::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::SignerError::KeyDerivationFailed)?;
@@ -114,6 +145,60 @@ pub fn derive_seed_from_mnemonic(
}
}
/// 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};
@@ -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::SignerError> {
Err(crate::SignerError::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::SignerError> {
Err(crate::SignerError::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::SignerError> {
Err(crate::SignerError::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::SignerError> {
Err(crate::SignerError::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::SignerError> {
Err(crate::SignerError::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::SignerError> {
Err(crate::SignerError::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::SignerError> {
Err(crate::SignerError::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}};
+3
View File
@@ -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,
+206 -12
View File
@@ -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.
@@ -159,7 +191,7 @@ impl ServerContext {
// 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);
@@ -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)
}
@@ -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);
}
}
+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"
);
}