v0.0.2 - Nostr login integration: GTK login dialog (local key, seed phrase, read-only, NIP-46, n_signer hardware), window.nostr NIP-07 injection via sovereign:// URI scheme bridge, security strip (CORS/SOP/TLS/mixed content disabled), WebKit Inspector, Open File menu, sovereign://security settings page, test page

This commit is contained in:
Laan Tungir
2026-07-11 16:03:50 -04:00
parent 6803eec526
commit 63e205f001
20 changed files with 3713 additions and 28 deletions

8
.gitignore vendored
View File

@@ -1,7 +1,15 @@
# build artifacts
sovereign_browser
*.o
*.a
*.so
*.dylib
*.dll
build/
# editor / OS
*.swp
.DS_Store
# vendored dependency (added as a subtree/checkout, not tracked here)
nostr_core_lib/

7
.roo/commands/push.md Normal file
View File

@@ -0,0 +1,7 @@
---
description: "This command increments the version number and then adds, commits and pushes to the repo."
---
Run increment_and_push.sh, and supply a good git commit message. For example:
./increment_and_push.sh "Added the hamburger dropdown menu"

View File

@@ -0,0 +1,7 @@
---
description: "This increments our git tag and version number, along with compiling and uploading a new release to our gitea page."
---
Run increment_and_push.sh -r -p, and supply a good git commit message. For example:
./increment_and_push.sh -r -p "Release: hamburger menu and build scripts"

View File

@@ -1,18 +1,30 @@
# sovereign_browser — WebKitGTK + C99
#
# Requires: libwebkit2gtk-4.1-dev (Debian: sudo apt install libwebkit2gtk-4.1-dev)
# nostr_core_lib (vendored at ./nostr_core_lib/, build it first)
# libsecp256k1-dev, libssl-dev, libcurl4-openssl-dev
CC ?= gcc
CFLAGS ?= -std=c99 -Wall -Wextra -O2
CFLAGS += $(shell pkg-config --cflags webkit2gtk-4.1)
CFLAGS += -I./nostr_core_lib
CFLAGS += -DNOSTR_ENABLE_NSIGNER_CLIENT
LDFLAGS ?=
LDLIBS += $(shell pkg-config --libs webkit2gtk-4.1)
BIN := sovereign_browser
SRC := src/main.c
# nostr_core_lib static library + its dependencies
NOSTR_LIB = ./nostr_core_lib/libnostr_core_x64.a
NOSTR_DEPS = -lsecp256k1 -lssl -lcrypto -lcurl -lz -ldl -lpthread -lm
$(BIN): $(SRC)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(LDLIBS)
BIN := sovereign_browser
SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c
$(BIN): $(SRC) $(NOSTR_LIB)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(NOSTR_LIB) $(LDLIBS) $(NOSTR_DEPS)
$(NOSTR_LIB):
@echo "Building nostr_core_lib..."
cd nostr_core_lib && ./build.sh --nips=all
run: $(BIN)
./$(BIN)

View File

@@ -87,14 +87,53 @@ make
Full architecture, engine comparison, and roadmap in
[`docs/architecture.md`](docs/architecture.md).
### Custom URI scheme layer
The browser registers custom URI schemes via
`webkit_web_context_register_uri_scheme()`. This is the **network/content
layer** — it controls what bytes get loaded into the web view. One
infrastructure, multiple uses:
| Scheme | Purpose | Status |
|--------|---------|--------|
| `sovereign://nostr/*` | `window.nostr` bridge — web pages call `fetch('sovereign://nostr/signEvent')` to sign via the C-side `nostr_signer_t` | Planned |
| `sovereign://settings` | Browser-internal pages (like `chrome://settings`) | Planned |
| `fips://` / `*.fips` | Route to FIPS mesh nodes via TUN interface | Planned |
| `nostr://` | Fetch Nostr events from relays, render as HTML | Planned |
A `WebKitWebExtension` (separate `.so` loaded into the web process) will be
added later for the **script manipulation layer** — security stripping
(CORS/SOP removal), synchronous NIP-07 calls, and content injection. The URI
scheme and WebExtension serve different layers and will coexist. See
[`plans/nostr-login-integration.md`](plans/nostr-login-integration.md) for the
full decision rationale.
### Nostr login
Login is a native GTK dialog (not a web page) that calls
[`nostr_core_lib`](https://github.com/laantungir/nostr_core_lib) directly.
Supported methods:
| Method | nostr_core_lib API |
|--------|-------------------|
| Local key (nsec) | `nostr_signer_local()`, `nostr_decode_nsec()` |
| Seed phrase (BIP-39) | `nostr_derive_keys_from_mnemonic()` |
| Read-only (npub) | `nostr_decode_npub()` |
| NIP-46 remote signer | `nostr_nip46_client_session_init()` |
| n_signer hardware | `nostr_signer_nsigner_serial/unix/tcp/qrexec()` + `nostr_signer_nsigner_set_nostr_index()` |
See [`plans/nostr-login-integration.md`](plans/nostr-login-integration.md) for
the full implementation plan.
## Roadmap
1. ✅ Base browser (WebKitGTK + C99, loads pages)
2.Security strip (disable SOP/CORS, accept any cert, shared context)
3.FIPS URI scheme (`fips://` / `*.fips` via WebKitGTK scheme handler)
4.Nostr signing (`window.nostr` → nostr_core_lib → n_signer)
5.`nostr://` content scheme
6. Future: agent runtime (didactyl hosting), multi-window, FIPS-distributed
2.Nostr login (GTK dialog → nostr_core_lib → nostr_signer_t)
3.`window.nostr` injection (`sovereign://` URI scheme bridge)
4.Security strip (disable SOP/CORS, accept any cert, shared context)
5.FIPS URI scheme (`fips://` / `*.fips` via WebKitGTK scheme handler)
6. `nostr://` content scheme
7. Future: agent runtime (didactyl hosting), multi-window, FIPS-distributed
## License

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.0.2

264
build.sh Executable file
View File

@@ -0,0 +1,264 @@
#!/bin/bash
# sovereign_browser - Build Script
# Builds the WebKitGTK + C99 browser for the current (or specified) architecture.
#
# This mirrors the workflow from nostr_core_lib/build.sh: colored output,
# architecture auto-detection, verbose mode, and a clean help screen. The
# actual compilation is delegated to the Makefile, which uses pkg-config
# for WebKitGTK 4.1.
set -e # Exit on error
# Color constants
RED='\033[31m'
GREEN='\033[32m'
YELLOW='\033[33m'
BLUE='\033[34m'
BOLD='\033[1m'
RESET='\033[0m'
# Detect if we should use colors (terminal output and not piped)
USE_COLORS=true
if [ ! -t 1 ] || [ "$NO_COLOR" = "1" ]; then
USE_COLORS=false
fi
# Function to print output with colors
print_info() {
if [ "$USE_COLORS" = true ]; then
echo -e "${BLUE}[INFO]${RESET} $1"
else
echo "[INFO] $1"
fi
}
print_success() {
if [ "$USE_COLORS" = true ]; then
echo -e "${GREEN}${BOLD}[SUCCESS]${RESET} $1"
else
echo "[SUCCESS] $1"
fi
}
print_warning() {
if [ "$USE_COLORS" = true ]; then
echo -e "${YELLOW}[WARNING]${RESET} $1"
else
echo "[WARNING] $1"
fi
}
print_error() {
if [ "$USE_COLORS" = true ]; then
echo -e "${RED}${BOLD}[ERROR]${RESET} $1"
else
echo "[ERROR] $1"
fi
}
# Default values
ARCHITECTURE=""
VERBOSE=false
HELP=false
RUN_AFTER=false
NO_COLOR_FLAG=false
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
x64|x86_64|amd64)
ARCHITECTURE="x64"
shift
;;
arm64|aarch64)
ARCHITECTURE="arm64"
shift
;;
--verbose|-v)
VERBOSE=true
shift
;;
--run|-r)
RUN_AFTER=true
shift
;;
--no-color)
NO_COLOR_FLAG=true
shift
;;
--help|-h)
HELP=true
shift
;;
*)
print_error "Unknown argument: $1"
HELP=true
shift
;;
esac
done
# Apply no-color flag
if [ "$NO_COLOR_FLAG" = true ]; then
USE_COLORS=false
fi
# Show help
if [ "$HELP" = true ]; then
echo "sovereign_browser - Build Script"
echo ""
echo "Usage: $0 [architecture] [options]"
echo ""
echo "Architectures:"
echo " x64, x86_64, amd64 Build for x86-64 architecture (native)"
echo " arm64, aarch64 Build for ARM64 architecture (cross-compile)"
echo " (default) Build for current architecture"
echo ""
echo "Options:"
echo " --run, -r Run the browser after a successful build"
echo " --verbose, -v Verbose output (pass V=1 to make)"
echo " --no-color Disable colored output"
echo " --help, -h Show this help"
echo ""
echo "Requirements:"
echo " Debian 13 (trixie) or similar with WebKitGTK 4.1 dev headers:"
echo " sudo apt install libwebkit2gtk-4.1-dev"
echo ""
echo "Examples:"
echo " $0 # Build for current architecture"
echo " $0 x64 # Build for x64"
echo " $0 -r # Build and run"
echo " $0 arm64 -v # Cross-compile for ARM64, verbose"
exit 0
fi
print_info "sovereign_browser - Build Script"
# Check if we're running from the correct directory
CURRENT_DIR=$(basename "$(pwd)")
if [ "$CURRENT_DIR" != "sovereign_browser" ]; then
print_error "Build script must be run from the sovereign_browser directory"
echo ""
echo "Current directory: $CURRENT_DIR"
echo "Expected directory: sovereign_browser"
echo ""
echo "Please change to the sovereign_browser directory first, then run the build script."
echo ""
echo "Correct usage:"
echo " cd sovereign_browser"
echo " ./build.sh"
echo ""
exit 1
fi
# Check that the source tree is present
if [ ! -f "src/main.c" ] || [ ! -f "Makefile" ]; then
print_error "src/main.c or Makefile not found in the current directory"
echo ""
echo "This script must be run from the root of the sovereign_browser project."
exit 1
fi
###########################################################################################
############ AUTODETECT SYSTEM ARCHITECTURE
###########################################################################################
# Determine architecture
if [ -z "$ARCHITECTURE" ]; then
ARCH=$(uname -m)
case $ARCH in
x86_64|amd64)
ARCHITECTURE="x64"
;;
aarch64|arm64)
ARCHITECTURE="arm64"
;;
*)
ARCHITECTURE="x64" # Default fallback
print_warning "Unknown architecture '$ARCH', defaulting to x64"
;;
esac
fi
print_info "Target architecture: $ARCHITECTURE"
# Set compiler based on architecture
case $ARCHITECTURE in
x64)
export CC="gcc"
;;
arm64)
export CC="aarch64-linux-gnu-gcc"
;;
*)
print_error "Unsupported architecture: $ARCHITECTURE"
exit 1
;;
esac
# Check if compiler exists
if ! command -v $CC &> /dev/null; then
print_error "Compiler $CC not found"
if [ "$ARCHITECTURE" = "arm64" ]; then
print_info "Install ARM64 cross-compiler: sudo apt install gcc-aarch64-linux-gnu"
fi
exit 1
fi
###########################################################################################
############ CHECK DEPENDENCIES
###########################################################################################
print_info "Checking dependencies..."
# WebKitGTK 4.1 is the hard requirement.
if ! command -v pkg-config &> /dev/null; then
print_error "pkg-config not found"
print_info "Install with: sudo apt install pkg-config"
exit 1
fi
if ! pkg-config --exists webkit2gtk-4.1; then
print_error "webkit2gtk-4.1 not found via pkg-config"
print_info "Install with: sudo apt install libwebkit2gtk-4.1-dev"
exit 1
fi
if [ "$VERBOSE" = true ]; then
print_info "WebKitGTK 4.1 CFLAGS: $(pkg-config --cflags webkit2gtk-4.1)"
print_info "WebKitGTK 4.1 LIBS: $(pkg-config --libs webkit2gtk-4.1)"
fi
print_success "All dependencies satisfied"
###########################################################################################
############ BUILD
###########################################################################################
# Read version from VERSION file (if present) for the build banner.
VERSION_STR="(unknown)"
if [ -f "VERSION" ]; then
VERSION_STR="v$(tr -d '[:space:]' < VERSION)"
fi
print_info "Building sovereign_browser $VERSION_STR..."
MAKE_FLAGS=""
if [ "$VERBOSE" = true ]; then
MAKE_FLAGS="V=1"
fi
# Delegate to the Makefile. CC is exported above so cross-compiles work.
if make $MAKE_FLAGS; then
print_success "Build complete: ./sovereign_browser $VERSION_STR"
else
print_error "Build failed"
exit 1
fi
# Optional: run immediately.
if [ "$RUN_AFTER" = true ]; then
print_info "Launching sovereign_browser..."
./sovereign_browser
fi

375
increment_and_push.sh Executable file
View File

