chore: initial foundation — server skeleton, autodocs, CI

- minimal Axum server exposing GET /health
- OpenAPI contract generated from handlers (gen_openapi -> openapi.json),
  drift-gated in CI so the spec can never silently diverge from the code
- CI: secret-scan + gitleaks, openapi drift gate, fmt/clippy/build/test/doc-test, cargo-deny
- Cargo.lock tracked for reproducible builds; publish=false; pinned toolchain
- README, SECURITY.md, deny.toml, .env.example
This commit is contained in:
jwinterm
2026-06-22 06:18:57 -04:00
commit 143f00701b
17 changed files with 4958 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
# smirk-backend-core — example environment.
#
# Copy to `.env` and fill in for your deployment. NEVER commit a real `.env`.
# Generate secrets with: openssl rand -hex 32
# ── Server ────────────────────────────────────────────────────────────────
SERVER_HOST=0.0.0.0
SERVER_PORT=8080
# Public absolute API base URL. REQUIRED when admin/erasure features are enabled:
# it is the canonical value the NIP-98 `u` tag is verified against (never the
# request Host header). Boot fails if unset while those features are on.
PUBLIC_API_URL=https://backend.example.org/api/v1
ENVIRONMENT=development
# Stable per-node identifier. Bound into signed admin/erasure actions so a proof
# captured against one node cannot be replayed across a load-balanced fleet.
INSTANCE_ID=
# ── Database ──────────────────────────────────────────────────────────────
DATABASE_URL=postgres://user:password@127.0.0.1:5432/smirk
# ── Auth (JWT) ────────────────────────────────────────────────────────────
JWT_SECRET=
JWT_EXPIRY_HOURS=24
# ── Proof-of-work signup gate (ALTCHA v2) ─────────────────────────────────
ALTCHA_HMAC_KEY=
POW_REQUIRED=false
ALTCHA_COST=100000
# ── Bitcoin ───────────────────────────────────────────────────────────────
BTC_NETWORK=mainnet
BTC_USE_LOCAL_NODE=false
BTC_RPC_URL=http://127.0.0.1:8332
BTC_RPC_USER=bitcoinrpc
BTC_RPC_PASS=
BTC_ELECTRUM_URL=
BTC_ELECTRUM_FALLBACKS=
# ── Litecoin ──────────────────────────────────────────────────────────────
LTC_NETWORK=mainnet
LTC_USE_LOCAL_NODE=false
LTC_RPC_URL=http://127.0.0.1:9332
LTC_RPC_USER=litecoinrpc
LTC_RPC_PASS=
LTC_ELECTRUM_URL=
LTC_ELECTRUM_FALLBACKS=
# ── Monero (daemon + light wallet server) ─────────────────────────────────
XMR_LWS_URL=http://127.0.0.1:8443
XMR_LWS_ADMIN_URL=http://127.0.0.1:9443
XMR_LWS_ADMIN_KEY=
XMR_DAEMON_URL=http://127.0.0.1:18081
# ── Wownero (daemon + light wallet server) ────────────────────────────────
WOW_LWS_URL=http://127.0.0.1:18443
WOW_LWS_ADMIN_URL=http://127.0.0.1:19443
WOW_LWS_ADMIN_KEY=
WOW_DAEMON_URL=http://127.0.0.1:34568
# ── Grin (wallet owner/foreign API + node API) ────────────────────────────
GRIN_OWNER_API_URL=http://127.0.0.1:3420/v3/owner
GRIN_OWNER_API_SECRET=
GRIN_WALLET_PASSWORD=
GRIN_FOREIGN_API_URL=http://127.0.0.1:3415/v2/foreign
GRIN_NODE_API_URL=http://127.0.0.1:3413/v2/owner
GRIN_NODE_API_USER=grin
GRIN_NODE_API_PASS=
GRIN_NODE_FOREIGN_API_URL=http://127.0.0.1:3413/v2/foreign
GRIN_NODE_FOREIGN_API_SECRET=
# ── Feature flags (each feature ships in the open core, toggled per deployment) ──
# The wallet client reads GET /api/v1/capabilities and greys out disabled features.
FEATURE_PRICES=true
PRICES_PROVIDER=coingecko
PRICES_FETCH_INTERVAL_SECS=300
# Tips are parked for the conditional-transfer redesign; off until that lands.
FEATURE_TIPS=false
# ── Admin surface (see docs; default posture is loopback/Tor) ──────────────
# Admin auth is "Sign in with Smirk": only admin PUBLIC keys are stored here,
# never a seed or private key. The allowlist is mutated CLI/loopback-only in v1.
ADMIN_ENABLED=false
ADMIN_BIND=127.0.0.1:8081
ADMIN_JWT_SECRET=
# Integrity MAC secret protecting admin/setup trust anchors against DB tampering.
# Losing this locks out admin — back it up (see docs/operations).
ADMIN_KEY_INTEGRITY_SECRET=
ADMIN_MAX_KEYS=8
ADMIN_PENDING_KEY_TTL_DAYS=7
# ── Public landing page (OFF by default; full + per-field tunable when on) ──
PUBLIC_LANDING_ENABLED=false
PUBLIC_EXPOSE_FEATURES=false
PUBLIC_EXPOSE_UPTIME=false
PUBLIC_STATS_ENABLED=false
PUBLIC_STATS_CACHE_HOURS=24
# ── Data retention / erasure ──────────────────────────────────────────────
ERASURE_ENABLED=false
ERASURE_PURGE_LOGIN_EVENTS=true
ERASURE_EXPORT_PER_DAY=3
# Log retention (auto-deletion); 0 = keep indefinitely.
RETENTION_LOGIN_EVENTS_DAYS=90
RETENTION_AUDIT_DAYS=365
+59
View File
@@ -0,0 +1,59 @@
name: ci
on:
push:
pull_request:
env:
CARGO_TERM_COLOR: always
jobs:
secret-scan:
name: secret scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: key / seed material
run: ./tools/secret-scan.sh
- name: gitleaks (full history)
run: |
curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.18.4/gitleaks_8.18.4_linux_x64.tar.gz \
| tar -xz gitleaks
sudo mv gitleaks /usr/local/bin/
gitleaks detect --no-banner --redact --source .
openapi-drift:
name: openapi drift gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Regenerate the spec; fail if it drifts from committed openapi.json
run: |
cargo run --quiet --bin gen_openapi > /tmp/openapi.json
diff openapi.json /tmp/openapi.json
rust:
name: fmt / clippy / build / test / doc-test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- run: cargo fmt --all -- --check
- run: cargo clippy --all-targets -- -D warnings
- run: cargo build --all-targets --locked
- run: cargo test --locked
- run: cargo test --doc
supply-chain:
name: cargo-deny
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: EmbarkStudios/cargo-deny-action@v2
+32
View File
@@ -0,0 +1,32 @@
# Rust build artifacts
/target
**/*.rs.bk
# Cargo.lock IS tracked — this is a deployable binary, so we want reproducible builds.
# (Do not ignore it.)
# Environment / secrets — never commit anything but the example.
.env
.env.*
!.env.example
*.key
*.pem
*.p12
secrets/
secret_store/
# Runtime / data
*.log
data/
*.sqlite
*.db
# Tor hidden-service keys (admin onion) must never enter the repo.
tor/
hs_ed25519_secret_key
# Editor / OS
.DS_Store
.idea/
.vscode/
*.swp
Generated
+4324
View File
File diff suppressed because it is too large Load Diff
+115
View File
@@ -0,0 +1,115 @@
[package]
name = "smirk-backend-core"
version = "0.3.0"
edition = "2021"
authors = ["John Winter Murphy <jw@such.software>"]
description = "Open, self-hostable backend for the Smirk non-custodial multi-chain wallet"
repository = "https://github.com/smirk-cash/smirk-backend-core"
# license: TBD — deciding MIT vs AGPL-3.0-or-later (see README). Left unset until chosen.
# Not a library for crates.io; this is a deployable server. Guard against accidental publish.
publish = false
[lib]
name = "smirk_backend_core"
path = "src/lib.rs"
[[bin]]
name = "smirk-backend-core"
path = "src/main.rs"
# Additional binaries (gen_openapi, smirk-admin, ...) are auto-discovered from src/bin/*.rs.
[dependencies]
# Async Runtime
tokio = { version = "1.35", features = ["full"] }
# Web Framework
axum = { version = "0.7", features = ["ws", "macros"] }
axum-extra = { version = "0.9", features = ["typed-header"] }
tower = { version = "0.4", features = ["limit", "timeout", "load-shed", "util"] }
tower-http = { version = "0.5", features = ["cors", "trace", "compression-gzip"] }
tower_governor = "0.4"
governor = "0.6"
# Database
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "ipnetwork"] }
ipnetwork = "0.20"
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# OpenAPI generation (autodocs): the public API spec is derived from the handlers
# via utoipa, then drives the generated TypeScript client. Single source of truth.
utoipa = { version = "5", features = ["chrono", "uuid"] }
# HTTP Client
reqwest = { version = "0.11", features = ["json", "rustls-tls"] }
# Cryptography
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
jsonwebtoken = "9.2"
rand = "0.8"
uuid = { version = "1.6", features = ["v4", "serde"] }
urlencoding = "2.1"
base64 = "0.22"
aes-gcm = "0.10"
# Proof-of-work gate for wallet registration (ALTCHA v2, PBKDF2/SHA-256).
altcha = "0.1"
# Constant-time byte comparison for token/secret verification. Never use == on secrets.
subtle = "2.5"
# secp256k1 for Grin ECDH/ECDSA and Nostr (NIP-98) BIP-340 schnorr verification.
k256 = { version = "0.13", features = ["ecdh", "ecdsa", "schnorr"] }
# Ed25519 for website auth signature verification.
ed25519-dalek = "2.1"
# Bitcoin/Litecoin RPC
bitcoincore-rpc = "0.18"
# Electrum protocol (balance/UTXO/history via public or self-hosted servers)
tokio-rustls = "0.25"
rustls = "0.22"
rustls-pki-types = "1.0"
webpki-roots = "0.26"
bs58 = "0.5"
# Monero/Wownero address parsing
monero = "0.20"
# ZeroMQ (LWS real-time 0-conf notifications)
zeromq = "0.4"
# MessagePack (LWS msgpack wire format)
rmp-serde = "1.3"
# URL parsing
url = "2.5"
# Configuration
dotenvy = "0.15"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
# Time
chrono = { version = "0.4", features = ["serde"] }
# Async utilities
futures = "0.3"
async-trait = "0.1"
# Validation
validator = { version = "0.16", features = ["derive"] }
[dev-dependencies]
tokio-test = "0.4"
mockall = "0.12"
[profile.release]
lto = true
codegen-units = 1
panic = "unwind"
strip = true
+51
View File
@@ -0,0 +1,51 @@
# smirk-backend-core
Open, self-hostable backend for the [Smirk](https://smirk.cash) non-custodial,
multi-chain wallet. It provides the chain-access and identity surface a Smirk
wallet needs — authentication, per-chain wallet access (Bitcoin, Litecoin,
Monero, Wownero, Grin), the Grin slatepack relay, and Nostr-based identity —
so anyone can run their own backend and a Smirk wallet can connect to it.
> **Status: early, under active development.** Interfaces and the database
> schema may change before the first tagged release. Not yet recommended for
> production self-hosting.
## What it is
- **Non-custodial.** The server never holds spend keys or seeds. It brokers
access to blockchain data and relays wallet-to-wallet messages.
- **Open core, feature-flagged.** Every feature ships in this codebase and is
toggled per deployment; the wallet reads `GET /api/v1/capabilities` and
adapts to what a given backend offers.
- **Identity via "Sign in with Smirk".** Authentication uses a seed-derived
Nostr key (NIP-98); the server stores only public keys.
- **Privacy first.** No PII is required to use it; operator and user privacy
are explicit design goals.
## Architecture
Rust + [Axum](https://github.com/tokio-rs/axum), PostgreSQL via
[sqlx](https://github.com/launchbadge/sqlx). The public HTTP contract is
generated from the handlers with [utoipa](https://github.com/juhaku/utoipa)
(OpenAPI), which in turn drives the wallet's generated TypeScript client —
one source of truth, checked for drift in CI.
## Quickstart
```sh
cp .env.example .env # then fill in DATABASE_URL, JWT_SECRET, node URLs
cargo run # starts the API server
```
A full self-hosting guide (node requirements, reverse proxy, Tor admin onion,
backups) will live in `docs/` as it stabilises.
## Security
Please report vulnerabilities privately — see [SECURITY.md](SECURITY.md).
Do not open public issues for security reports.
## License
To be finalized (MIT vs AGPL-3.0-or-later). Until a `LICENSE` file is added,
all rights are reserved.
+28
View File
@@ -0,0 +1,28 @@
# Security Policy
## Reporting a vulnerability
Please report security issues **privately**. Do not open public issues or pull
requests for vulnerabilities.
- Email: security@smirk.cash
- Encrypt sensitive reports with our PGP key (fingerprint published at
`https://smirk.cash/.well-known/security.txt`).
Include enough detail to reproduce: affected endpoint/component, version or
commit, and a proof of concept where possible. We aim to acknowledge reports
within 72 hours and to keep you updated as we triage and fix.
## Scope
This repository (`smirk-backend-core`) — the self-hostable backend. Issues in
node software it talks to (Bitcoin/Litecoin/Monero/Wownero/Grin daemons,
light-wallet servers, Electrum) should be reported to those projects, though
we welcome reports about how this backend uses them.
## Operating securely
`smirk-backend-core` is non-custodial: it never stores seeds or spend keys.
The admin surface authenticates with seed-derived public keys ("Sign in with
Smirk") and stores only public keys. Default deployment posture binds admin and
setup to loopback / a Tor hidden service. See `docs/` for the hardening guide.
+36
View File
@@ -0,0 +1,36 @@
# cargo-deny configuration — supply-chain gate for the public backend.
# Run locally with: cargo deny check
[advisories]
version = 2
# Fail on known-vulnerable or yanked crates (RUSTSEC advisory DB).
yanked = "deny"
[bans]
# Duplicate versions bloat the build and the audit surface; surface but don't block.
multiple-versions = "warn"
wildcards = "deny"
[licenses]
version = 2
# Permissive + weak-copyleft licenses we accept in the dependency tree.
allow = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Unicode-DFS-2016",
"Unicode-3.0",
"Zlib",
"MPL-2.0",
"CC0-1.0",
"OpenSSL",
]
confidence-threshold = 0.9
[sources]
# Only crates.io; no unvetted git or alternate registries.
unknown-registry = "deny"
unknown-git = "deny"
+62
View File
@@ -0,0 +1,62 @@
{
"openapi": "3.1.0",
"info": {
"title": "smirk-backend-core",
"description": "Open, self-hostable backend for the Smirk non-custodial multi-chain wallet.",
"contact": {
"name": "John Winter Murphy",
"email": "jw@such.software"
},
"license": {
"name": ""
},
"version": "0.3.0"
},
"paths": {
"/health": {
"get": {
"tags": [
"system"
],
"summary": "Liveness probe.",
"description": "Returns `200` with `{ \"status\": \"ok\" }` whenever the server is up. Used by\nload balancers and uptime checks; requires no authentication.",
"operationId": "health",
"responses": {
"200": {
"description": "Service is up",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HealthResponse"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"HealthResponse": {
"type": "object",
"description": "Liveness response.",
"required": [
"status"
],
"properties": {
"status": {
"type": "string",
"description": "Always `\"ok\"` when the service is reachable."
}
}
}
}
},
"tags": [
{
"name": "system",
"description": "Service health and metadata."
}
]
}
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "stable"
components = ["rustfmt", "clippy"]
+27
View File
@@ -0,0 +1,27 @@
//! Health / liveness endpoint.
use axum::Json;
use serde::Serialize;
/// Liveness response.
#[derive(Serialize, utoipa::ToSchema)]
pub struct HealthResponse {
/// Always `"ok"` when the service is reachable.
pub status: String,
}
/// Liveness probe.
///
/// Returns `200` with `{ "status": "ok" }` whenever the server is up. Used by
/// load balancers and uptime checks; requires no authentication.
#[utoipa::path(
get,
path = "/health",
responses((status = 200, description = "Service is up", body = HealthResponse)),
tag = "system"
)]
pub async fn health() -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok".to_string(),
})
}
+4
View File
@@ -0,0 +1,4 @@
//! HTTP API layer: handlers and the OpenAPI assembly.
pub mod health;
pub mod openapi;
+21
View File
@@ -0,0 +1,21 @@
//! OpenAPI specification assembly.
//!
//! `ApiDoc` aggregates the annotated handlers into the spec that is the single
//! source of truth for the API contract and the wallet's generated TypeScript
//! client. Add a path here once its handler carries `#[utoipa::path(...)]` and
//! its request/response types derive `utoipa::ToSchema`.
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
info(
title = "smirk-backend-core",
version = "0.3.0",
description = "Open, self-hostable backend for the Smirk non-custodial multi-chain wallet."
),
paths(crate::api::health::health),
components(schemas(crate::api::health::HealthResponse)),
tags((name = "system", description = "Service health and metadata."))
)]
pub struct ApiDoc;
+20
View File
@@ -0,0 +1,20 @@
//! Emit the OpenAPI 3 specification to stdout.
//!
//! The spec is derived from the handlers (`utoipa` annotations) and is the
//! single source of truth for the API contract and the generated TypeScript
//! client. Regenerate the committed contract with:
//!
//! cargo run --bin gen_openapi > openapi.json
//!
//! CI regenerates and diffs against the committed `openapi.json`; a drift is a
//! failed build, not a stale doc.
use smirk_backend_core::api::openapi::ApiDoc;
use utoipa::OpenApi;
fn main() {
let spec = ApiDoc::openapi()
.to_pretty_json()
.expect("serialize OpenAPI spec");
println!("{spec}");
}
+11
View File
@@ -0,0 +1,11 @@
//! smirk-backend-core
//!
//! Open, self-hostable backend for the Smirk non-custodial multi-chain wallet:
//! authentication, per-chain wallet access (Bitcoin, Litecoin, Monero, Wownero,
//! Grin), the Grin slatepack relay, and Nostr-based identity.
//!
//! The HTTP contract is generated from the handlers (`utoipa`) into
//! `openapi.json`, which is the single source of truth for the API and the
//! wallet's generated TypeScript client.
pub mod api;
+23
View File
@@ -0,0 +1,23 @@
//! smirk-backend-core server entry point.
//!
//! Minimal server skeleton. Configuration, application state, the full router,
//! and background tasks are added as modules land; for now it serves the
//! health endpoint so the build, the OpenAPI contract, and deployment wiring
//! are exercised end to end.
use axum::{routing::get, Router};
use smirk_backend_core::api::health;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let app = Router::new().route("/health", get(health::health));
let addr = "0.0.0.0:8080";
let listener = tokio::net::TcpListener::bind(addr)
.await
.expect("bind listener");
tracing::info!("smirk-backend-core listening on http://{addr}");
axum::serve(listener, app).await.expect("server error");
}
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
#
# secret-scan: block private keys and seed material from entering the tree.
#
# Public-safe by design: it matches on secret *shapes* (key blocks, mnemonic
# markers), never on project-internal names. gitleaks runs alongside it in CI
# for broad token/credential patterns.
#
# ./tools/secret-scan.sh
#
set -uo pipefail
cd "$(dirname "$0")/.."
if git rev-parse --is-inside-work-tree >/dev/null 2>&1 && [ -n "$(git ls-files)" ]; then
mapfile -t FILES < <(git ls-files)
else
mapfile -t FILES < <(find . -type f -not -path './.git/*' -not -path './target/*' -printf '%P\n')
fi
# Secret shapes (not names): PEM/OpenSSH/PGP private-key blocks and seed phrases.
PATTERNS='(-----BEGIN ([A-Z ]+ )?PRIVATE KEY-----|-----BEGIN PGP PRIVATE KEY|BEGIN OPENSSH PRIVATE KEY|\b(mnemonic|seed[_ ]?phrase)\b\s*[:=])'
fail=0
for f in "${FILES[@]}"; do
[ -f "$f" ] || continue
case "$f" in tools/secret-scan.sh) continue ;; esac
if grep -EnI "$PATTERNS" "$f" >/dev/null 2>&1; then
echo "SECRET-SCAN: possible key/seed material in $f"
grep -EnI "$PATTERNS" "$f" | sed 's/^/ /'
fail=1
fi
done
if [ "$fail" -ne 0 ]; then
echo "SECRET-SCAN FAILED — remove key/seed material before committing."
exit 1
fi
echo "secret-scan OK (${#FILES[@]} files checked)."