Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e7e178c0f | ||
|
|
892abe0ccf | ||
|
|
782716c53b | ||
|
|
b2f5d06570 | ||
|
|
03a977b774 | ||
|
|
9c762edbd2 |
@@ -0,0 +1,5 @@
|
||||
signer/target
|
||||
signer/dist
|
||||
signer/.git
|
||||
nostr_core_lib_rust/target
|
||||
nostr_core_lib_rust/.git
|
||||
@@ -1,3 +1,4 @@
|
||||
/target/
|
||||
/dist/
|
||||
*.log
|
||||
*.tar.gz
|
||||
|
||||
Generated
+3
-3
@@ -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.17"
|
||||
version = "0.0.22"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chacha20poly1305",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "signer"
|
||||
version = "0.0.17"
|
||||
version = "0.0.23"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Attended Nostr signing daemon — Rust port of n_signer"
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+83
-17
@@ -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
@@ -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
@@ -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
@@ -31,4 +31,4 @@ pub mod error;
|
||||
pub use error::SignerError;
|
||||
|
||||
/// Version string (matches C NSIGNER_VERSION).
|
||||
pub const VERSION: &str = "v0.0.17";
|
||||
pub const VERSION: &str = "v0.0.23";
|
||||
|
||||
+6
-2
@@ -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
@@ -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
@@ -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}};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+180
-10
@@ -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.
|
||||
@@ -294,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
|
||||
@@ -308,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);
|
||||
}
|
||||
};
|
||||
@@ -316,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);
|
||||
}
|
||||
|
||||
@@ -342,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()
|
||||
);
|
||||
@@ -351,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)
|
||||
}
|
||||
|
||||
@@ -509,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);
|
||||
@@ -551,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user