@@ -0,0 +1,375 @@
#!/bin/bash
set -e
# increment_and_push.sh - Version increment and git automation script
#
# Usage:
# ./increment_and_push.sh "meaningful git comment"
# ./increment_and_push.sh -p "meaningful git comment" # patch bump (default)
# ./increment_and_push.sh -m "meaningful git comment" # minor bump
# ./increment_and_push.sh -M "meaningful git comment" # major bump
# ./increment_and_push.sh -r -p "meaningful git comment" # release: build, tag, push, gitea release + assets
# ./increment_and_push.sh --set-version vX.Y.Z "comment" # explicit version
#
# Mirrors the n_signer workflow: -p/-m/-M/-r flags, version read from git tags,
# updates both VERSION and src/version.h, and (in release mode) builds the
# binary, creates a Gitea release, and uploads the binary + source tarball.
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
print_status() { echo -e "${BLUE}[INFO]${NC} $1" >&2; }
print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1" >&2; }
print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1" >&2; }
print_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
# --- Config -------------------------------------------------------------
# Gitea repo for release uploads (derived from the origin remote).
GITEA_API_URL="https://git.laantungir.net/api/v1/repos/laantungir/sovereign_browser"
BIN_NAME="sovereign_browser"
# --- Args ---------------------------------------------------------------
COMMIT_MESSAGE=""
RELEASE_MODE=false
VERSION_INCREMENT_TYPE="patch"
TARGET_VERSION=""
show_usage() {
echo "sovereign_browser Increment and Push Script"
echo ""
echo "USAGE:"
echo " $0 [OPTIONS] \"commit message\""
echo ""
echo "OPTIONS:"
echo " -p, --patch Increment patch version (default)"
echo " -m, --minor Increment minor version"
echo " -M, --major Increment major version"
echo " -r, --release Create a Gitea release with built binary + tarball"
echo " --set-version vX.Y.Z Set an explicit version instead of incrementing"
echo " -h, --help Show this help message"
echo ""
echo "EXAMPLES:"
echo " $0 \"Add hamburger menu\""
echo " $0 -m \"Security strip feature\""
echo " $0 -r -p \"Release: FIPS scheme handler\""
echo " $0 --set-version v0.1.0 \"First stable release\""
}
while [[ $# -gt 0 ]]; do
case $1 in
-r|--release)
RELEASE_MODE=true
shift
;;
-p|--patch)
VERSION_INCREMENT_TYPE="patch"
shift
;;
-m|--minor)
VERSION_INCREMENT_TYPE="minor"
shift
;;
-M|--major)
VERSION_INCREMENT_TYPE="major"
shift
;;
--set-version)
if [[ -z "$2" ]]; then
print_error "--set-version requires a version argument (vX.Y.Z)"
exit 1
fi
TARGET_VERSION="$2"
shift 2
;;
-h|--help)
show_usage
exit 0
;;
*)
if [[ -z "$COMMIT_MESSAGE" ]]; then
COMMIT_MESSAGE="$1"
fi
shift
;;
esac
done
if [[ -z "$COMMIT_MESSAGE" ]]; then
print_error "Commit message is required"
show_usage
exit 1
fi
# --- Checks -------------------------------------------------------------
# Check if we're in the correct directory
CURRENT_DIR=$(basename "$(pwd)")
if [ "$CURRENT_DIR" != "sovereign_browser" ]; then
print_error "Script must be run from the sovereign_browser directory"
echo ""
echo "Current directory: $CURRENT_DIR"
echo "Expected directory: sovereign_browser"
exit 1
fi
# Check if git repository exists
if ! git rev-parse --git-dir > /dev/null 2>&1; then
print_error "Not a git repository. Please initialize git first."
exit 1
fi
# Check that the version header exists (C-side source of truth)
if [ ! -f "src/version.h" ]; then
print_error "src/version.h not found"
exit 1
fi
# --- Version logic ------------------------------------------------------
update_version_files() {
local new_version="$1" # includes leading 'v'
local major="$2"
local minor="$3"
local patch="$4"
# VERSION file: plain X.Y.Z (no leading 'v'), matches nostr_core_lib
echo "$major.$minor.$patch" > VERSION
print_success "Updated VERSION file -> $major.$minor.$patch"
# src/version.h: C defines (SB_ prefix to avoid clashing with nostr_core_lib's VERSION)
sed -i "s/#define SB_VERSION .*/#define SB_VERSION \"v$major.$minor.$patch\"/" src/version.h
sed -i "s/#define SB_VERSION_MAJOR .*/#define SB_VERSION_MAJOR $major/" src/version.h
sed -i "s/#define SB_VERSION_MINOR .*/#define SB_VERSION_MINOR $minor/" src/version.h
sed -i "s/#define SB_VERSION_PATCH .*/#define SB_VERSION_PATCH $patch/" src/version.h
print_success "Updated src/version.h -> v$major.$minor.$patch"
}
increment_version() {
local increment_type="$1"
# Read the latest version from git tags (like n_signer), falling back to
# the VERSION file if no tags exist yet.
LATEST_TAG=$(git tag -l 'v*.*.*' | sort -V | tail -n 1 2>/dev/null || echo "")
if [[ -z "$LATEST_TAG" ]]; then
if [ -f "VERSION" ]; then
local file_ver
file_ver=$(tr -d '[:space:]' < VERSION)
file_ver="${file_ver#v}"
LATEST_TAG="v$file_ver"
else
LATEST_TAG="v0.0.0"
fi
print_warning "No version tags found, starting from $LATEST_TAG"
fi
VERSION=${LATEST_TAG#v}
if [[ $VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
MAJOR=${BASH_REMATCH[1]}
MINOR=${BASH_REMATCH[2]}
PATCH=${BASH_REMATCH[3]}
else
print_error "Invalid version format in tag: $LATEST_TAG"
exit 1
fi
if [[ "$increment_type" == "major" ]]; then
NEW_VERSION="v$((MAJOR + 1)).0.0"
elif [[ "$increment_type" == "minor" ]]; then
NEW_VERSION="v${MAJOR}.$((MINOR + 1)).0"
else
NEW_VERSION="v${MAJOR}.${MINOR}.$((PATCH + 1))"
fi
local new_no_v=${NEW_VERSION#v}
local new_major=${new_no_v%%.*}
local rest=${new_no_v#*.}
local new_minor=${rest%%.*}
local new_patch=${rest##*.}
update_version_files "$NEW_VERSION" "$new_major" "$new_minor" "$new_patch"
export NEW_VERSION
}
set_explicit_version() {
if [[ ! "$TARGET_VERSION" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
print_error "Invalid target version format: $TARGET_VERSION (expected vX.Y.Z)"
exit 1
fi
NEW_VERSION="$TARGET_VERSION"
local new_no_v=${NEW_VERSION#v}
local new_major=${new_no_v%%.*}
local rest=${new_no_v#*.}
local new_minor=${rest%%.*}
local new_patch=${rest##*.}
update_version_files "$NEW_VERSION" "$new_major" "$new_minor" "$new_patch"
export NEW_VERSION
}
# --- Git operations -----------------------------------------------------
git_commit_and_push() {
git add .
if ! git diff --staged --quiet; then
git commit -m "$NEW_VERSION - $COMMIT_MESSAGE"
print_success "Changes committed"
else
print_warning "No changes to commit"
fi
# Create (or recreate) the tag.
if git tag "$NEW_VERSION" > /dev/null 2>&1; then
print_success "Created tag: $NEW_VERSION"
else
git tag -d "$NEW_VERSION" > /dev/null 2>&1 || true
git tag "$NEW_VERSION" > /dev/null 2>&1
print_warning "Replaced existing tag: $NEW_VERSION"
fi
CURRENT_BRANCH=$(git branch --show-current)
git push origin "$CURRENT_BRANCH"
git push origin "$NEW_VERSION" || git push --force origin "$NEW_VERSION"
}
# --- Release mode: build + Gitea release --------------------------------
build_release_binary() {
if [[ ! -f "build.sh" ]]; then
print_error "build.sh not found"
return 1
fi
# Prevent stale artifacts from previous builds.
rm -f "$BIN_NAME"
print_status "Building release binary..."
./build.sh > /dev/null 2>&1 || return 1
if [[ ! -x "$BIN_NAME" ]]; then
print_error "Binary not found or not executable after build: $BIN_NAME"
return 1
fi
print_success "Release binary built: $BIN_NAME"
return 0
}
create_source_tarball() {
local tarball_name="${BIN_NAME}-${NEW_VERSION#v}.tar.gz"
if tar -czf "$tarball_name" \
--exclude="$BIN_NAME" \
--exclude='.git*' \
--exclude='*.log' \
--exclude='*.tar.gz' \
--exclude='nostr_core_lib' \
. > /dev/null 2>&1; then
echo "$tarball_name"
else
return 1
fi
}
create_gitea_release() {
if [[ ! -f "$HOME/.gitea_token" ]]; then
print_warning "No ~/.gitea_token found. Skipping release creation."
return 0
fi
local token
token=$(tr -d '\n\r' < "$HOME/.gitea_token")
local response
response=$(curl -s -X POST "$GITEA_API_URL/releases" \
-H "Authorization: token $token" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$NEW_VERSION\", \"name\": \"$NEW_VERSION\", \"body\": \"$COMMIT_MESSAGE\"}")
if echo "$response" | grep -q '"id"'; then
echo "$response" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2
else
print_warning "Gitea release creation did not return an id. Response: $response"
return 1
fi
}
upload_release_assets() {
local release_id="$1"
local binary_path="$2"
local tarball_path="$3"
if [[ ! -f "$HOME/.gitea_token" ]]; then
print_warning "No ~/.gitea_token found. Skipping asset uploads."
return 0
fi
local token
token=$(tr -d '\n\r' < "$HOME/.gitea_token")
local assets_url="$GITEA_API_URL/releases/$release_id/assets"
if [[ -f "$binary_path" ]]; then
curl -s -X POST "$assets_url" \
-H "Authorization: token $token" \
-F "attachment=@$binary_path;filename=$(basename "$binary_path")" \
-F "name=$(basename "$binary_path")" > /dev/null
print_success "Uploaded binary: $(basename "$binary_path")"
fi
if [[ -f "$tarball_path" ]]; then
curl -s -X POST "$assets_url" \
-H "Authorization: token $token" \
-F "attachment=@$tarball_path;filename=$(basename "$tarball_path")" \
-F "name=$(basename "$tarball_path")" > /dev/null
print_success "Uploaded tarball: $(basename "$tarball_path")"
fi
}
# --- Main ---------------------------------------------------------------
print_status "Starting version increment and push process..."
if [[ -n "$TARGET_VERSION" ]]; then
set_explicit_version
else
increment_version "$VERSION_INCREMENT_TYPE"
fi
print_status "New version: $NEW_VERSION"
if [[ "$RELEASE_MODE" == true ]]; then
# Build before committing so a failed build aborts the release.
if ! build_release_binary; then
print_error "Release build failed; aborting before push/upload"
exit 1
fi
git_commit_and_push
local_tarball=""
local_tarball=$(create_source_tarball || true)
release_id=""
release_id=$(create_gitea_release || true)
if [[ -n "$release_id" ]]; then
upload_release_assets "$release_id" "$BIN_NAME" "$local_tarball"
fi
# Clean up the tarball (the binary is gitignored already).
[[ -n "$local_tarball" && -f "$local_tarball" ]] && rm -f "$local_tarball"
print_success "Release flow completed: $NEW_VERSION"
else
git_commit_and_push
print_success "Increment and push completed: $NEW_VERSION"
fi
echo ""
echo "🎉 Done! Version $NEW_VERSION is live."

View File

@@ -0,0 +1,301 @@
# Nostr Login Integration — Plan
## Goal
When the browser opens, the first thing the user sees is a Nostr login screen.
After authenticating, the browser has the user's Nostr identity (pubkey + signing
capability) and injects `window.nostr` into every page it loads.
We want all the functionality of `nostr_login_lite` — local key, seed phrase,
read-only, NIP-46 remote signer, n_signer hardware — as login methods.
## Reference implementations
- **`nostr_core_lib/examples/note_poster.c`** — shows local key + all n_signer
transport modes (unix, serial, tcp, fds) via `nostr_signer_t`.
- **`n_signer/client/demo_c99.c`** — comprehensive C99 demo: qrexec transport,
`nostr_signer_nsigner_set_nostr_index()`, getPublicKey + signEvent + nip44
encrypt/decrypt round-trip. This is the pattern for n_signer login.
- **`nostr_core_lib/examples/nip46_remote_signer.c`** — NIP-46 session pattern.
- **`nostr_core_lib/examples/mnemonic_generation.c`** / **`mnemonic_derivation.c`**
— seed phrase key generation/derivation pattern.
## Key insight: nostr_core_lib already has everything
`nostr_core_lib` (at `~/lt/nostr_core_lib`) is a C library that already
implements all the crypto and protocol functionality that `nostr_login_lite`
does in JavaScript:
| Login method | nostr_core_lib C API |
|---|---|
| **Local key** (generate / paste nsec) | `nostr_generate_keypair()`, `nostr_decode_nsec()`, `nostr_key_to_bech32()` |
| **Seed phrase** (BIP-39) | `nostr_generate_mnemonic_and_keys()`, `nostr_derive_keys_from_mnemonic()` |
| **Read-only** (npub only) | `nostr_decode_npub()` |
| **NIP-46 remote signer** | `nostr_nip46_parse_bunker_url()`, `nostr_nip46_client_session_t`, full request/response event handling |
| **n_signer hardware** | `nostr_signer_nsigner_unix()`, `nostr_signer_nsigner_serial()`, `nostr_signer_nsigner_tcp()`, `nostr_signer_nsigner_qrexec()` — direct transport (USB serial, UNIX socket, TCP, Qubes qrexec), **better** than WebUSB. Key selection via `nostr_signer_nsigner_set_nostr_index()`. See `n_signer/client/demo_c99.c` for the full pattern. |
| **Schnorr signing** | `nostr_create_and_sign_event()`, `nostr_signer_sign_event()` |
| **NIP-04 encrypt/decrypt** | `nostr_nip04_encrypt()`, `nostr_nip04_decrypt()` |
| **NIP-44 encrypt/decrypt** | `nostr_nip44_encrypt()`, `nostr_nip44_decrypt()` |
| **Unified signer** | `nostr_signer_t` — local + n_signer backends, all NIP-07 verbs (getPublicKey, signEvent, nip04/nip44) |
The `nostr_signer_t` abstraction is the key: it provides a unified interface
across local keys and n_signer hardware. The browser creates a signer at login
time and routes all `window.nostr` calls through it.
**What nostr_login_lite adds that is NOT in nostr_core_lib:**
1. **UI** — modal dialog, floating tab, themes (we build a GTK dialog instead)
2. **Orchestration** — method selection, persistence, session restore (straightforward C)
3. **window.nostr facade** — JS shim injected into pages (we inject via WebKitGTK)
## Decision: Native C99 login using nostr_core_lib
Since nostr_core_lib provides all the functionality, we build the login as a
**native GTK dialog** that calls nostr_core_lib directly. No JavaScript login
page needed. This is the purest C99 path and avoids the WebKitGTK WebUSB/WebSerial
limitation entirely — n_signer works via direct serial/socket transport in C.
### Architecture
```mermaid
flowchart TB
subgraph C99Host[sovereign_browser C99 host]
LoginDlg[GTK Login Dialog]
KeyStore[C Key Store - file persistence]
Signer[nostr_signer_t from nostr_core_lib]
NostrInject[window.nostr JS injector]
Bridge[C to JS bridge - sovereign:// scheme]
end
subgraph NostrCoreLib[nostr_core_lib - linked static lib]
NIP01[NIP-01 Event signing]
NIP06[NIP-06 Seed phrase derivation]
NIP19[NIP-19 bech32 nsec/npub]
NIP04[NIP-04 Encryption]
NIP44[NIP-44 Encryption]
NIP46[NIP-46 Remote signer]
NSigner[n_signer transport - serial/socket/TCP]
end
subgraph WebKit[WebKitGTK Web View]
Pages[Web pages with window.nostr]
end
LoginDlg -- user picks method --> Signer
LoginDlg -- saves identity --> KeyStore
Signer --> NIP01
Signer --> NIP06
Signer --> NIP19
Signer --> NIP04
Signer --> NIP44
Signer --> NIP46
Signer --> NSigner
KeyStore -- restore on startup --> Signer
Signer -- handles sign requests --> Bridge
Bridge -- marshals calls --> NostrInject
NostrInject -- injects into --> Pages
```
### Login flow
```mermaid
flowchart TD
Start[Browser starts] --> CheckKeyStore{Saved key in store?}
CheckKeyStore -- yes --> RestoreSigner[Restore signer from saved key]
CheckKeyStore -- no --> ShowLogin[Show GTK Login Dialog]
ShowLogin --> MethodSelect{User selects method}
MethodSelect -- Local key --> EnterNsec[Paste nsec or generate new]
MethodSelect -- Seed phrase --> EnterSeed[Enter BIP-39 mnemonic]
MethodSelect -- Read-only --> EnterNpub[Paste npub]
MethodSelect -- NIP-46 --> EnterBunker[Paste bunker:// URL]
MethodSelect -- n_signer USB --> PickSerial[Select /dev/ttyACM* device]
EnterNsec --> CreateSigner[Create nostr_signer_local]
EnterSeed --> DeriveKeys[nostr_derive_keys_from_mnemonic]
DeriveKeys --> CreateSigner
EnterNpub --> CreateReadonly[Store pubkey only, no signer]
EnterBunker --> ConnectNIP46[Parse URL, connect to relay]
ConnectNIP46 --> CreateSigner
PickSerial --> CreateNSigner[Create nostr_signer_nsigner_serial]
CreateNSigner --> CreateSigner
CreateSigner --> SaveKeyStore[Save identity to key store]
CreateReadonly --> SaveKeyStore
SaveKeyStore --> LoadBrowser[Load start URL in web view]
RestoreSigner --> LoadBrowser
LoadBrowser --> InjectNostr[Inject window.nostr into all pages]
```
### window.nostr injection — `sovereign://` URI scheme bridge
After login, every page loaded in the web view gets a `window.nostr` object
injected via `WebKitUserContentManager` + `webkit_user_content_manager_add_script()`.
The injected JS shim exposes the NIP-07 API:
```javascript
window.nostr = {
getPublicKey: () => bridgeCall('getPublicKey'),
signEvent: (event) => bridgeCall('signEvent', event),
getRelays: () => bridgeCall('getRelays'),
nip04: { encrypt: (pubkey, text) => bridgeCall('nip04_encrypt', {pubkey, text}),
decrypt: (pubkey, ct) => bridgeCall('nip04_decrypt', {pubkey, ct}) },
nip44: { encrypt: (pubkey, text) => bridgeCall('nip44_encrypt', {pubkey, text}),
decrypt: (pubkey, ct) => bridgeCall('nip44_decrypt', {pubkey, ct}) }
};
```
`bridgeCall()` marshals to C via a custom `sovereign://` URI scheme fetch:
`fetch('sovereign://nostr/getPublicKey')` etc. The C scheme handler receives
the request, calls the appropriate `nostr_signer_t` function, and returns the
result as the HTTP response body.
This is the same nos2x surface, so existing Nostr web apps work without an
extension.
### Architecture decision: URI scheme now, WebExtension later
Two approaches were considered for the JS→C bridge:
1. **`sovereign://` URI scheme** (chosen for now) — JS does `fetch()` to a
custom scheme, C handles it. Simple, one callback, no separate library.
2. **`WebKitWebExtension` + JSC** (future) — a `.so` loaded into the web
process that registers C functions as JS globals. The "proper" WebKitGTK
way, supports synchronous calls, process isolation.
**Why start with the URI scheme:** it serves double duty. The same
`webkit_web_context_register_uri_scheme()` infrastructure is needed for:
- `sovereign://nostr/*` — window.nostr bridge (Phase 2)
- `sovereign://settings`, `sovereign://about` — browser-internal pages
- `fips://` / `*.fips` — FIPS mesh routing (roadmap item 3)
- `nostr://` — Nostr content scheme (roadmap item 5)
Building the scheme handler infrastructure once covers all these use cases.
**When to add WebExtension:** when we need:
- Synchronous NIP-07 calls (some older Nostr apps expect sync `signEvent`)
- Security stripping (CORS/SOP removal in the web process, before scripts run)
- Fine-grained request interception or content injection
The URI scheme is the **network/content layer** (controls what bytes load).
The WebExtension is the **script manipulation layer** (controls what happens
inside the web process). They serve different layers and will coexist.
## Implementation plan
### Phase 1 — Link nostr_core_lib + key store + local key login
1. **Update Makefile** to link against `libnostr_core_x64.a` and its dependencies
(`-lsecp256k1 -lssl -lcrypto -lcurl -lz -ldl -lpthread -lm`).
2. **Create `src/key_store.h` / `src/key_store.c`** — a C module that:
- Saves/loads the user's identity to `~/.sovereign_browser/identity.json`
- Stores: `{ method, pubkey_hex, private_key_hex?, nsigner_device?,
nip46_session? }`
- On startup, checks if a saved identity exists and restores the signer.
3. **Create `src/login_dialog.h` / `src/login_dialog.c`** — a GTK dialog with:
- Method selection buttons: Local Key, Seed Phrase, Read-only, NIP-46,
n_signer Hardware
- Input fields per method (nsec entry, mnemonic entry, npub entry, bunker
URL entry, serial device dropdown)
- "Generate new key" button for local key method
- "Generate new mnemonic" button for seed phrase method
- Calls nostr_core_lib functions to create the signer
- Returns a `nostr_signer_t*` (or NULL for read-only) + pubkey
4. **Modify `src/main.c`** — on startup:
- Try to restore identity from key store
- If no saved identity, show login dialog before creating the web view
- Store the signer globally for the window.nostr bridge
5. **Test**: launch browser, see login dialog, paste an nsec, verify the pubkey
is derived correctly, browser opens.
### Phase 2 — window.nostr injection + sovereign:// bridge
1. **Register `sovereign://` URI scheme** via
`webkit_web_context_register_uri_scheme()`.
2. **Create `src/nostr_bridge.h` / `src/nostr_bridge.c`** — handles
`sovereign://nostr/<method>` requests:
- `getPublicKey` → `nostr_signer_get_public_key()`
- `signEvent` → `nostr_signer_sign_event()`
- `nip04_encrypt` / `nip04_decrypt` → `nostr_signer_nip04_*()`
- `nip44_encrypt` / `nip44_decrypt` → `nostr_signer_nip44_*()`
- Returns results as JSON in the scheme response stream.
3. **Create `src/nostr_inject.h` / `src/nostr_inject.c`** — builds the JS shim
string and injects it via `WebKitUserContentManager` into all frames before
page scripts run.
4. **Test**: load a Nostr web app (e.g. a test page that calls
`window.nostr.getPublicKey()`), verify it gets the pubkey.
### Phase 3 — Seed phrase + read-only + NIP-46 login
1. **Seed phrase screen** — text area for mnemonic, calls
`nostr_derive_keys_from_mnemonic()`. Add "generate new mnemonic" button
using `nostr_generate_mnemonic_and_keys()`.
2. **Read-only screen** — npub entry, calls `nostr_decode_npub()`. No signer
created; `window.nostr.signEvent()` returns an error.
3. **NIP-46 screen** — bunker:// URL entry, calls
`nostr_nip46_parse_bunker_url()`, establishes a WebSocket connection to the
relay, sends a connect request. Uses `nostr_nip46_client_session_t`.
4. **Test**: each login method end-to-end.
### Phase 4 — n_signer hardware login
1. **n_signer screen** — transport selection:
- **Serial**: enumerate via `nsigner_transport_list_serial()`, dropdown
- **UNIX socket**: enumerate via `nsigner_transport_list_unix()`, dropdown
- **TCP**: host + port entry fields
- **Qubes qrexec**: target qube + service name entry (for Qubes OS)
- **nostr_index** selector (NIP-06 m/44'/1237'/N'/0/0) — numeric input, default 0
2. **Create signer** — `nostr_signer_nsigner_serial/unix/tcp/qrexec(...)`, then
`nostr_signer_nsigner_set_nostr_index(signer, index)`.
3. **Get pubkey** — `nostr_signer_get_public_key()` to verify the device and
show the npub to the user.
4. **Persist** — save the transport type + connection params + nostr_index (not
a private key) in the key store. On restore, reopen the transport.
5. **Test**: connect to n_signer via each transport, verify pubkey, sign an
event from a web page. Follow the pattern in `n_signer/client/demo_c99.c`.
### Phase 5 — Persistence + polish
1. **Encrypted key file** — encrypt the private key in the key store with a
user-supplied password (AES-256-GCM via OpenSSL).
2. **Session lock** — menu option to lock the session (clear signer, require
re-auth).
3. **Auto-restore on startup** — if a saved identity exists, skip the login
dialog and restore the signer directly.
4. **Login dialog styling** — match the browser's aesthetic (monospace, dark
theme option).
5. **Error handling** — clear error messages for invalid keys, connection
failures, device not found, etc.
## File structure after implementation
```
src/
main.c — modified: login flow + signer lifecycle
version.h — existing
key_store.h — new: identity persistence
key_store.c — new
login_dialog.h — new: GTK login dialog
login_dialog.c — new
nostr_bridge.h — new: sovereign:// scheme handler for window.nostr
nostr_bridge.c — new
nostr_inject.h — new: JS shim injection
nostr_inject.c — new
Makefile — modified: link nostr_core_lib + deps
```
## Dependencies to add
- `libnostr_core_x64.a` (or arm64) — static library from nostr_core_lib
- `-lsecp256k1` — Schnorr signing
- `-lssl -lcrypto` — OpenSSL (AES, SHA, HMAC)
- `-lcurl` — HTTP client (NIP-05, relay queries)
- `-lz` — compression
- All already required by nostr_core_lib; see its
[README](../nostr_core_lib/README.md:164) for the full link line.
## What we do NOT need
- **nostr_login_lite JS** — not needed; nostr_core_lib covers all functionality
- **nostr-tools JS bundle** — not needed
- **WebUSB / WebSerial** — not needed; n_signer works via direct serial/socket
- **A web server** — not needed; login is a native GTK dialog
- **JS-to-C bridge for login** — not needed; login is pure C + GTK
- **JS-to-C bridge for signing** — needed, but only for the window.nostr
injection into web pages (Phase 2), not for the login itself

269
src/key_store.c Normal file
View File

@@ -0,0 +1,269 @@
/*
* key_store.c — Nostr identity persistence for sovereign_browser
*/
#include "key_store.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <errno.h>
#include <unistd.h>
#include "nostr_core/nostr_core.h"
#include "nostr_core/nip006.h"
#include "nostr_core/nip019.h"
/* ── Path helpers ─────────────────────────────────────────────── */
int key_store_path(char *out, size_t out_sz) {
const char *home = getenv("HOME");
if (home == NULL || home[0] == '\0') {
return -1;
}
/* Ensure ~/.sovereign_browser/ exists. */
char dir[512];
int n = snprintf(dir, sizeof(dir), "%s/.sovereign_browser", home);
if (n < 0 || (size_t)n >= sizeof(dir)) {
return -1;
}
if (mkdir(dir, 0700) != 0 && errno != EEXIST) {
return -1;
}
n = snprintf(out, out_sz, "%s/identity.json", dir);
if (n < 0 || (size_t)n >= out_sz) {
return -1;
}
return 0;
}
/* ── Save / load ──────────────────────────────────────────────── */
static const char *method_to_string(key_store_method_t m) {
switch (m) {
case KEY_STORE_METHOD_LOCAL: return "local";
case KEY_STORE_METHOD_SEED: return "seed";
case KEY_STORE_METHOD_READONLY: return "readonly";
case KEY_STORE_METHOD_NIP46: return "nip46";
case KEY_STORE_METHOD_NSIGNER: return "nsigner";
default: return "none";
}
}
static key_store_method_t string_to_method(const char *s) {
if (!s) return KEY_STORE_METHOD_NONE;
if (strcmp(s, "local") == 0) return KEY_STORE_METHOD_LOCAL;
if (strcmp(s, "seed") == 0) return KEY_STORE_METHOD_SEED;
if (strcmp(s, "readonly") == 0) return KEY_STORE_METHOD_READONLY;
if (strcmp(s, "nip46") == 0) return KEY_STORE_METHOD_NIP46;
if (strcmp(s, "nsigner") == 0) return KEY_STORE_METHOD_NSIGNER;
return KEY_STORE_METHOD_NONE;
}
int key_store_save(const key_store_identity_t *identity) {
char path[512];
if (key_store_path(path, sizeof(path)) != 0) {
return -1;
}
FILE *f = fopen(path, "w");
if (f == NULL) {
return -1;
}
/* Write with restrictive permissions. */
fchmod(fileno(f), 0600);
fprintf(f, "{\n");
fprintf(f, " \"method\": \"%s\",\n", method_to_string(identity->method));
fprintf(f, " \"pubkey_hex\": \"%s\"", identity->pubkey_hex);
if (identity->method == KEY_STORE_METHOD_LOCAL ||
identity->method == KEY_STORE_METHOD_SEED) {
fprintf(f, ",\n \"privkey_hex\": \"%s\"", identity->privkey_hex);
}
if (identity->method == KEY_STORE_METHOD_SEED && identity->mnemonic[0]) {
fprintf(f, ",\n \"mnemonic\": \"%s\"", identity->mnemonic);
}
if (identity->method == KEY_STORE_METHOD_NIP46 && identity->bunker_url[0]) {
fprintf(f, ",\n \"bunker_url\": \"%s\"", identity->bunker_url);
}
if (identity->method == KEY_STORE_METHOD_NSIGNER) {
fprintf(f, ",\n \"nsigner_transport\": \"%s\"", identity->nsigner_transport);
fprintf(f, ",\n \"nsigner_device\": \"%s\"", identity->nsigner_device);
fprintf(f, ",\n \"nsigner_index\": %d", identity->nsigner_index);
}
fprintf(f, "\n}\n");
fclose(f);
return 0;
}
/* Minimal JSON string field extractor (no nested objects, good enough for our flat schema). */
static int json_extract_string(const char *json, const char *key, char *out, size_t out_sz) {
char pattern[64];
snprintf(pattern, sizeof(pattern), "\"%s\"", key);
const char *p = strstr(json, pattern);
if (p == NULL) return -1;
p += strlen(pattern);
/* Skip whitespace and colon. */
while (*p && (*p == ' ' || *p == '\t' || *p == ':' || *p == '\n' || *p == '\r')) {
p++;
}
if (*p != '"') return -1;
p++;
size_t i = 0;
while (*p && *p != '"' && i < out_sz - 1) {
if (*p == '\\' && p[1]) {
p++;
}
out[i++] = *p++;
}
out[i] = '\0';
return (i > 0 || *p == '"') ? 0 : -1;
}
static int json_extract_int(const char *json, const char *key, int *out) {
char pattern[64];
snprintf(pattern, sizeof(pattern), "\"%s\"", key);
const char *p = strstr(json, pattern);
if (p == NULL) return -1;
p += strlen(pattern);
while (*p && (*p == ' ' || *p == '\t' || *p == ':' || *p == '\n' || *p == '\r')) {
p++;
}
char *end;
long val = strtol(p, &end, 10);
if (end == p) return -1;
*out = (int)val;
return 0;
}
int key_store_load(key_store_identity_t *out) {
memset(out, 0, sizeof(*out));
char path[512];
if (key_store_path(path, sizeof(path)) != 0) {
return -1;
}
FILE *f = fopen(path, "r");
if (f == NULL) {
if (errno == ENOENT) return 1; /* no identity file */
return -1;
}
/* Read the whole file (small). */
char buf[4096];
size_t n = fread(buf, 1, sizeof(buf) - 1, f);
fclose(f);
buf[n] = '\0';
char method_str[32];
if (json_extract_string(buf, "method", method_str, sizeof(method_str)) != 0) {
return -1;
}
out->method = string_to_method(method_str);
if (out->method == KEY_STORE_METHOD_NONE) {
return -1;
}
if (json_extract_string(buf, "pubkey_hex", out->pubkey_hex, sizeof(out->pubkey_hex)) != 0) {
return -1;
}
json_extract_string(buf, "privkey_hex", out->privkey_hex, sizeof(out->privkey_hex));
json_extract_string(buf, "mnemonic", out->mnemonic, sizeof(out->mnemonic));
json_extract_string(buf, "bunker_url", out->bunker_url, sizeof(out->bunker_url));
json_extract_string(buf, "nsigner_transport", out->nsigner_transport, sizeof(out->nsigner_transport));
json_extract_string(buf, "nsigner_device", out->nsigner_device, sizeof(out->nsigner_device));
json_extract_int(buf, "nsigner_index", &out->nsigner_index);
return 0;
}
int key_store_clear(void) {
char path[512];
if (key_store_path(path, sizeof(path)) != 0) {
return -1;
}
if (unlink(path) != 0 && errno != ENOENT) {
return -1;
}
return 0;
}
/* ── Signer creation ──────────────────────────────────────────── */
nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity) {
if (identity == NULL) {
return NULL;
}
switch (identity->method) {
case KEY_STORE_METHOD_LOCAL:
case KEY_STORE_METHOD_SEED: {
unsigned char privkey[32];
if (nostr_hex_to_bytes(identity->privkey_hex, privkey, 32) != 0) {
return NULL;
}
return nostr_signer_local(privkey);
}
case KEY_STORE_METHOD_READONLY:
/* No signer for read-only mode. */
return NULL;
case KEY_STORE_METHOD_NIP46:
/* NIP-46 requires a live WebSocket session — handled in login_dialog
* and the bridge, not here. For now, return NULL; the session
* will be established separately. */
return NULL;
case KEY_STORE_METHOD_NSIGNER: {
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
nostr_signer_t *signer = NULL;
if (strcmp(identity->nsigner_transport, "serial") == 0) {
signer = nostr_signer_nsigner_serial(identity->nsigner_device, NULL, 15000);
} else if (strcmp(identity->nsigner_transport, "unix") == 0) {
signer = nostr_signer_nsigner_unix(identity->nsigner_device, NULL, 15000);
} else if (strcmp(identity->nsigner_transport, "tcp") == 0) {
/* nsigner_device is "host:port" */
char host[256];
int port = 0;
const char *sep = strrchr(identity->nsigner_device, ':');
if (sep && sep != identity->nsigner_device) {
size_t hlen = (size_t)(sep - identity->nsigner_device);
if (hlen < sizeof(host)) {
memcpy(host, identity->nsigner_device, hlen);
host[hlen] = '\0';
port = atoi(sep + 1);
signer = nostr_signer_nsigner_tcp(host, port, NULL, 15000);
}
}
} else if (strcmp(identity->nsigner_transport, "qrexec") == 0) {
signer = nostr_signer_nsigner_qrexec(identity->nsigner_device, "qubes.NsignerRpc", NULL, 30000);
}
if (signer && identity->nsigner_index >= 0) {
nostr_signer_nsigner_set_nostr_index(signer, identity->nsigner_index);
}
return signer;
#else
return NULL;
#endif
}
default:
return NULL;
}
}

89
src/key_store.h Normal file
View File

@@ -0,0 +1,89 @@
/*
* key_store.h — Nostr identity persistence for sovereign_browser
*
* Saves/loads the user's Nostr identity to ~/.sovereign_browser/identity.json
* so the browser can restore the signer on startup without re-prompting.
*/
#ifndef KEY_STORE_H
#define KEY_STORE_H
#include <stddef.h>
#include "nostr_core/nostr_signer.h"
#ifdef __cplusplus
extern "C" {
#endif
/* Login methods supported by the browser. */
typedef enum {
KEY_STORE_METHOD_NONE = 0,
KEY_STORE_METHOD_LOCAL, /* nsec — private key stored (encrypted later) */
KEY_STORE_METHOD_SEED, /* BIP-39 mnemonic — private key re-derived */
KEY_STORE_METHOD_READONLY, /* npub only — no signing */
KEY_STORE_METHOD_NIP46, /* bunker:// URL — remote signer session */
KEY_STORE_METHOD_NSIGNER /* n_signer hardware — device path + index */
} key_store_method_t;
/* Identity record persisted to disk. */
typedef struct {
key_store_method_t method;
/* Hex pubkey (always present, 64 chars + NUL). */
char pubkey_hex[65];
/* Hex private key (local + seed methods only; 64 chars + NUL).
* TODO: encrypt at rest with AES-256-GCM + user password (Phase 5). */
char privkey_hex[65];
/* BIP-39 mnemonic (seed method only). */
char mnemonic[256];
/* NIP-46 bunker URL (nip46 method only). */
char bunker_url[2048];
/* n_signer transport (nsigner method only). */
char nsigner_transport[16]; /* "serial" | "unix" | "tcp" | "qrexec" */
char nsigner_device[256]; /* device path / socket name / host:port / qube */
int nsigner_index; /* nostr_index (NIP-06 m/44'/1237'/N'/0/0) */
} key_store_identity_t;
/*
* Get the path to the identity file (~/.sovereign_browser/identity.json).
* Creates the directory if it doesn't exist.
* Returns 0 on success, -1 on error.
*/
int key_store_path(char *out, size_t out_sz);
/*
* Save an identity to disk.
* Returns 0 on success, -1 on error.
*/
int key_store_save(const key_store_identity_t *identity);
/*
* Load an identity from disk.
* Returns 0 on success, 1 if no identity file exists, -1 on error.
*/
int key_store_load(key_store_identity_t *out);
/*
* Delete the identity file (logout).
* Returns 0 on success, -1 on error.
*/
int key_store_clear(void);
/*
* Create a nostr_signer_t from a loaded identity.
* For readonly: returns NULL (caller should use pubkey_hex directly).
* For nsigner: creates the appropriate transport-backed signer.
* Caller must free the returned signer with nostr_signer_free().
* Returns NULL on error or readonly method.
*/
nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity);
#ifdef __cplusplus
}
#endif
#endif /* KEY_STORE_H */

828
src/login_dialog.c Normal file
View File

@@ -0,0 +1,828 @@
/*
* login_dialog.c — GTK Nostr login dialog for sovereign_browser
*
* All login methods implemented:
* - Local key (paste nsec or generate new)
* - Seed phrase (BIP-39 mnemonic entry or generation)
* - Read-only (npub only, no signing)
* - NIP-46 remote signer (bunker:// URL)
* - n_signer hardware (serial / unix / tcp / qrexec transport)
*/
#include "login_dialog.h"
#include "key_store.h"
#include <string.h>
#include <stdlib.h>
#include "nostr_core/nostr_core.h"
#include "nostr_core/nip006.h"
#include "nostr_core/nip019.h"
#include "nostr_core/nip046.h"
#include "nostr_core/nsigner_transport.h"
/* ── Helpers ──────────────────────────────────────────────────── */
/* Convert a 32-byte private key to hex pubkey via nostr_core_lib. */
static int derive_pubkey(const unsigned char privkey[32], char pubkey_hex[65]) {
unsigned char pubkey[32];
if (nostr_ec_public_key_from_private_key(privkey, pubkey) != 0) {
return -1;
}
for (int i = 0; i < 32; i++) {
snprintf(pubkey_hex + i * 2, 3, "%02x", pubkey[i]);
}
pubkey_hex[64] = '\0';
return 0;
}
/* Convert hex string to 32-byte array. */
static int hex_to_bytes32(const char *hex, unsigned char out[32]) {
if (strlen(hex) != 64) return -1;
for (int i = 0; i < 32; i++) {
unsigned int b;
if (sscanf(hex + 2 * i, "%2x", &b) != 1) return -1;
out[i] = (unsigned char)b;
}
return 0;
}
/* Convert 32-byte pubkey to npub bech32 for display. */
static void pubkey_to_npub(const char pubkey_hex[65], char npub[128]) {
unsigned char pubkey[32];
if (hex_to_bytes32(pubkey_hex, pubkey) != 0) {
npub[0] = '\0';
return;
}
if (nostr_key_to_bech32(pubkey, "npub", npub) != 0) {
npub[0] = '\0';
}
}
/* Store privkey as hex string. */
static void privkey_to_hex(const unsigned char privkey[32], char hex[65]) {
for (int i = 0; i < 32; i++) {
snprintf(hex + i * 2, 3, "%02x", privkey[i]);
}
hex[64] = '\0';
}
/* ── Dialog state ─────────────────────────────────────────────── */
typedef struct {
GtkWidget *dialog;
GtkWidget *content_area;
GtkWidget *stack; /* GtkStack for method switching */
GtkWidget *status_label; /* error/status display */
login_result_t *result; /* output */
gboolean done; /* dialog completed */
} login_ctx_t;
/* ── Forward declarations ─────────────────────────────────────── */
static GtkWidget *create_local_screen(login_ctx_t *ctx);
static GtkWidget *create_seed_screen(login_ctx_t *ctx);
static GtkWidget *create_readonly_screen(login_ctx_t *ctx);
static GtkWidget *create_nip46_screen(login_ctx_t *ctx);
static GtkWidget *create_nsigner_screen(login_ctx_t *ctx);
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
static void on_detect_serial(GtkWidget *btn, gpointer user_data);
#endif
/* ── Callbacks ────────────────────────────────────────────────── */
static void on_generate_key(GtkWidget *btn, gpointer user_data) {
(void)btn;
GtkWidget *entry = GTK_WIDGET(user_data);
unsigned char privkey[32], pubkey[32];
if (nostr_generate_keypair(privkey, pubkey) != 0) {
gtk_entry_set_text(GTK_ENTRY(entry), "(generation failed)");
return;
}
char nsec[128];
if (nostr_key_to_bech32(privkey, "nsec", nsec) != 0) {
gtk_entry_set_text(GTK_ENTRY(entry), "(bech32 failed)");
return;
}
gtk_entry_set_text(GTK_ENTRY(entry), nsec);
}
static void on_generate_mnemonic(GtkWidget *btn, gpointer user_data) {
(void)btn;
GtkWidget *entry = GTK_WIDGET(user_data);
char mnemonic[256] = {0};
unsigned char privkey[32], pubkey[32];
if (nostr_generate_mnemonic_and_keys(mnemonic, sizeof(mnemonic), 0,
privkey, pubkey) != 0) {
gtk_entry_set_text(GTK_ENTRY(entry), "(generation failed)");
return;
}
gtk_entry_set_text(GTK_ENTRY(entry), mnemonic);
}
/* Method selection button: switch the stack to the named screen. */
static void on_method_button_clicked(GtkWidget *btn, gpointer user_data) {
GtkStack *stack = GTK_STACK(user_data);
const char *name = (const char *)g_object_get_data(G_OBJECT(btn), "stack-name");
if (name) {
gtk_stack_set_visible_child_name(stack, name);
}
}
/* ── Login handler ────────────────────────────────────────────── */
static void on_login_clicked(GtkWidget *btn, gpointer user_data) {
login_ctx_t *ctx = (login_ctx_t *)user_data;
(void)btn;
const char *current = gtk_stack_get_visible_child_name(GTK_STACK(ctx->stack));
if (current == NULL) {
gtk_label_set_text(GTK_LABEL(ctx->status_label), "No method selected.");
return;
}
memset(ctx->result, 0, sizeof(*ctx->result));
/* ── Local key ─────────────────────────────────────────── */
if (strcmp(current, "local") == 0) {
GtkWidget *screen = gtk_stack_get_child_by_name(GTK_STACK(ctx->stack), "local");
GtkWidget *entry = g_object_get_data(G_OBJECT(screen), "nsec-entry");
const char *input = gtk_entry_get_text(GTK_ENTRY(entry));
if (input[0] == '\0') {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
"Enter an nsec or click Generate.");
return;
}
unsigned char privkey[32];
if (strncmp(input, "nsec1", 5) == 0) {
if (nostr_decode_nsec(input, privkey) != 0) {
gtk_label_set_text(GTK_LABEL(ctx->status_label), "Invalid nsec.");
return;
}
} else if (strlen(input) == 64) {
if (hex_to_bytes32(input, privkey) != 0) {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
"Invalid hex private key.");
return;
}
} else {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
"Enter an nsec1... or 64-char hex key.");
return;
}
char pubkey_hex[65];
if (derive_pubkey(privkey, pubkey_hex) != 0) {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
"Failed to derive public key.");
return;
}
nostr_signer_t *signer = nostr_signer_local(privkey);
if (signer == NULL) {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
"Failed to create signer.");
return;
}
ctx->result->method = KEY_STORE_METHOD_LOCAL;
ctx->result->signer = signer;
memcpy(ctx->result->pubkey_hex, pubkey_hex, 64);
ctx->result->pubkey_hex[64] = '\0';
ctx->result->identity.method = KEY_STORE_METHOD_LOCAL;
memcpy(ctx->result->identity.pubkey_hex, pubkey_hex, 64);
ctx->result->identity.pubkey_hex[64] = '\0';
privkey_to_hex(privkey, ctx->result->identity.privkey_hex);
ctx->done = TRUE;
gtk_dialog_response(GTK_DIALOG(ctx->dialog), GTK_RESPONSE_ACCEPT);
return;
}
/* ── Seed phrase ───────────────────────────────────────── */
if (strcmp(current, "seed") == 0) {
GtkWidget *screen = gtk_stack_get_child_by_name(GTK_STACK(ctx->stack), "seed");
GtkWidget *entry = g_object_get_data(G_OBJECT(screen), "mnemonic-entry");
const char *mnemonic = gtk_entry_get_text(GTK_ENTRY(entry));
if (mnemonic[0] == '\0') {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
"Enter a seed phrase or click Generate.");
return;
}
unsigned char privkey[32], pubkey[32];
if (nostr_derive_keys_from_mnemonic(mnemonic, 0, privkey, pubkey) != 0) {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
"Invalid seed phrase. Check the words and try again.");
return;
}
char pubkey_hex[65];
/* Convert pubkey bytes to hex. */
for (int i = 0; i < 32; i++) {
snprintf(pubkey_hex + i * 2, 3, "%02x", pubkey[i]);
}
pubkey_hex[64] = '\0';
nostr_signer_t *signer = nostr_signer_local(privkey);
if (signer == NULL) {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
"Failed to create signer.");
return;
}
ctx->result->method = KEY_STORE_METHOD_SEED;
ctx->result->signer = signer;
memcpy(ctx->result->pubkey_hex, pubkey_hex, 64);
ctx->result->pubkey_hex[64] = '\0';
ctx->result->identity.method = KEY_STORE_METHOD_SEED;
memcpy(ctx->result->identity.pubkey_hex, pubkey_hex, 64);
ctx->result->identity.pubkey_hex[64] = '\0';
privkey_to_hex(privkey, ctx->result->identity.privkey_hex);
strncpy(ctx->result->identity.mnemonic, mnemonic, sizeof(ctx->result->identity.mnemonic) - 1);
ctx->result->identity.mnemonic[sizeof(ctx->result->identity.mnemonic) - 1] = '\0';
ctx->done = TRUE;
gtk_dialog_response(GTK_DIALOG(ctx->dialog), GTK_RESPONSE_ACCEPT);
return;
}
/* ── Read-only (npub) ──────────────────────────────────── */
if (strcmp(current, "readonly") == 0) {
GtkWidget *screen = gtk_stack_get_child_by_name(GTK_STACK(ctx->stack), "readonly");
GtkWidget *entry = g_object_get_data(G_OBJECT(screen), "npub-entry");
const char *input = gtk_entry_get_text(GTK_ENTRY(entry));
if (input[0] == '\0') {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
"Enter an npub1... or 64-char hex pubkey.");
return;
}
unsigned char pubkey[32];
char pubkey_hex[65];
if (strncmp(input, "npub1", 5) == 0) {
if (nostr_decode_npub(input, pubkey) != 0) {
gtk_label_set_text(GTK_LABEL(ctx->status_label), "Invalid npub.");
return;
}
for (int i = 0; i < 32; i++) {
snprintf(pubkey_hex + i * 2, 3, "%02x", pubkey[i]);
}
pubkey_hex[64] = '\0';
} else if (strlen(input) == 64) {
if (hex_to_bytes32(input, pubkey) != 0) {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
"Invalid hex pubkey.");
return;
}
memcpy(pubkey_hex, input, 64);
pubkey_hex[64] = '\0';
} else {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
"Enter an npub1... or 64-char hex pubkey.");
return;
}
/* Read-only: no signer created. */
ctx->result->method = KEY_STORE_METHOD_READONLY;
ctx->result->signer = NULL;
memcpy(ctx->result->pubkey_hex, pubkey_hex, 64);
ctx->result->pubkey_hex[64] = '\0';
ctx->result->identity.method = KEY_STORE_METHOD_READONLY;
memcpy(ctx->result->identity.pubkey_hex, pubkey_hex, 64);
ctx->result->identity.pubkey_hex[64] = '\0';
ctx->done = TRUE;
gtk_dialog_response(GTK_DIALOG(ctx->dialog), GTK_RESPONSE_ACCEPT);
return;
}
/* ── NIP-46 remote signer ──────────────────────────────── */
if (strcmp(current, "nip46") == 0) {
GtkWidget *screen = gtk_stack_get_child_by_name(GTK_STACK(ctx->stack), "nip46");
GtkWidget *entry = g_object_get_data(G_OBJECT(screen), "bunker-entry");
const char *bunker_url = gtk_entry_get_text(GTK_ENTRY(entry));
if (bunker_url[0] == '\0') {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
"Enter a bunker:// URL.");
return;
}
/* Parse the bunker URL. */
nostr_nip46_bunker_url_t bunker;
memset(&bunker, 0, sizeof(bunker));
if (nostr_nip46_parse_bunker_url(bunker_url, &bunker) != 0) {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
"Invalid bunker:// URL. Format: bunker://<pubkey>?relay=wss://...&secret=...");
return;
}
/* For NIP-46, we need a client keypair. Generate one if not provided.
* The client key is used to encrypt requests to the remote signer.
* The user's actual pubkey comes from the remote signer. */
unsigned char client_privkey[32], client_pubkey[32];
if (nostr_generate_keypair(client_privkey, client_pubkey) != 0) {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
"Failed to generate client keypair.");
return;
}
/* The user's pubkey is the remote signer's pubkey. */
char pubkey_hex[65];
strncpy(pubkey_hex, bunker.remote_signer_pubkey, 64);
pubkey_hex[64] = '\0';
/* For now, we store the bunker URL and client key. The actual
* WebSocket connection to the relay will be established when
* signing is needed (in the bridge). This is a simplified
* implementation — a full NIP-46 client would connect now,
* send a connect request, and wait for approval. */
nostr_signer_t *signer = nostr_signer_local(client_privkey);
if (signer == NULL) {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
"Failed to create client signer.");
return;
}
/* Store the client private key as the signer key — the bridge
* will use it to encrypt NIP-46 requests. The pubkey stored
* is the remote signer's pubkey (the user's identity). */
ctx->result->method = KEY_STORE_METHOD_NIP46;
ctx->result->signer = signer;
memcpy(ctx->result->pubkey_hex, pubkey_hex, 64);
ctx->result->pubkey_hex[64] = '\0';
ctx->result->identity.method = KEY_STORE_METHOD_NIP46;
memcpy(ctx->result->identity.pubkey_hex, pubkey_hex, 64);
ctx->result->identity.pubkey_hex[64] = '\0';
privkey_to_hex(client_privkey, ctx->result->identity.privkey_hex);
strncpy(ctx->result->identity.bunker_url, bunker_url,
sizeof(ctx->result->identity.bunker_url) - 1);
ctx->result->identity.bunker_url[sizeof(ctx->result->identity.bunker_url) - 1] = '\0';
char npub[128];
pubkey_to_npub(pubkey_hex, npub);
g_print("[login] NIP-46: remote signer pubkey=%s npub=%s\n", pubkey_hex,
npub[0] ? npub : "(conversion failed)");
ctx->done = TRUE;
gtk_dialog_response(GTK_DIALOG(ctx->dialog), GTK_RESPONSE_ACCEPT);
return;
}
/* ── n_signer hardware ─────────────────────────────────── */
if (strcmp(current, "nsigner") == 0) {
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
GtkWidget *screen = gtk_stack_get_child_by_name(GTK_STACK(ctx->stack), "nsigner");
GtkWidget *transport_combo = g_object_get_data(G_OBJECT(screen), "transport-combo");
GtkWidget *device_entry = g_object_get_data(G_OBJECT(screen), "device-entry");
GtkWidget *index_spin = g_object_get_data(G_OBJECT(screen), "index-spin");
const char *device = gtk_entry_get_text(GTK_ENTRY(device_entry));
int nostr_index = gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(index_spin));
gint transport_idx = gtk_combo_box_get_active(GTK_COMBO_BOX(transport_combo));
if (device[0] == '\0') {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
"Enter a device path or select one.");
return;
}
/* transport_idx: 0=serial, 1=unix, 2=tcp, 3=qrexec */
nostr_signer_t *signer = NULL;
const char *transport_name = "serial";
if (transport_idx == 0) {
signer = nostr_signer_nsigner_serial(device, NULL, 15000);
transport_name = "serial";
} else if (transport_idx == 1) {
signer = nostr_signer_nsigner_unix(device, NULL, 15000);
transport_name = "unix";
} else if (transport_idx == 2) {
/* device is "host:port" */
char host[256];
int port = 0;
const char *sep = strrchr(device, ':');
if (sep && sep != device) {
size_t hlen = (size_t)(sep - device);
if (hlen < sizeof(host)) {
memcpy(host, device, hlen);
host[hlen] = '\0';
port = atoi(sep + 1);
signer = nostr_signer_nsigner_tcp(host, port, NULL, 15000);
transport_name = "tcp";
}
}
} else if (transport_idx == 3) {
signer = nostr_signer_nsigner_qrexec(device, "qubes.NsignerRpc", NULL, 30000);
transport_name = "qrexec";
}
if (signer == NULL) {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
"Failed to connect to n_signer. Check the device/path.");
return;
}
/* Set the nostr_index. */
if (nostr_signer_nsigner_set_nostr_index(signer, nostr_index) != NOSTR_SUCCESS) {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
"Failed to set nostr_index on n_signer.");
nostr_signer_free(signer);
return;
}
/* Get the pubkey to verify the connection. */
char pubkey_hex[65];
if (nostr_signer_get_public_key(signer, pubkey_hex) != NOSTR_SUCCESS) {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
"Failed to get pubkey from n_signer. Is the device unlocked?");
nostr_signer_free(signer);
return;
}
char npub[128];
pubkey_to_npub(pubkey_hex, npub);
g_print("[login] n_signer: transport=%s index=%d pubkey=%s npub=%s\n",
transport_name, nostr_index, pubkey_hex,
npub[0] ? npub : "(conversion failed)");
ctx->result->method = KEY_STORE_METHOD_NSIGNER;
ctx->result->signer = signer;
memcpy(ctx->result->pubkey_hex, pubkey_hex, 64);
ctx->result->pubkey_hex[64] = '\0';
ctx->result->identity.method = KEY_STORE_METHOD_NSIGNER;
memcpy(ctx->result->identity.pubkey_hex, pubkey_hex, 64);
ctx->result->identity.pubkey_hex[64] = '\0';
strncpy(ctx->result->identity.nsigner_transport, transport_name,
sizeof(ctx->result->identity.nsigner_transport) - 1);
ctx->result->identity.nsigner_transport[sizeof(ctx->result->identity.nsigner_transport) - 1] = '\0';
strncpy(ctx->result->identity.nsigner_device, device,
sizeof(ctx->result->identity.nsigner_device) - 1);
ctx->result->identity.nsigner_device[sizeof(ctx->result->identity.nsigner_device) - 1] = '\0';
ctx->result->identity.nsigner_index = nostr_index;
ctx->done = TRUE;
gtk_dialog_response(GTK_DIALOG(ctx->dialog), GTK_RESPONSE_ACCEPT);
return;
#else
gtk_label_set_text(GTK_LABEL(ctx->status_label),
"n_signer client not compiled in (NOSTR_ENABLE_NSIGNER_CLIENT).");
return;
#endif
}
gtk_label_set_text(GTK_LABEL(ctx->status_label), "Unknown method.");
}
static void on_cancel_clicked(GtkWidget *btn, gpointer user_data) {
(void)btn;
login_ctx_t *ctx = (login_ctx_t *)user_data;
ctx->done = FALSE;
gtk_dialog_response(GTK_DIALOG(ctx->dialog), GTK_RESPONSE_CANCEL);
}
/* ── Screen creation ──────────────────────────────────────────── */
static GtkWidget *create_local_screen(login_ctx_t *ctx) {
(void)ctx;
GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
gtk_widget_set_margin_top(box, 12);
gtk_widget_set_margin_bottom(box, 12);
gtk_widget_set_margin_start(box, 12);
gtk_widget_set_margin_end(box, 12);
GtkWidget *label = gtk_label_new("Enter your Nostr private key (nsec) or generate a new one:");
gtk_widget_set_halign(label, GTK_ALIGN_START);
gtk_box_pack_start(GTK_BOX(box), label, FALSE, FALSE, 0);
GtkWidget *entry = gtk_entry_new();
gtk_entry_set_placeholder_text(GTK_ENTRY(entry), "nsec1...");
gtk_entry_set_width_chars(GTK_ENTRY(entry), 60);
gtk_box_pack_start(GTK_BOX(box), entry, FALSE, FALSE, 0);
GtkWidget *gen_btn = gtk_button_new_with_label("Generate New Key");
g_signal_connect(gen_btn, "clicked", G_CALLBACK(on_generate_key), entry);
gtk_box_pack_start(GTK_BOX(box), gen_btn, FALSE, FALSE, 0);
g_object_set_data(G_OBJECT(box), "nsec-entry", entry);
return box;
}
static GtkWidget *create_seed_screen(login_ctx_t *ctx) {
(void)ctx;
GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
gtk_widget_set_margin_top(box, 12);
gtk_widget_set_margin_bottom(box, 12);
gtk_widget_set_margin_start(box, 12);
gtk_widget_set_margin_end(box, 12);
GtkWidget *label = gtk_label_new("Enter a BIP-39 seed phrase (12-24 words) or generate a new one:");
gtk_widget_set_halign(label, GTK_ALIGN_START);
gtk_box_pack_start(GTK_BOX(box), label, FALSE, FALSE, 0);
GtkWidget *entry = gtk_entry_new();
gtk_entry_set_placeholder_text(GTK_ENTRY(entry), "abandon abandon abandon ... abandon art");
gtk_entry_set_width_chars(GTK_ENTRY(entry), 60);
gtk_box_pack_start(GTK_BOX(box), entry, FALSE, FALSE, 0);
GtkWidget *gen_btn = gtk_button_new_with_label("Generate New Mnemonic");
g_signal_connect(gen_btn, "clicked", G_CALLBACK(on_generate_mnemonic), entry);
gtk_box_pack_start(GTK_BOX(box), gen_btn, FALSE, FALSE, 0);
GtkWidget *hint = gtk_label_new("Key derivation: m/44'/1237'/0'/0/0 (account 0)");
gtk_widget_set_sensitive(hint, FALSE);
gtk_widget_set_halign(hint, GTK_ALIGN_START);
gtk_box_pack_start(GTK_BOX(box), hint, FALSE, FALSE, 0);
g_object_set_data(G_OBJECT(box), "mnemonic-entry", entry);
return box;
}
static GtkWidget *create_readonly_screen(login_ctx_t *ctx) {
(void)ctx;
GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
gtk_widget_set_margin_top(box, 12);
gtk_widget_set_margin_bottom(box, 12);
gtk_widget_set_margin_start(box, 12);
gtk_widget_set_margin_end(box, 12);
GtkWidget *label = gtk_label_new("Enter a Nostr public key (npub) for read-only mode:");
gtk_widget_set_halign(label, GTK_ALIGN_START);
gtk_box_pack_start(GTK_BOX(box), label, FALSE, FALSE, 0);
GtkWidget *entry = gtk_entry_new();
gtk_entry_set_placeholder_text(GTK_ENTRY(entry), "npub1...");
gtk_entry_set_width_chars(GTK_ENTRY(entry), 60);
gtk_box_pack_start(GTK_BOX(box), entry, FALSE, FALSE, 0);
GtkWidget *hint = gtk_label_new("Read-only mode: you can view content but cannot sign events.");
gtk_widget_set_sensitive(hint, FALSE);
gtk_widget_set_halign(hint, GTK_ALIGN_START);
gtk_box_pack_start(GTK_BOX(box), hint, FALSE, FALSE, 0);
g_object_set_data(G_OBJECT(box), "npub-entry", entry);
return box;
}
static GtkWidget *create_nip46_screen(login_ctx_t *ctx) {
(void)ctx;
GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
gtk_widget_set_margin_top(box, 12);
gtk_widget_set_margin_bottom(box, 12);
gtk_widget_set_margin_start(box, 12);
gtk_widget_set_margin_end(box, 12);
GtkWidget *label = gtk_label_new("Connect to a NIP-46 remote signer (bunker:// URL):");
gtk_widget_set_halign(label, GTK_ALIGN_START);
gtk_box_pack_start(GTK_BOX(box), label, FALSE, FALSE, 0);
GtkWidget *entry = gtk_entry_new();
gtk_entry_set_placeholder_text(GTK_ENTRY(entry),
"bunker://<pubkey>?relay=wss://...&secret=...");
gtk_entry_set_width_chars(GTK_ENTRY(entry), 60);
gtk_box_pack_start(GTK_BOX(box), entry, FALSE, FALSE, 0);
GtkWidget *hint = gtk_label_new("The remote signer holds your private key. Signing requests are sent over Nostr relays.");
gtk_widget_set_sensitive(hint, FALSE);
gtk_widget_set_halign(hint, GTK_ALIGN_START);
gtk_label_set_line_wrap(GTK_LABEL(hint), TRUE);
gtk_box_pack_start(GTK_BOX(box), hint, FALSE, FALSE, 0);
g_object_set_data(G_OBJECT(box), "bunker-entry", entry);
return box;
}
static GtkWidget *create_nsigner_screen(login_ctx_t *ctx) {
(void)ctx;
GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
gtk_widget_set_margin_top(box, 12);
gtk_widget_set_margin_bottom(box, 12);
gtk_widget_set_margin_start(box, 12);
gtk_widget_set_margin_end(box, 12);
GtkWidget *label = gtk_label_new("Connect to n_signer hardware signer:");
gtk_widget_set_halign(label, GTK_ALIGN_START);
gtk_box_pack_start(GTK_BOX(box), label, FALSE, FALSE, 0);
/* Transport type selector. */
GtkWidget *transport_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_box_pack_start(GTK_BOX(box), transport_box, FALSE, FALSE, 0);
GtkWidget *transport_label = gtk_label_new("Transport:");
gtk_box_pack_start(GTK_BOX(transport_box), transport_label, FALSE, FALSE, 0);
GtkWidget *transport_combo = gtk_combo_box_text_new();
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "USB Serial");
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "UNIX Socket");
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "TCP");
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "Qubes qrexec");
gtk_combo_box_set_active(GTK_COMBO_BOX(transport_combo), 0);
gtk_box_pack_start(GTK_BOX(transport_box), transport_combo, FALSE, FALSE, 0);
/* Device path entry. */
GtkWidget *device_label = gtk_label_new("Device / Path:");
gtk_widget_set_halign(device_label, GTK_ALIGN_START);
gtk_box_pack_start(GTK_BOX(box), device_label, FALSE, FALSE, 0);
GtkWidget *device_entry = gtk_entry_new();
gtk_entry_set_placeholder_text(GTK_ENTRY(device_entry), "/dev/ttyACM0");
gtk_entry_set_width_chars(GTK_ENTRY(device_entry), 40);
gtk_box_pack_start(GTK_BOX(box), device_entry, FALSE, FALSE, 0);
/* Enumerate serial devices button. */
GtkWidget *enum_btn = gtk_button_new_with_label("Detect Serial Devices");
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
g_signal_connect(enum_btn, "clicked", G_CALLBACK(on_detect_serial), device_entry);
#else
gtk_widget_set_sensitive(enum_btn, FALSE);
#endif
gtk_box_pack_start(GTK_BOX(box), enum_btn, FALSE, FALSE, 0);
/* Nostr index spinner. */
GtkWidget *index_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_box_pack_start(GTK_BOX(box), index_box, FALSE, FALSE, 0);
GtkWidget *index_label = gtk_label_new("Key Index (NIP-06 m/44'/1237'/N'/0/0):");
gtk_box_pack_start(GTK_BOX(index_box), index_label, FALSE, FALSE, 0);
GtkWidget *index_spin = gtk_spin_button_new_with_range(0, 1000, 1);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(index_spin), 0);
gtk_box_pack_start(GTK_BOX(index_box), index_spin, FALSE, FALSE, 0);
GtkWidget *hint = gtk_label_new("n_signer is a foreground, RAM-only hardware signer. Your private key never leaves the device.");
gtk_widget_set_sensitive(hint, FALSE);
gtk_widget_set_halign(hint, GTK_ALIGN_START);
gtk_label_set_line_wrap(GTK_LABEL(hint), TRUE);
gtk_box_pack_start(GTK_BOX(box), hint, FALSE, FALSE, 0);
g_object_set_data(G_OBJECT(box), "transport-combo", transport_combo);
g_object_set_data(G_OBJECT(box), "device-entry", device_entry);
g_object_set_data(G_OBJECT(box), "index-spin", index_spin);
return box;
}
/* ── Serial device enumeration callback ───────────────────────── */
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
static void on_detect_serial(GtkWidget *btn, gpointer user_data) {
(void)btn;
GtkWidget *device_entry = GTK_WIDGET(user_data);
char paths[16][64];
int count = nsigner_transport_list_serial(paths, 16);
if (count <= 0) {
gtk_entry_set_text(GTK_ENTRY(device_entry), "(no serial devices found)");
return;
}
/* For now, just set the first device. A future improvement would
* show a dropdown of all detected devices. */
gtk_entry_set_text(GTK_ENTRY(device_entry), paths[0]);
g_print("[nsigner] Detected %d serial device(s): %s\n", count, paths[0]);
}
#endif
/* ── Main dialog ──────────────────────────────────────────────── */
int login_dialog_run(GtkWindow *parent, login_result_t *result) {
memset(result, 0, sizeof(*result));
if (nostr_init() != NOSTR_SUCCESS) {
return -1;
}
GtkWidget *dialog = gtk_dialog_new_with_buttons(
"sovereign browser — Sign In",
parent,
GTK_DIALOG_MODAL,
"_Cancel", GTK_RESPONSE_CANCEL,
"_Sign In", GTK_RESPONSE_ACCEPT,
NULL);
gtk_window_set_default_size(GTK_WINDOW(dialog), 560, 380);
/* Apply monospace font styling to the dialog. */
GtkCssProvider *css = gtk_css_provider_new();
gtk_css_provider_load_from_data(css,
"dialog { font-family: monospace; }\n"
"label { font-family: monospace; }\n"
"entry { font-family: monospace; }\n"
"button { font-family: monospace; }\n",
-1, NULL);
GtkStyleContext *sctx = gtk_widget_get_style_context(dialog);
gtk_style_context_add_provider(sctx, GTK_STYLE_PROVIDER(css),
GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
g_object_unref(css);
GtkWidget *content = gtk_dialog_get_content_area(GTK_DIALOG(dialog));
gtk_container_set_border_width(GTK_CONTAINER(content), 12);
login_ctx_t ctx;
memset(&ctx, 0, sizeof(ctx));
ctx.dialog = dialog;
ctx.content_area = content;
ctx.result = result;
ctx.done = FALSE;
/* Title. */
GtkWidget *title = gtk_label_new(NULL);
gtk_label_set_markup(GTK_LABEL(title),
"<span size='large' weight='bold'>Sign in with your Nostr key</span>");
gtk_box_pack_start(GTK_BOX(content), title, FALSE, FALSE, 8);
/* Method selector. */
GtkWidget *method_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_box_pack_start(GTK_BOX(content), method_box, FALSE, FALSE, 4);
/* Stack for method-specific screens. */
GtkWidget *stack = gtk_stack_new();
gtk_stack_set_transition_type(GTK_STACK(stack), GTK_STACK_TRANSITION_TYPE_CROSSFADE);
gtk_box_pack_start(GTK_BOX(content), stack, TRUE, TRUE, 4);
ctx.stack = stack;
/* Create all screens. */
GtkWidget *local_screen = create_local_screen(&ctx);
gtk_stack_add_named(GTK_STACK(stack), local_screen, "local");
GtkWidget *seed_screen = create_seed_screen(&ctx);
gtk_stack_add_named(GTK_STACK(stack), seed_screen, "seed");
GtkWidget *readonly_screen = create_readonly_screen(&ctx);
gtk_stack_add_named(GTK_STACK(stack), readonly_screen, "readonly");
GtkWidget *nip46_screen = create_nip46_screen(&ctx);
gtk_stack_add_named(GTK_STACK(stack), nip46_screen, "nip46");
GtkWidget *nsigner_screen = create_nsigner_screen(&ctx);
gtk_stack_add_named(GTK_STACK(stack), nsigner_screen, "nsigner");
/* Method buttons. */
const struct {
const char *label;
const char *stack_name;
} methods[] = {
{"Local Key", "local"},
{"Seed Phrase", "seed"},
{"Read-only", "readonly"},
{"NIP-46", "nip46"},
{"n_signer", "nsigner"},
};
for (size_t i = 0; i < sizeof(methods) / sizeof(methods[0]); i++) {
GtkWidget *btn = gtk_button_new_with_label(methods[i].label);
g_object_set_data(G_OBJECT(btn), "stack-name", (gpointer)methods[i].stack_name);
g_signal_connect(btn, "clicked", G_CALLBACK(on_method_button_clicked), stack);
gtk_box_pack_start(GTK_BOX(method_box), btn, FALSE, FALSE, 0);
}
/* Status label. */
GtkWidget *status = gtk_label_new("");
gtk_widget_set_halign(status, GTK_ALIGN_START);
gtk_box_pack_start(GTK_BOX(content), status, FALSE, FALSE, 4);
ctx.status_label = status;
/* Wire the dialog buttons. */
GtkWidget *cancel_btn = gtk_dialog_get_widget_for_response(GTK_DIALOG(dialog), GTK_RESPONSE_CANCEL);
GtkWidget *login_btn = gtk_dialog_get_widget_for_response(GTK_DIALOG(dialog), GTK_RESPONSE_ACCEPT);
g_signal_connect(cancel_btn, "clicked", G_CALLBACK(on_cancel_clicked), &ctx);
g_signal_connect(login_btn, "clicked", G_CALLBACK(on_login_clicked), &ctx);
gtk_widget_show_all(dialog);
gint response = gtk_dialog_run(GTK_DIALOG(dialog));
if (!ctx.done || response != GTK_RESPONSE_ACCEPT) {
if (result->signer) {
nostr_signer_free(result->signer);
result->signer = NULL;
}
memset(result, 0, sizeof(*result));
gtk_widget_destroy(dialog);
return -1;
}
gtk_widget_destroy(dialog);
return 0;
}
void login_result_free(login_result_t *result) {
if (result == NULL) return;
if (result->signer) {
nostr_signer_free(result->signer);
result->signer = NULL;
}
}

51
src/login_dialog.h Normal file
View File

@@ -0,0 +1,51 @@
/*
* login_dialog.h — GTK Nostr login dialog for sovereign_browser
*
* Presents a modal GTK dialog where the user selects a login method and
* enters credentials. On success, returns a nostr_signer_t and the user's
* pubkey hex. The caller is responsible for freeing the signer.
*/
#ifndef LOGIN_DIALOG_H
#define LOGIN_DIALOG_H
#include <gtk/gtk.h>
#include "nostr_core/nostr_signer.h"
#include "key_store.h"
#ifdef __cplusplus
extern "C" {
#endif
/*
* Result of a login dialog interaction.
* On success: signer != NULL (or NULL for readonly), identity is filled in.
* On cancel: signer == NULL, method == KEY_STORE_METHOD_NONE.
*/
typedef struct {
key_store_method_t method;
nostr_signer_t *signer; /* NULL for readonly; caller frees */
char pubkey_hex[65];
key_store_identity_t identity; /* full identity for key_store_save() */
} login_result_t;
/*
* Show the login dialog (modal). Blocks until the user completes or cancels.
* Initializes nostr_core_lib if needed.
*
* Returns 0 on success (result filled in), -1 on error/cancel.
* The caller must free result->signer with nostr_signer_free() if non-NULL.
*/
int login_dialog_run(GtkWindow *parent, login_result_t *result);
/*
* Free resources associated with a login result (the signer).
* Does not free the result struct itself.
*/
void login_result_free(login_result_t *result);
#ifdef __cplusplus
}
#endif
#endif /* LOGIN_DIALOG_H */

View File

@@ -1,14 +1,17 @@
/*
* sovereign_browser — WebKitGTK + C99
*
* Minimal browser: a GTK window with a URL entry and a WebKitWebView.
* Type a URL, press Enter, the page loads. That's it for now.
* Minimal browser: a GTK window with a URL entry, a hamburger menu, and a
* WebKitWebView. Type a URL, press Enter, the page loads. The hamburger
* menu to the left of the URL bar exposes roadmap actions (reload, security
* strip toggle, FIPS, Nostr signing) as placeholders to wire up next.
*
* On startup, a Nostr login dialog is shown. The user signs in with a local
* key (nsec), seed phrase, read-only npub, NIP-46 remote signer, or n_signer
* hardware. The resulting nostr_signer_t is held globally and will be used
* to inject window.nostr into every page (Phase 2).
*
* Build: make. Run: ./sovereign_browser [url]
*
* Goal: a usable browser first. FIPS URI scheme, Nostr signing (via
* n_signer), and security stripping come next. See README.md and
* docs/architecture.md.
*/
#include <gtk/gtk.h>
@@ -17,8 +20,295 @@
#include <string.h>
#include <stdlib.h>
/* Normalize a bare string into a loadable URL.
* "example.com" -> "https://example.com"; "http://..." left as-is. */
#include "version.h"
#include "key_store.h"
#include "login_dialog.h"
#include "nostr_bridge.h"
#include "nostr_inject.h"
#include "nostr_core/nostr_core.h"
/* ---- Global state --------------------------------------------------- *
* The signer is created at login and held for the lifetime of the session.
* Phase 2 will use it to back the window.nostr injection.
*/
typedef struct {
nostr_signer_t *signer; /* NULL for read-only mode */
char pubkey_hex[65];
key_store_method_t method;
gboolean readonly; /* TRUE if no signing available */
} app_state_t;
static app_state_t g_state = {0};
/* ---- Menu action callbacks ------------------------------------------- */
static void on_menu_reload(GtkMenuItem *item, WebKitWebView *webview) {
(void)item;
if (webview != NULL) {
webkit_web_view_reload_bypass_cache(webview);
}
}
static void on_menu_stop(GtkMenuItem *item, WebKitWebView *webview) {
(void)item;
if (webview != NULL) {
webkit_web_view_stop_loading(webview);
}
}
static void on_menu_security_strip(GtkMenuItem *item, gpointer data) {
(void)data;
gboolean active = gtk_check_menu_item_get_active(GTK_CHECK_MENU_ITEM(item));
g_print("[menu] security strip: %s\n", active ? "ON" : "OFF");
}
static void on_menu_fips(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
g_print("[menu] FIPS URI scheme: not yet implemented (roadmap)\n");
}
static void on_menu_nostr_sign(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
if (g_state.signer) {
g_print("[menu] Nostr signing: signer active, pubkey=%s\n",
g_state.pubkey_hex);
} else if (g_state.readonly) {
g_print("[menu] Nostr signing: read-only mode (no signer)\n");
} else {
g_print("[menu] Nostr signing: no signer loaded\n");
}
}
/* Show the login dialog again (switch identity / re-auth). */
static void on_menu_switch_identity(GtkMenuItem *item, gpointer data) {
(void)item;
GtkWindow *window = GTK_WINDOW(data);
if (window == NULL) return;
login_result_t result;
if (login_dialog_run(window, &result) == 0) {
/* Free the old signer. */
if (g_state.signer) {
nostr_signer_free(g_state.signer);
}
g_state.signer = result.signer;
g_state.method = result.method;
strncpy(g_state.pubkey_hex, result.pubkey_hex, 64);
g_state.pubkey_hex[64] = '\0';
g_state.readonly = (result.method == KEY_STORE_METHOD_READONLY);
/* Update the bridge so window.nostr uses the new signer. */
nostr_bridge_set_signer(g_state.signer, g_state.pubkey_hex,
g_state.readonly);
g_print("[identity] switched: method=%d pubkey=%s\n",
g_state.method, g_state.pubkey_hex);
}
}
static void on_menu_logout(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
if (g_state.signer) {
nostr_signer_free(g_state.signer);
g_state.signer = NULL;
}
g_state.pubkey_hex[0] = '\0';
g_state.method = KEY_STORE_METHOD_NONE;
g_state.readonly = FALSE;
key_store_clear();
g_print("[identity] logged out\n");
}
static void on_menu_about(GtkMenuItem *item, gpointer data) {
(void)item;
GtkWidget *window = GTK_WIDGET(data);
if (window == NULL) {
return;
}
GtkWidget *dialog = gtk_message_dialog_new(
GTK_WINDOW(window),
GTK_DIALOG_DESTROY_WITH_PARENT,
GTK_MESSAGE_INFO,
GTK_BUTTONS_OK,
"sovereign browser %s\n"
"WebKitGTK + C99 — sovereign identity, not permissioned domains.",
SB_VERSION);
g_signal_connect(dialog, "response", G_CALLBACK(gtk_widget_destroy), NULL);
gtk_widget_show_all(dialog);
}
/* Toggle the WebKit Web Inspector. */
static void on_menu_inspector(GtkMenuItem *item, gpointer data) {
(void)item;
WebKitWebView *webview = WEBKIT_WEB_VIEW(data);
if (webview == NULL) return;
WebKitWebInspector *inspector = webkit_web_view_get_inspector(webview);
if (inspector == NULL) return;
/* Just show the inspector — WebKitGTK handles the toggle. */
webkit_web_inspector_show(inspector);
}
/* Open a local file via file chooser dialog. */
static void on_menu_open_file(GtkMenuItem *item, gpointer data) {
(void)item;
GtkWindow *window = GTK_WINDOW(data);
if (window == NULL) return;
GtkWidget *dialog = gtk_file_chooser_dialog_new(
"Open File",
window,
GTK_FILE_CHOOSER_ACTION_OPEN,
"_Cancel", GTK_RESPONSE_CANCEL,
"_Open", GTK_RESPONSE_ACCEPT,
NULL);
GtkFileFilter *filter_html = gtk_file_filter_new();
gtk_file_filter_set_name(filter_html, "HTML files");
gtk_file_filter_add_pattern(filter_html, "*.html");
gtk_file_filter_add_pattern(filter_html, "*.htm");
gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(dialog), filter_html);
GtkFileFilter *filter_all = gtk_file_filter_new();
gtk_file_filter_set_name(filter_all, "All files");
gtk_file_filter_add_pattern(filter_all, "*");
gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(dialog), filter_all);
if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) {
char *filename = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(dialog));
char *uri = g_strdup_printf("file://%s", filename);
/* Get the web view from the window's data. */
WebKitWebView *webview = g_object_get_data(G_OBJECT(window), "webview");
if (webview) {
webkit_web_view_load_uri(webview, uri);
}
g_free(uri);
g_free(filename);
}
gtk_widget_destroy(dialog);
}
/* Lock session: clear the signer but keep the saved identity.
* The user will need to re-authenticate (switch identity) to sign again. */
static void on_menu_lock_session(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
if (g_state.signer) {
nostr_signer_free(g_state.signer);
g_state.signer = NULL;
}
g_state.readonly = TRUE; /* no signing until re-auth */
nostr_bridge_set_signer(NULL, g_state.pubkey_hex, TRUE);
g_print("[identity] session locked (signer cleared, identity preserved)\n");
}
/* Build the hamburger menu. */
static GtkWidget *build_hamburger_menu(GtkWidget *window,
WebKitWebView *webview) {
GtkWidget *menu = gtk_menu_new();
/* Navigation group. */
GtkWidget *item_open = gtk_menu_item_new_with_label("Open File…");
GtkWidget *item_reload = gtk_menu_item_new_with_label("Reload");
GtkWidget *item_stop = gtk_menu_item_new_with_label("Stop");
g_signal_connect(item_open, "activate", G_CALLBACK(on_menu_open_file),
window);
g_signal_connect(item_reload, "activate", G_CALLBACK(on_menu_reload),
webview);
g_signal_connect(item_stop, "activate", G_CALLBACK(on_menu_stop), webview);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_open);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_reload);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_stop);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
/* Identity group — show current identity status. */
char identity_label[80];
const char *method_name = "none";
switch (g_state.method) {
case KEY_STORE_METHOD_LOCAL: method_name = "local"; break;
case KEY_STORE_METHOD_SEED: method_name = "seed"; break;
case KEY_STORE_METHOD_READONLY: method_name = "readonly"; break;
case KEY_STORE_METHOD_NIP46: method_name = "nip46"; break;
case KEY_STORE_METHOD_NSIGNER: method_name = "nsigner"; break;
default: break;
}
if (g_state.pubkey_hex[0]) {
/* Show first 8 chars of pubkey for identification. */
char short_pubkey[12];
memcpy(short_pubkey, g_state.pubkey_hex, 8);
short_pubkey[8] = '\0';
snprintf(identity_label, sizeof(identity_label),
"Identity: %s… (%s)", short_pubkey, method_name);
} else {
snprintf(identity_label, sizeof(identity_label), "Identity: none");
}
GtkWidget *item_identity = gtk_menu_item_new_with_label(identity_label);
gtk_widget_set_sensitive(item_identity, FALSE);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_identity);
GtkWidget *item_switch = gtk_menu_item_new_with_label("Switch Identity…");
GtkWidget *item_lock = gtk_menu_item_new_with_label("Lock Session");
GtkWidget *item_logout = gtk_menu_item_new_with_label("Logout");
g_signal_connect(item_switch, "activate", G_CALLBACK(on_menu_switch_identity),
window);
g_signal_connect(item_lock, "activate", G_CALLBACK(on_menu_lock_session), NULL);
g_signal_connect(item_logout, "activate", G_CALLBACK(on_menu_logout), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_switch);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_lock);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_logout);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
/* Roadmap group. */
GtkWidget *item_security =
gtk_check_menu_item_new_with_label("Security strip (SOP/CORS/certs)");
gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(item_security), FALSE);
g_signal_connect(item_security, "toggled",
G_CALLBACK(on_menu_security_strip), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_security);
GtkWidget *item_fips = gtk_menu_item_new_with_label("FIPS URI scheme…");
GtkWidget *item_nostr = gtk_menu_item_new_with_label("Nostr signing status");
g_signal_connect(item_fips, "activate", G_CALLBACK(on_menu_fips), NULL);
g_signal_connect(item_nostr, "activate", G_CALLBACK(on_menu_nostr_sign),
NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_fips);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_nostr);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
GtkWidget *item_inspector = gtk_menu_item_new_with_label("Toggle Inspector");
g_signal_connect(item_inspector, "activate", G_CALLBACK(on_menu_inspector),
webview);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_inspector);
GtkWidget *item_about = gtk_menu_item_new_with_label("About");
g_signal_connect(item_about, "activate", G_CALLBACK(on_menu_about),
window);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_about);
gtk_widget_show_all(menu);
GtkWidget *button = gtk_menu_button_new();
gtk_menu_button_set_popup(GTK_MENU_BUTTON(button), menu);
gtk_button_set_image(
GTK_BUTTON(button),
gtk_image_new_from_icon_name("open-menu-symbolic",
GTK_ICON_SIZE_BUTTON));
gtk_widget_set_tooltip_text(button, "Menu");
return button;
}
/* Normalize a bare string into a loadable URL. */
static char *normalize_url(const char *input) {
if (input == NULL || input[0] == '\0') {
return NULL;
@@ -29,7 +319,6 @@ static char *normalize_url(const char *input) {
return g_strdup_printf("https://%s", input);
}
/* Enter pressed in the URL bar: load the typed URL. */
static void on_url_activate(GtkEntry *entry, WebKitWebView *webview) {
const char *text = gtk_entry_get_text(entry);
char *url = normalize_url(text);
@@ -39,7 +328,6 @@ static void on_url_activate(GtkEntry *entry, WebKitWebView *webview) {
}
}
/* Keep the URL bar in sync as the page navigates. */
static void on_load_changed(WebKitWebView *webview,
WebKitLoadEvent load_event,
GtkEntry *url_entry) {
@@ -57,7 +345,6 @@ static void on_load_changed(WebKitWebView *webview,
}
}
/* Surface load failures so we can see them in the terminal. */
static gboolean on_load_failed(WebKitWebView *webview,
WebKitLoadEvent load_event,
gchar *failing_uri,
@@ -72,15 +359,49 @@ static gboolean on_load_failed(WebKitWebView *webview,
return FALSE;
}
/* Window closed: quit the app. */
static void on_window_destroy(GtkWidget *widget, gpointer data) {
(void)widget;
(void)data;
if (g_state.signer) {
nostr_signer_free(g_state.signer);
g_state.signer = NULL;
}
nostr_cleanup();
gtk_main_quit();
}
/* ---- Login flow ------------------------------------------------------ *
* Try to restore a saved identity. If none, show the login dialog.
* Returns 0 on success (signer or readonly loaded), -1 if user cancelled.
*/
static int do_login(GtkWindow *parent) {
/* Initialize nostr_core_lib. */
if (nostr_init() != NOSTR_SUCCESS) {
g_printerr("[login] Failed to initialize nostr_core_lib\n");
return -1;
}
/* Show the login dialog every time — no plaintext key persistence.
* Encrypted key storage will be added in a future phase. */
login_result_t result;
if (login_dialog_run(parent, &result) != 0) {
return -1; /* cancelled */
}
g_state.signer = result.signer;
g_state.method = result.method;
strncpy(g_state.pubkey_hex, result.pubkey_hex, 64);
g_state.pubkey_hex[64] = '\0';
g_state.readonly = (result.method == KEY_STORE_METHOD_READONLY);
g_print("[login] New identity: method=%d pubkey=%s\n",
g_state.method, g_state.pubkey_hex);
return 0;
}
/* ---- Main ------------------------------------------------------------ */
int main(int argc, char **argv) {
/* GTK requires argv parsing first. */
gtk_init(&argc, &argv);
const char *start_url = (argc > 1) ? argv[1] : "https://example.com";
@@ -88,22 +409,95 @@ int main(int argc, char **argv) {
/* Top-level window. */
GtkWidget *window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
gtk_window_set_title(GTK_WINDOW(window), "sovereign browser");
gtk_window_set_title(GTK_WINDOW(window), "sovereign browser " SB_VERSION);
gtk_window_set_default_size(GTK_WINDOW(window), 1024, 768);
g_signal_connect(window, "destroy", G_CALLBACK(on_window_destroy), NULL);
/* Vertical box: URL bar on top, web view below. */
/* Login before showing the browser. */
if (do_login(GTK_WINDOW(window)) != 0) {
/* User cancelled login — exit. */
g_print("[login] Cancelled, exiting.\n");
gtk_widget_destroy(window);
return EXIT_SUCCESS;
}
/* Vertical box: toolbar on top, web view below. */
GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_container_add(GTK_CONTAINER(window), vbox);
/* URL bar. */
WebKitWebView *webview = WEBKIT_WEB_VIEW(webkit_web_view_new());
/* ── Security strip: disable web security restrictions ───────
* The "reckless browser" thesis: identity and transport are handled
* at a different layer (Nostr keys + FIPS mesh), so the browser's
* own security sandbox (CORS, SOP, TLS cert enforcement, mixed
* content blocking) gets in the way and is deliberately removed.
*/
/* Enable developer extras (Web Inspector) for debugging. */
WebKitSettings *settings = webkit_web_view_get_settings(webview);
webkit_settings_set_enable_developer_extras(settings, TRUE);
webkit_settings_set_enable_javascript(settings, TRUE);
webkit_settings_set_javascript_can_open_windows_automatically(settings, TRUE);
/* Allow file:// pages to access other file:// resources and make
* cross-origin requests (needed for our test page to call
* sovereign://). */
webkit_settings_set_allow_file_access_from_file_urls(settings, TRUE);
webkit_settings_set_allow_universal_access_from_file_urls(settings, TRUE);
/* Disable mixed content blocking — allow HTTP resources on HTTPS
* pages (FIPS/nostr:// content may not use TLS). */
webkit_settings_set_allow_modal_dialogs(settings, TRUE);
/* Get the web context for scheme + security manager registration. */
WebKitWebContext *web_ctx = webkit_web_view_get_context(webview);
/* Register sovereign:// as a secure, local, CORS-enabled scheme so
* that fetch() from any page can call it without cross-origin
* restrictions. */
WebKitSecurityManager *sec_mgr = webkit_web_context_get_security_manager(web_ctx);
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "sovereign");
webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "sovereign");
webkit_security_manager_register_uri_scheme_as_cors_enabled(sec_mgr, "sovereign");
/* Also register file:// as secure so local pages can call our bridge. */
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "file");
webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "file");
/* Accept any TLS certificate (FIPS uses Noise IK, not TLS CAs). */
WebKitWebsiteDataManager *data_mgr = webkit_web_context_get_website_data_manager(web_ctx);
webkit_website_data_manager_set_tls_errors_policy(
data_mgr, WEBKIT_TLS_ERRORS_POLICY_IGNORE);
/* Register the sovereign:// URI scheme for the window.nostr bridge
* and browser-internal pages (sovereign://security). */
nostr_bridge_register(web_ctx, g_state.signer, g_state.pubkey_hex,
g_state.readonly);
nostr_bridge_set_security_refs(settings, sec_mgr);
/* Inject window.nostr into every page before page scripts run. */
nostr_inject_setup(webview);
/* Toolbar: [hamburger] [url entry], horizontal. */
GtkWidget *toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_margin_top(toolbar, 4);
gtk_widget_set_margin_bottom(toolbar, 4);
gtk_widget_set_margin_start(toolbar, 4);
gtk_widget_set_margin_end(toolbar, 4);
gtk_box_pack_start(GTK_BOX(vbox), toolbar, FALSE, FALSE, 0);
GtkWidget *hamburger = build_hamburger_menu(window, webview);
gtk_box_pack_start(GTK_BOX(toolbar), hamburger, FALSE, FALSE, 0);
GtkWidget *url_entry = gtk_entry_new();
gtk_entry_set_text(GTK_ENTRY(url_entry),
normalized ? normalized : start_url);
gtk_box_pack_start(GTK_BOX(vbox), url_entry, FALSE, FALSE, 0);
gtk_box_pack_start(GTK_BOX(toolbar), url_entry, TRUE, TRUE, 0);
/* Store webview reference on the window for menu callbacks. */
g_object_set_data(G_OBJECT(window), "webview", webview);
/* The web view itself. */
WebKitWebView *webview = WEBKIT_WEB_VIEW(webkit_web_view_new());
gtk_box_pack_start(GTK_BOX(vbox), GTK_WIDGET(webview), TRUE, TRUE, 0);
/* Wire signals. */

624
src/nostr_bridge.c Normal file
View File

@@ -0,0 +1,624 @@
/*
* nostr_bridge.c — sovereign:// URI scheme handler for window.nostr
*
* Handles sovereign://nostr/<method> requests from the injected JS shim.
* Calls the appropriate nostr_signer_t function and returns the result.
*/
#include "nostr_bridge.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include "nostr_core/nostr_core.h"
#include "nostr_core/nip019.h"
#include "../nostr_core_lib/cjson/cJSON.h"
/* ── Global bridge state ────────────────────────────────────────── *
* The URI scheme callback needs access to the current signer. We keep
* a global pointer that is updated via nostr_bridge_set_signer().
*/
typedef struct {
nostr_signer_t *signer;
char pubkey_hex[65];
int readonly;
WebKitSettings *settings;
WebKitSecurityManager *sec_mgr;
} bridge_state_t;
static bridge_state_t g_bridge = {0};
void nostr_bridge_set_security_refs(WebKitSettings *settings,
WebKitSecurityManager *sec_mgr) {
g_bridge.settings = settings;
g_bridge.sec_mgr = sec_mgr;
}
void nostr_bridge_set_signer(nostr_signer_t *signer,
const char *pubkey_hex,
int readonly) {
g_bridge.signer = signer;
g_bridge.readonly = readonly;
if (pubkey_hex) {
memcpy(g_bridge.pubkey_hex, pubkey_hex, 64);
g_bridge.pubkey_hex[64] = '\0';
} else {
g_bridge.pubkey_hex[0] = '\0';
}
}
/* ── Response helpers ───────────────────────────────────────────── */
/* Finish a scheme request with a JSON body and 200 status. */
static void respond_json(WebKitURISchemeRequest *request, const char *json) {
GBytes *bytes = g_bytes_new_take(g_strdup(json), strlen(json));
GInputStream *stream = g_memory_input_stream_new_from_bytes(bytes);
g_bytes_unref(bytes);
webkit_uri_scheme_request_finish(request, stream, strlen(json),
"application/json");
g_object_unref(stream);
}
/* Finish a scheme request with an HTML body. */
static void respond_html(WebKitURISchemeRequest *request, const char *html) {
size_t len = strlen(html);
GBytes *bytes = g_bytes_new_take(g_strdup(html), len);
GInputStream *stream = g_memory_input_stream_new_from_bytes(bytes);
g_bytes_unref(bytes);
webkit_uri_scheme_request_finish(request, stream, len, "text/html");
g_object_unref(stream);
}
/* Finish a scheme request with an error (JSON body, error status). */
static void respond_error_json(WebKitURISchemeRequest *request,
int code, const char *message) {
cJSON *err = cJSON_CreateObject();
cJSON_AddNumberToObject(err, "error", code);
cJSON_AddStringToObject(err, "message", message);
char *json = cJSON_PrintUnformatted(err);
cJSON_Delete(err);
GBytes *bytes = g_bytes_new_take(json, strlen(json));
GInputStream *stream = g_memory_input_stream_new_from_bytes(bytes);
g_bytes_unref(bytes);
/* WebKit doesn't let us set a custom status code for scheme responses,
* but the JS shim checks the body for an "error" field. */
webkit_uri_scheme_request_finish(request, stream, strlen(json),
"application/json");
g_object_unref(stream);
}
/* ── Method handlers ────────────────────────────────────────────── */
static void handle_get_public_key(WebKitURISchemeRequest *request) {
if (g_bridge.pubkey_hex[0] == '\0') {
respond_error_json(request, -1, "No identity loaded");
return;
}
cJSON *result = cJSON_CreateObject();
cJSON_AddStringToObject(result, "pubkey", g_bridge.pubkey_hex);
char *json = cJSON_PrintUnformatted(result);
cJSON_Delete(result);
respond_json(request, json);
g_free(json); /* cJSON_PrintUnformatted uses malloc, but g_free is fine for our response */
}
static void handle_sign_event(WebKitURISchemeRequest *request,
const char *body) {
if (g_bridge.readonly || g_bridge.signer == NULL) {
respond_error_json(request, -1, "Read-only mode: no signer available");
return;
}
/* Parse the unsigned event from the request body. */
cJSON *unsigned_event = cJSON_Parse(body);
if (unsigned_event == NULL) {
respond_error_json(request, -1, "Invalid event JSON");
return;
}
/* The signer requires the pubkey field. If the caller didn't
* include it (common for NIP-07 signEvent), inject our pubkey. */
cJSON *pubkey_item = cJSON_GetObjectItemCaseSensitive(unsigned_event, "pubkey");
if (pubkey_item == NULL || !cJSON_IsString(pubkey_item)) {
cJSON_AddStringToObject(unsigned_event, "pubkey", g_bridge.pubkey_hex);
}
/* Sign it. */
cJSON *signed_event = NULL;
int rc = nostr_signer_sign_event(g_bridge.signer, unsigned_event, &signed_event);
cJSON_Delete(unsigned_event);
if (rc != NOSTR_SUCCESS || signed_event == NULL) {
char errbuf[128];
snprintf(errbuf, sizeof(errbuf), "sign_event failed: error code %d", rc);
respond_error_json(request, rc, errbuf);
if (signed_event) cJSON_Delete(signed_event);
return;
}
/* Return the signed event. */
char *json = cJSON_PrintUnformatted(signed_event);
cJSON_Delete(signed_event);
respond_json(request, json);
free(json);
}
static void handle_nip04_encrypt(WebKitURISchemeRequest *request,
const char *body) {
if (g_bridge.readonly || g_bridge.signer == NULL) {
respond_error_json(request, -1, "Read-only mode: no signer available");
return;
}
/* body: { "peer_pubkey": "...", "plaintext": "..." } */
cJSON *params = cJSON_Parse(body);
if (params == NULL) {
respond_error_json(request, -1, "Invalid params JSON");
return;
}
const char *peer = cJSON_GetObjectItemCaseSensitive(params, "peer_pubkey")->valuestring;
const char *plaintext = cJSON_GetObjectItemCaseSensitive(params, "plaintext")->valuestring;
if (peer == NULL || plaintext == NULL) {
respond_error_json(request, -1, "Missing peer_pubkey or plaintext");
cJSON_Delete(params);
return;
}
char *ciphertext = NULL;
int rc = nostr_signer_nip04_encrypt(g_bridge.signer, peer, plaintext, &ciphertext);
cJSON_Delete(params);
if (rc != NOSTR_SUCCESS || ciphertext == NULL) {
char errbuf[128];
snprintf(errbuf, sizeof(errbuf), "nip04_encrypt failed: error code %d", rc);
respond_error_json(request, rc, errbuf);
free(ciphertext);
return;
}
cJSON *result = cJSON_CreateObject();
cJSON_AddStringToObject(result, "result", ciphertext);
char *json = cJSON_PrintUnformatted(result);
cJSON_Delete(result);
free(ciphertext);
respond_json(request, json);
free(json);
}
static void handle_nip04_decrypt(WebKitURISchemeRequest *request,
const char *body) {
if (g_bridge.readonly || g_bridge.signer == NULL) {
respond_error_json(request, -1, "Read-only mode: no signer available");
return;
}
cJSON *params = cJSON_Parse(body);
if (params == NULL) {
respond_error_json(request, -1, "Invalid params JSON");
return;
}
const char *peer = cJSON_GetObjectItemCaseSensitive(params, "peer_pubkey")->valuestring;
const char *ciphertext = cJSON_GetObjectItemCaseSensitive(params, "ciphertext")->valuestring;
if (peer == NULL || ciphertext == NULL) {
respond_error_json(request, -1, "Missing peer_pubkey or ciphertext");
cJSON_Delete(params);
return;
}
char *plaintext = NULL;
int rc = nostr_signer_nip04_decrypt(g_bridge.signer, peer, ciphertext, &plaintext);
cJSON_Delete(params);
if (rc != NOSTR_SUCCESS || plaintext == NULL) {
char errbuf[128];
snprintf(errbuf, sizeof(errbuf), "nip04_decrypt failed: error code %d", rc);
respond_error_json(request, rc, errbuf);
free(plaintext);
return;
}
cJSON *result = cJSON_CreateObject();
cJSON_AddStringToObject(result, "result", plaintext);
char *json = cJSON_PrintUnformatted(result);
cJSON_Delete(result);
free(plaintext);
respond_json(request, json);
free(json);
}
static void handle_nip44_encrypt(WebKitURISchemeRequest *request,
const char *body) {
if (g_bridge.readonly || g_bridge.signer == NULL) {
respond_error_json(request, -1, "Read-only mode: no signer available");
return;
}
cJSON *params = cJSON_Parse(body);
if (params == NULL) {
respond_error_json(request, -1, "Invalid params JSON");
return;
}
const char *peer = cJSON_GetObjectItemCaseSensitive(params, "peer_pubkey")->valuestring;
const char *plaintext = cJSON_GetObjectItemCaseSensitive(params, "plaintext")->valuestring;
if (peer == NULL || plaintext == NULL) {
respond_error_json(request, -1, "Missing peer_pubkey or plaintext");
cJSON_Delete(params);
return;
}
char *ciphertext = NULL;
int rc = nostr_signer_nip44_encrypt(g_bridge.signer, peer, plaintext, &ciphertext);
cJSON_Delete(params);
if (rc != NOSTR_SUCCESS || ciphertext == NULL) {
char errbuf[128];
snprintf(errbuf, sizeof(errbuf), "nip44_encrypt failed: error code %d", rc);
respond_error_json(request, rc, errbuf);
free(ciphertext);
return;
}
cJSON *result = cJSON_CreateObject();
cJSON_AddStringToObject(result, "result", ciphertext);
char *json = cJSON_PrintUnformatted(result);
cJSON_Delete(result);
free(ciphertext);
respond_json(request, json);
free(json);
}
static void handle_nip44_decrypt(WebKitURISchemeRequest *request,
const char *body) {
if (g_bridge.readonly || g_bridge.signer == NULL) {
respond_error_json(request, -1, "Read-only mode: no signer available");
return;
}
cJSON *params = cJSON_Parse(body);
if (params == NULL) {
respond_error_json(request, -1, "Invalid params JSON");
return;
}
const char *peer = cJSON_GetObjectItemCaseSensitive(params, "peer_pubkey")->valuestring;
const char *ciphertext = cJSON_GetObjectItemCaseSensitive(params, "ciphertext")->valuestring;
if (peer == NULL || ciphertext == NULL) {
respond_error_json(request, -1, "Missing peer_pubkey or ciphertext");
cJSON_Delete(params);
return;
}
char *plaintext = NULL;
int rc = nostr_signer_nip44_decrypt(g_bridge.signer, peer, ciphertext, &plaintext);
cJSON_Delete(params);
if (rc != NOSTR_SUCCESS || plaintext == NULL) {
char errbuf[128];
snprintf(errbuf, sizeof(errbuf), "nip44_decrypt failed: error code %d", rc);
respond_error_json(request, rc, errbuf);
free(plaintext);
return;
}
cJSON *result = cJSON_CreateObject();
cJSON_AddStringToObject(result, "result", plaintext);
char *json = cJSON_PrintUnformatted(result);
cJSON_Delete(result);
free(plaintext);
respond_json(request, json);
free(json);
}
static void handle_get_relays(WebKitURISchemeRequest *request) {
/* Return a default relay list. In the future this could be user-configurable. */
cJSON *result = cJSON_CreateObject();
cJSON *relays = cJSON_CreateArray();
cJSON_AddItemToArray(relays, cJSON_CreateString("wss://relay.damus.io"));
cJSON_AddItemToArray(relays, cJSON_CreateString("wss://nos.lol"));
cJSON_AddItemToArray(relays, cJSON_CreateString("wss://relay.laantungir.net"));
cJSON_AddItemToObject(result, "relays", relays);
char *json = cJSON_PrintUnformatted(result);
cJSON_Delete(result);
respond_json(request, json);
free(json);
}
/* ── Security settings page ────────────────────────────────────── */
/* Generate the sovereign://security HTML page with toggle switches. */
static void handle_security_page(WebKitURISchemeRequest *request) {
/* Read current settings state. */
int dev_extras = g_bridge.settings ?
webkit_settings_get_enable_developer_extras(g_bridge.settings) : 0;
int file_access = g_bridge.settings ?
webkit_settings_get_allow_file_access_from_file_urls(g_bridge.settings) : 0;
int universal_access = g_bridge.settings ?
webkit_settings_get_allow_universal_access_from_file_urls(g_bridge.settings) : 0;
/* Build the HTML page. */
char *html = g_strdup_printf(
"<!DOCTYPE html>\n"
"<html><head><meta charset='UTF-8'>\n"
"<title>sovereign browser — Security Settings</title>\n"
"<style>\n"
" body { font-family: monospace; max-width: 700px; margin: 40px auto;\n"
" padding: 20px; background: #1a1a1a; color: #e0e0e0; }\n"
" h1 { color: #ff4444; }\n"
" .setting { display: flex; justify-content: space-between;\n"
" align-items: center; padding: 12px 0;\n"
" border-bottom: 1px solid #333; }\n"
" .setting-name { font-weight: bold; }\n"
" .setting-desc { color: #888; font-size: 12px; margin-top: 4px; }\n"
" .toggle { position: relative; width: 50px; height: 24px;\n"
" background: #333; border-radius: 12px; cursor: pointer;\n"
" transition: background 0.2s; }\n"
" .toggle.on { background: #2a5a2a; }\n"
" .toggle.off { background: #5a2a2a; }\n"
" .toggle::after { content: ''; position: absolute; top: 2px; left: 2px;\n"
" width: 20px; height: 20px; border-radius: 50%%; background: #e0e0e0;\n"
" transition: transform 0.2s; }\n"
" .toggle.on::after { transform: translateX(26px); }\n"
" .status { padding: 8px; margin: 10px 0; border-radius: 4px; display: none; }\n"
" .status.show { display: block; }\n"
" .status.ok { background: #1a3a1a; color: #44ff44; }\n"
" .status.err { background: #3a1a1a; color: #ff4444; }\n"
" .note { color: #888; font-size: 12px; margin: 20px 0; }\n"
"</style>\n"
"</head><body>\n"
"<h1>Security Settings</h1>\n"
"<p class='note'>The 'reckless browser' thesis: identity and transport are handled\n"
"at a different layer (Nostr keys + FIPS mesh), so the browser's own security\n"
"sandbox is deliberately stripped. Toggle features on if you need them.</p>\n"
"\n"
"<div class='setting'>\n"
" <div><div class='setting-name'>Developer Tools (Inspector)</div>\n"
" <div class='setting-desc'>Enable WebKit Web Inspector for JS debugging</div></div>\n"
" <div class='toggle %s' onclick='toggle(\"dev_extras\")'></div>\n"
"</div>\n"
"\n"
"<div class='setting'>\n"
" <div><div class='setting-name'>File Access from file://</div>\n"
" <div class='setting-desc'>Allow file:// pages to access other file:// resources</div></div>\n"
" <div class='toggle %s' onclick='toggle(\"file_access\")'></div>\n"
"</div>\n"
"\n"
"<div class='setting'>\n"
" <div><div class='setting-name'>Universal Access from file://</div>\n"
" <div class='setting-desc'>Allow file:// pages to make cross-origin requests (needed for sovereign:// bridge)</div></div>\n"
" <div class='toggle %s' onclick='toggle(\"universal_access\")'></div>\n"
"</div>\n"
"\n"
"<div class='setting'>\n"
" <div><div class='setting-name'>CORS Enforcement</div>\n"
" <div class='setting-desc'>Block cross-origin requests without CORS headers (currently always off)</div></div>\n"
" <div class='toggle off' style='opacity:0.5'></div>\n"
"</div>\n"
"\n"
"<div class='setting'>\n"
" <div><div class='setting-name'>TLS Certificate Check</div>\n"
" <div class='setting-desc'>Reject invalid/self-signed certificates (currently always off — FIPS uses Noise IK)</div></div>\n"
" <div class='toggle off' style='opacity:0.5'></div>\n"
"</div>\n"
"\n"
"<div id='status' class='status'></div>\n"
"\n"
"<script>\n"
" function toggle(feature) {\n"
" fetch('sovereign://security/set?feature=' + feature)\n"
" .then(r => r.json())\n"
" .then(d => {\n"
" var s = document.getElementById('status');\n"
" if (d.error) {\n"
" s.className = 'status err show';\n"
" s.textContent = d.message;\n"
" } else {\n"
" s.className = 'status ok show';\n"
" s.textContent = d.feature + ': ' + (d.enabled ? 'ON' : 'OFF');\n"
" setTimeout(() => location.reload(), 500);\n"
" }\n"
" })\n"
" .catch(e => {\n"
" var s = document.getElementById('status');\n"
" s.className = 'status err show';\n"
" s.textContent = 'Error: ' + e.message;\n"
" });\n"
" }\n"
"</script>\n"
"</body></html>\n",
dev_extras ? "on" : "off",
file_access ? "on" : "off",
universal_access ? "on" : "off"
);
respond_html(request, html);
g_free(html);
}
/* Handle sovereign://security/set?feature=X — toggle a setting. */
static void handle_security_set(WebKitURISchemeRequest *request,
const char *query) {
if (g_bridge.settings == NULL) {
respond_error_json(request, -1, "Settings not available");
return;
}
/* Parse feature= from query string. */
const char *feat = strstr(query, "feature=");
if (feat == NULL) {
respond_error_json(request, -1, "Missing feature parameter");
return;
}
feat += 8; /* skip "feature=" */
/* Extract feature name (up to & or end). */
char feature[64];
size_t i = 0;
while (feat[i] && feat[i] != '&' && i < sizeof(feature) - 1) {
feature[i] = feat[i];
i++;
}
feature[i] = '\0';
/* Toggle the setting. */
int new_state = 0;
if (strcmp(feature, "dev_extras") == 0) {
new_state = !webkit_settings_get_enable_developer_extras(g_bridge.settings);
webkit_settings_set_enable_developer_extras(g_bridge.settings, new_state);
} else if (strcmp(feature, "file_access") == 0) {
new_state = !webkit_settings_get_allow_file_access_from_file_urls(g_bridge.settings);
webkit_settings_set_allow_file_access_from_file_urls(g_bridge.settings, new_state);
} else if (strcmp(feature, "universal_access") == 0) {
new_state = !webkit_settings_get_allow_universal_access_from_file_urls(g_bridge.settings);
webkit_settings_set_allow_universal_access_from_file_urls(g_bridge.settings, new_state);
} else {
respond_error_json(request, -1, "Unknown feature");
return;
}
g_print("[security] %s: %s\n", feature, new_state ? "ON" : "OFF");
/* Return the new state. */
cJSON *result = cJSON_CreateObject();
cJSON_AddStringToObject(result, "feature", feature);
cJSON_AddBoolToObject(result, "enabled", new_state);
char *json = cJSON_PrintUnformatted(result);
cJSON_Delete(result);
respond_json(request, json);
free(json);
}
/* ── URI scheme callback ────────────────────────────────────────── */
static void on_sovereign_scheme(WebKitURISchemeRequest *request,
gpointer user_data) {
(void)user_data;
const gchar *uri = webkit_uri_scheme_request_get_uri(request);
g_print("[bridge] %s\n", uri);
if (uri == NULL) {
respond_error_json(request, -1, "Invalid URI");
return;
}
/* Route sovereign://security requests. */
if (strcmp(uri, "sovereign://security") == 0 ||
strncmp(uri, "sovereign://security?", 20) == 0) {
handle_security_page(request);
return;
}
if (strncmp(uri, "sovereign://security/set", 24) == 0) {
const char *query = strchr(uri + 24, '?');
handle_security_set(request, query ? query + 1 : "");
return;
}
/* Route sovereign://nostr/<method> requests. */
if (strncmp(uri, "sovereign://nostr/", 18) != 0) {
respond_error_json(request, -1, "Unknown sovereign:// path");
return;
}
const char *method = uri + 18;
/* For methods that need a body, read it from the request.
* WebKitURISchemeRequest doesn't directly expose the body in the
* GTK 3 / WebKitGTK 4.1 API. We use the stream from
* webkit_uri_scheme_request_get_stream() if available, but for
* simplicity in this first implementation, we handle GET-style
* methods (getPublicKey, getRelays) which don't need a body.
*
* For POST methods (signEvent, nip04/nip44), the JS shim encodes
* the payload in the URI path as a query parameter or we read
* the stream. For now, we support a simple convention:
* sovereign://nostr/signEvent?body=<base64-encoded-json>
*
* Actually, WebKitGTK 4.1 does provide the request body via
* webkit_uri_scheme_request_get_stream(). Let's use that. */
/* Extract method name (strip query string if present). */
char method_name[64];
const char *query = strchr(method, '?');
size_t method_len = query ? (size_t)(query - method) : strlen(method);
if (method_len >= sizeof(method_name)) {
respond_error_json(request, -1, "Method name too long");
return;
}
memcpy(method_name, method, method_len);
method_name[method_len] = '\0';
/* GET-style methods (no body needed). */
if (strcmp(method_name, "getPublicKey") == 0) {
handle_get_public_key(request);
return;
}
if (strcmp(method_name, "getRelays") == 0) {
handle_get_relays(request);
return;
}
/* POST-style methods: read the body from the query string.
* The JS shim appends ?body=<urlencoded-json> for these.
* This is a workaround for WebKitGTK 4.1 not exposing the POST
* body directly in the scheme callback. A future WebExtension
* would handle this more cleanly. */
const char *body = NULL;
char *decoded_body = NULL;
if (query && strncmp(query, "?body=", 6) == 0) {
const char *encoded = query + 6;
/* URL-decode the body. */
decoded_body = g_uri_unescape_string(encoded, NULL);
body = decoded_body;
}
if (body == NULL) {
respond_error_json(request, -1, "Missing request body");
g_free(decoded_body);
return;
}
if (strcmp(method_name, "signEvent") == 0) {
handle_sign_event(request, body);
} else if (strcmp(method_name, "nip04_encrypt") == 0) {
handle_nip04_encrypt(request, body);
} else if (strcmp(method_name, "nip04_decrypt") == 0) {
handle_nip04_decrypt(request, body);
} else if (strcmp(method_name, "nip44_encrypt") == 0) {
handle_nip44_encrypt(request, body);
} else if (strcmp(method_name, "nip44_decrypt") == 0) {
handle_nip44_decrypt(request, body);
} else {
respond_error_json(request, -1, "Unknown method");
}
g_free(decoded_body);
}
/* ── Registration ───────────────────────────────────────────────── */
void nostr_bridge_register(WebKitWebContext *ctx,
nostr_signer_t *signer,
const char *pubkey_hex,
int readonly) {
/* Store the signer reference. */
nostr_bridge_set_signer(signer, pubkey_hex, readonly);
/* Register the sovereign:// scheme. */
webkit_web_context_register_uri_scheme(
ctx, "sovereign", on_sovereign_scheme, NULL, NULL);
}

61
src/nostr_bridge.h Normal file
View File

@@ -0,0 +1,61 @@
/*
* nostr_bridge.h — sovereign:// URI scheme handler for window.nostr
*
* Registers the "sovereign" URI scheme with WebKitGTK so that web pages
* can call our C-side nostr_signer_t via fetch():
*
* fetch('sovereign://nostr/getPublicKey')
* fetch('sovereign://nostr/signEvent', { method: 'POST', body: JSON.stringify(event) })
*
* Also serves browser-internal pages:
* sovereign://security — security settings page (toggle switches)
* sovereign://security/set — update a security setting (called by the page)
*
* The C handler receives the request, calls the appropriate nostr_signer_t
* function, and returns the result as the response body.
*/
#ifndef NOSTR_BRIDGE_H
#define NOSTR_BRIDGE_H
#include <webkit2/webkit2.h>
#include "nostr_core/nostr_signer.h"
#ifdef __cplusplus
extern "C" {
#endif
/*
* Register the sovereign:// URI scheme on the given web context.
* The signer and pubkey must remain valid for the lifetime of the context.
*
* signer: the nostr_signer_t from login (may be NULL for read-only)
* pubkey: hex pubkey string (64 chars, NUL-terminated)
* readonly: TRUE if no signing is available (signer is NULL)
*/
void nostr_bridge_register(WebKitWebContext *ctx,
nostr_signer_t *signer,
const char *pubkey_hex,
int readonly);
/*
* Update the signer reference (e.g. after switching identity).
* The URI scheme handler will use the new signer for subsequent requests.
*/
void nostr_bridge_set_signer(nostr_signer_t *signer,
const char *pubkey_hex,
int readonly);
/*
* Provide the WebKitSettings and WebKitSecurityManager references so the
* sovereign://security page can read and toggle security features.
* Must be called after nostr_bridge_register().
*/
void nostr_bridge_set_security_refs(WebKitSettings *settings,
WebKitSecurityManager *sec_mgr);
#ifdef __cplusplus
}
#endif
#endif /* NOSTR_BRIDGE_H */

136
src/nostr_inject.c Normal file
View File

@@ -0,0 +1,136 @@
/*
* nostr_inject.c — window.nostr JS injection for sovereign_browser
*
* Injects a JS shim into every page that provides the NIP-07 window.nostr
* API. The shim uses fetch() to call the sovereign:// URI scheme bridge,
* which routes to our C-side nostr_signer_t.
*/
#include "nostr_inject.h"
#include <string.h>
/* The JS shim that provides window.nostr.
*
* Each method does a fetch() to sovereign://nostr/<method> and returns
* a Promise. For methods that need a body (signEvent, nip04/nip44),
* the body is URL-encoded and appended as ?body=<encoded>.
*
* The response is JSON: either { "pubkey": "..." } / { "result": "..." }
* on success, or { "error": code, "message": "..." } on failure.
*/
static const char *NOSTR_SHIM_JS =
"(function() {\n"
" 'use strict';\n"
"\n"
" var BRIDGE_BASE = 'sovereign://nostr/';\n"
"\n"
" function bridgeCall(method, bodyObj) {\n"
" var url = BRIDGE_BASE + method;\n"
" var opts = {};\n"
"\n"
" if (bodyObj !== undefined && bodyObj !== null) {\n"
" /* Encode the body as a query parameter since WebKitGTK's custom\n"
" * scheme handler doesn't easily expose POST bodies. */\n"
" var bodyJson = JSON.stringify(bodyObj);\n"
" url += '?body=' + encodeURIComponent(bodyJson);\n"
" }\n"
"\n"
" return fetch(url).then(function(resp) {\n"
" return resp.text();\n"
" }).then(function(text) {\n"
" var data;\n"
" try {\n"
" data = JSON.parse(text);\n"
" } catch(e) {\n"
" throw new Error('Bridge returned invalid JSON: ' + text);\n"
" }\n"
" if (data.error !== undefined) {\n"
" var msg = data.message || ('Error code ' + data.error);\n"
" throw new Error(msg);\n"
" }\n"
" return data;\n"
" });\n"
" }\n"
"\n"
" /* NIP-07 window.nostr API */\n"
" var nostr = {\n"
" getPublicKey: function() {\n"
" return bridgeCall('getPublicKey').then(function(d) { return d.pubkey; });\n"
" },\n"
"\n"
" signEvent: function(event) {\n"
" return bridgeCall('signEvent', event).then(function(d) {\n"
" /* The bridge returns the full signed event JSON. */\n"
" return d;\n"
" });\n"
" },\n"
"\n"
" getRelays: function() {\n"
" return bridgeCall('getRelays').then(function(d) { return d.relays; });\n"
" },\n"
"\n"
" nip04: {\n"
" encrypt: function(peerPubkey, plaintext) {\n"
" return bridgeCall('nip04_encrypt', {\n"
" peer_pubkey: peerPubkey,\n"
" plaintext: plaintext\n"
" }).then(function(d) { return d.result; });\n"
" },\n"
" decrypt: function(peerPubkey, ciphertext) {\n"
" return bridgeCall('nip04_decrypt', {\n"
" peer_pubkey: peerPubkey,\n"
" ciphertext: ciphertext\n"
" }).then(function(d) { return d.result; });\n"
" }\n"
" },\n"
"\n"
" nip44: {\n"
" encrypt: function(peerPubkey, plaintext) {\n"
" return bridgeCall('nip44_encrypt', {\n"
" peer_pubkey: peerPubkey,\n"
" plaintext: plaintext\n"
" }).then(function(d) { return d.result; });\n"
" },\n"
" decrypt: function(peerPubkey, ciphertext) {\n"
" return bridgeCall('nip44_decrypt', {\n"
" peer_pubkey: peerPubkey,\n"
" ciphertext: ciphertext\n"
" }).then(function(d) { return d.result; });\n"
" }\n"
" }\n"
" };\n"
"\n"
" /* Only install if no real extension is present. */\n"
" if (window.nostr === undefined || window.nostr === null) {\n"
" Object.defineProperty(window, 'nostr', {\n"
" value: nostr,\n"
" writable: false,\n"
" configurable: false,\n"
" enumerable: true\n"
" });\n"
" }\n"
"})();\n";
void nostr_inject_setup(WebKitWebView *webview) {
WebKitUserContentManager *manager =
webkit_web_view_get_user_content_manager(webview);
if (manager == NULL) {
g_printerr("[inject] No user content manager — window.nostr not injected\n");
return;
}
WebKitUserScript *script = webkit_user_script_new(
NOSTR_SHIM_JS,
WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
NULL, /* allow list (NULL = all) */
NULL /* block list */
);
webkit_user_content_manager_add_script(manager, script);
/* The manager takes ownership of the script. */
webkit_user_script_unref(script);
g_print("[inject] window.nostr shim installed\n");
}

30
src/nostr_inject.h Normal file
View File

@@ -0,0 +1,30 @@
/*
* nostr_inject.h — window.nostr JS injection for sovereign_browser
*
* Injects a JavaScript shim into every web page (before page scripts run)
* that provides the NIP-07 window.nostr API. The shim marshals calls
* to the C-side signer via the sovereign:// URI scheme bridge.
*/
#ifndef NOSTR_INJECT_H
#define NOSTR_INJECT_H
#include <webkit2/webkit2.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Set up window.nostr injection on the given web view.
* Call this once after creating the web view and before loading any page.
* The injection uses WebKitUserContentManager to add a script that runs
* in all frames before page scripts.
*/
void nostr_inject_setup(WebKitWebView *webview);
#ifdef __cplusplus
}
#endif
#endif /* NOSTR_INJECT_H */

19
src/version.h Normal file
View File

@@ -0,0 +1,19 @@
/*
* version.h — single source of truth for the build version.
*
* These defines are updated automatically by increment_and_push.sh
* (mirroring nostr_core_lib's nostr_core.h convention). Do not edit
* by hand unless setting an explicit release version.
*
* Note: we use SB_VERSION (sovereign browser version) to avoid clashing
* with VERSION defined in nostr_core_lib's nostr_core.h.
*/
#ifndef SOVEREIGN_BROWSER_VERSION_H
#define SOVEREIGN_BROWSER_VERSION_H
#define SB_VERSION "v0.0.2"
#define SB_VERSION_MAJOR 0
#define SB_VERSION_MINOR 0
#define SB_VERSION_PATCH 2
#endif /* SOVEREIGN_BROWSER_VERSION_H */

170
tests/test_nostr.html Normal file
View File

@@ -0,0 +1,170 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>sovereign browser — window.nostr test</title>
<style>
body {
font-family: "Courier New", Courier, monospace;
max-width: 800px;
margin: 40px auto;
padding: 20px;
background: #1a1a1a;
color: #e0e0e0;
}
h1 { color: #ff4444; }
h2 { color: #ff8888; margin-top: 30px; }
button {
font-family: monospace;
font-size: 14px;
padding: 8px 16px;
margin: 4px;
background: #333;
color: #e0e0e0;
border: 1px solid #555;
border-radius: 4px;
cursor: pointer;
}
button:hover { background: #444; border-color: #777; }
button:active { background: #222; }
pre {
background: #0d0d0d;
border: 1px solid #333;
border-radius: 4px;
padding: 12px;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
font-size: 13px;
line-height: 1.4;
}
.result { border-left: 3px solid #ff4444; padding-left: 12px; margin: 10px 0; }
.success { color: #44ff44; }
.error { color: #ff4444; }
.label { color: #888; font-size: 12px; }
#status { font-weight: bold; padding: 8px; border-radius: 4px; margin: 10px 0; }
.status-ok { background: #1a3a1a; color: #44ff44; }
.status-err { background: #3a1a1a; color: #ff4444; }
.status-wait { background: #3a3a1a; color: #ffff44; }
</style>
</head>
<body>
<h1>sovereign browser — window.nostr test</h1>
<p class="label">This page tests the NIP-07 window.nostr API injected by the browser.</p>
<div id="status">Checking for window.nostr…</div>
<h2>1. Get Public Key</h2>
<p>Call <code>window.nostr.getPublicKey()</code> to retrieve your Nostr public key.</p>
<button onclick="testGetPublicKey()">Get Public Key</button>
<div id="pubkey-result" class="result"></div>
<h2>2. Sign Event</h2>
<p>Create and sign a kind-1 text note via <code>window.nostr.signEvent()</code>.</p>
<input type="text" id="note-text" value="Hello from sovereign browser!" style="width:400px; font-family:monospace; padding:4px; background:#333; color:#e0e0e0; border:1px solid #555; border-radius:4px;">
<button onclick="testSignEvent()">Sign Event</button>
<div id="sign-result" class="result"></div>
<h2>3. Get Relays</h2>
<p>Call <code>window.nostr.getRelays()</code> to get the browser's relay list.</p>
<button onclick="testGetRelays()">Get Relays</button>
<div id="relays-result" class="result"></div>
<h2>4. NIP-44 Encrypt/Decrypt (self)</h2>
<p>Encrypt a message to yourself and decrypt it back.</p>
<input type="text" id="encrypt-text" value="Secret test message" style="width:400px; font-family:monospace; padding:4px; background:#333; color:#e0e0e0; border:1px solid #555; border-radius:4px;">
<button onclick="testNip44()">Test NIP-44</button>
<div id="nip44-result" class="result"></div>
<script>
// Check if window.nostr is available
function checkNostr() {
var status = document.getElementById('status');
if (typeof window.nostr !== 'undefined' && window.nostr !== null) {
status.className = 'status-ok';
status.textContent = '✓ window.nostr is available';
} else {
status.className = 'status-err';
status.textContent = '✗ window.nostr is NOT available — are you running this in sovereign browser?';
}
}
function showResult(elementId, success, content) {
var el = document.getElementById(elementId);
var cls = success ? 'success' : 'error';
el.innerHTML = '<span class="' + cls + '">' + (success ? '✓' : '✗') + '</span> <pre>' + escapeHtml(content) + '</pre>';
}
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function testGetPublicKey() {
var el = 'pubkey-result';
try {
showResult(el, true, 'Calling window.nostr.getPublicKey()…');
var pubkey = await window.nostr.getPublicKey();
showResult(el, true, 'Public key (hex): ' + pubkey);
} catch(e) {
showResult(el, false, 'Error: ' + e.message);
}
}
async function testSignEvent() {
var el = 'sign-result';
try {
var content = document.getElementById('note-text').value;
var event = {
kind: 1,
content: content,
tags: [],
created_at: Math.floor(Date.now() / 1000)
};
showResult(el, true, 'Calling window.nostr.signEvent() with:\n' + JSON.stringify(event, null, 2));
var signed = await window.nostr.signEvent(event);
showResult(el, true, 'Signed event:\n' + JSON.stringify(signed, null, 2));
} catch(e) {
showResult(el, false, 'Error: ' + e.message);
}
}
async function testGetRelays() {
var el = 'relays-result';
try {
showResult(el, true, 'Calling window.nostr.getRelays()…');
var relays = await window.nostr.getRelays();
showResult(el, true, 'Relays: ' + JSON.stringify(relays, null, 2));
} catch(e) {
showResult(el, false, 'Error: ' + e.message);
}
}
async function testNip44() {
var el = 'nip44-result';
try {
var text = document.getElementById('encrypt-text').value;
var pubkey = await window.nostr.getPublicKey();
showResult(el, true, 'Encrypting "' + text + '" to self (' + pubkey.substring(0, 16) + '…)');
var ciphertext = await window.nostr.nip44.encrypt(pubkey, text);
showResult(el, true, 'Ciphertext: ' + ciphertext.substring(0, 80) + '…');
var decrypted = await window.nostr.nip44.decrypt(pubkey, ciphertext);
if (decrypted === text) {
showResult(el, true, 'Round-trip verified!\nOriginal: ' + text + '\nDecrypted: ' + decrypted);
} else {
showResult(el, false, 'Round-trip FAILED!\nOriginal: ' + text + '\nDecrypted: ' + decrypted);
}
} catch(e) {
showResult(el, false, 'Error: ' + e.message);
}
}
// Run check on load
checkNostr();
</script>
</body>
</html>