Skip to content

Commit 98a683d

Browse files
hyperpolymathclaude
andcommitted
feat: complete all remaining stapeln features
Ecto persistence: - DbStore module with DB-first, GenServer-fallback pattern - NativeBridge, Auth, SettingsStore all route through DbStore when Repo is up WebSocket real-time: - Phoenix channels (UserSocket, StackChannel) for validate/scan/gap - Frontend Socket.res: raw WebSocket Phoenix v2 wire protocol client - Model/Msg/Update/App wired for WS messages Post-quantum cryptography: - Hybrid Ed25519 + XMSS-inspired hash-based signatures - Lamport OTS leaves, Merkle tree auth paths - POST /stacks/:id/sign, GET /stacks/:id/verify endpoints Ephemeral pinholes: - PinholeManager GenServer with auto-expiry timers - Create/list/revoke/check API endpoints - PortConfigPanel UI: creation form + active pinhole list VeriSimDB audit trail: - JSONL append-log with remote VeriSimDB client fallback - Audit hooks in Stacks context (create, update, scan, gap) - GET /api/audit endpoint Simulation mode (35% → 90%): - Full packet flow with auto-generation from route pool - Network presets (Ideal/Normal/Congested/Lossy) - Statistics dashboard, parameter sliders, event log - Packet detail inspection, protocol color legend Frontend cleanup: - 0 errors, 0 warnings (was 52+ deprecation warnings) - Replaced all Js.Array2, Js.Exn, Js.Dict, Js.Math deprecations - Fixed unused variables across 7 files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3ea8f7b commit 98a683d

374 files changed

Lines changed: 18233 additions & 7900 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.hypatia/activity.jsonl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"timestamp":"2026-03-08T02:06:48Z","bot":"hypatia-autofix","action":"scan","details":"fixes=5"}

.hypatia/last-visit.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"last_visit": "2026-03-08T02:06:48Z",
3+
"last_bot": "hypatia-autofix",
4+
"last_action": "scan",
5+
"visits_total": 1
6+
}

backend/lib/stapeln/application.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ defmodule Stapeln.Application do
2727
Stapeln.StackStore,
2828
Stapeln.Auth.UserStore,
2929
Stapeln.SettingsStore,
30+
Stapeln.Firewall.PinholeManager,
3031
{Task.Supervisor, name: Stapeln.TaskSupervisor},
3132
# Start a worker by calling: Stapeln.Worker.start_link(arg)
3233
# {Stapeln.Worker, arg},

backend/lib/stapeln/auth.ex

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,15 @@ defmodule Stapeln.Auth do
55
@moduledoc """
66
Authentication context.
77
8-
Users are stored in-memory via a GenServer (UserStore) for simplicity.
8+
Tries Ecto-backed `Stapeln.DbStore` when PostgreSQL is available, and
9+
falls back to in-memory `UserStore` otherwise.
10+
911
Passwords are hashed with :crypto.hash/2 (SHA-256). For production,
1012
swap to bcrypt/argon2.
1113
"""
1214

1315
alias Stapeln.Auth.{Token, UserStore}
16+
alias Stapeln.DbStore
1417

1518
@doc "Register a new user. Returns {:ok, token} or {:error, reason}."
1619
@spec register(String.t(), String.t()) :: {:ok, String.t()} | {:error, atom()}
@@ -22,7 +25,15 @@ defmodule Stapeln.Auth do
2225
String.length(password) < 6 -> {:error, :password_too_short}
2326
true ->
2427
hashed = hash_password(password)
25-
case UserStore.create(email, hashed) do
28+
29+
result =
30+
if DbStore.available?() do
31+
DbStore.create_user(email, hashed)
32+
else
33+
UserStore.create(email, hashed)
34+
end
35+
36+
case result do
2637
{:ok, user_id} -> {:ok, Token.generate(user_id)}
2738
{:error, _} = err -> err
2839
end
@@ -34,7 +45,14 @@ defmodule Stapeln.Auth do
3445
def login(email, password) when is_binary(email) and is_binary(password) do
3546
email = String.downcase(String.trim(email))
3647

37-
case UserStore.get_by_email(email) do
48+
lookup =
49+
if DbStore.available?() do
50+
DbStore.get_user_by_email(email)
51+
else
52+
UserStore.get_by_email(email)
53+
end
54+
55+
case lookup do
3856
{:ok, %{id: user_id, password_hash: stored_hash}} ->
3957
if Plug.Crypto.secure_compare(hash_password(password), stored_hash) do
4058
{:ok, Token.generate(user_id)}
@@ -52,7 +70,11 @@ defmodule Stapeln.Auth do
5270
@doc "Get user info by ID."
5371
@spec get_user(String.t()) :: {:ok, map()} | {:error, :not_found}
5472
def get_user(user_id) do
55-
UserStore.get(user_id)
73+
if DbStore.available?() do
74+
DbStore.get_user(user_id)
75+
else
76+
UserStore.get(user_id)
77+
end
5678
end
5779

5880
defp hash_password(password) do

backend/lib/stapeln/crypto.ex

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
defmodule Stapeln.Crypto do
3+
@moduledoc """
4+
Post-quantum cryptography for stapeln.
5+
6+
Provides hybrid signing using Ed25519 (classical) + a SPHINCS+-inspired
7+
hash-based signature scheme. Since Erlang/Elixir doesn't have native
8+
Dilithium support, we implement a Merkle-tree / Lamport-OTS scheme using
9+
the available `:crypto` primitives (SHA-256).
10+
11+
## Hybrid approach
12+
13+
The hybrid approach means a signature contains BOTH a classical Ed25519
14+
signature and a hash-based post-quantum signature. Verification succeeds
15+
if EITHER scheme verifies, providing both classical security today and
16+
post-quantum resistance for the future.
17+
18+
## Key format
19+
20+
A keypair bundles:
21+
- An Ed25519 keypair (via `:crypto.generate_key(:eddsa, :ed25519)`)
22+
- A hash-based Merkle-tree keypair (via `Stapeln.Crypto.HashSignature`)
23+
24+
The combined public key is the concatenation of the Ed25519 public key
25+
(32 bytes) and the Merkle root (32 bytes), totalling 64 bytes.
26+
27+
## Stack signing
28+
29+
Stacks can optionally be signed. The signature covers a canonical hash
30+
of the stack's content (name, description, services). Unsigned stacks
31+
continue to work normally.
32+
"""
33+
34+
alias Stapeln.Crypto.HashSignature
35+
36+
@ed25519_pub_bytes 32
37+
@merkle_root_bytes 32
38+
39+
@type public_key :: binary()
40+
@type secret_key :: map()
41+
@type hybrid_signature :: binary()
42+
43+
# ---------------------------------------------------------------------------
44+
# Key generation
45+
# ---------------------------------------------------------------------------
46+
47+
@doc """
48+
Generate a hybrid keypair (Ed25519 + hash-based).
49+
50+
Returns `{public_key, secret_key}` where:
51+
- `public_key` is a 64-byte binary (32 bytes Ed25519 pub + 32 bytes Merkle root)
52+
- `secret_key` is an opaque map containing both secret components
53+
"""
54+
@spec generate_keypair() :: {public_key(), secret_key()}
55+
def generate_keypair do
56+
# Classical: Ed25519
57+
{ed_pub, ed_sec} = :crypto.generate_key(:eddsa, :ed25519)
58+
59+
# Post-quantum: hash-based Merkle tree
60+
{merkle_root, hash_secret} = HashSignature.generate_keypair()
61+
62+
public_key = ed_pub <> merkle_root
63+
64+
secret_key = %{
65+
ed25519_public: ed_pub,
66+
ed25519_secret: ed_sec,
67+
merkle_root: merkle_root,
68+
hash_state: hash_secret
69+
}
70+
71+
{public_key, secret_key}
72+
end
73+
74+
# ---------------------------------------------------------------------------
75+
# Hybrid signing
76+
# ---------------------------------------------------------------------------
77+
78+
@doc """
79+
Sign a message using the hybrid scheme (Ed25519 + hash-based).
80+
81+
Returns `{:ok, signature, updated_secret}` or `{:error, reason}`.
82+
The secret key is updated because each hash-based OTS slot is single-use.
83+
"""
84+
@spec sign(binary(), secret_key()) :: {:ok, hybrid_signature(), secret_key()} | {:error, term()}
85+
def sign(message, secret_key) when is_binary(message) do
86+
# Classical signature (Ed25519)
87+
ed_sig = :crypto.sign(:eddsa, :none, message, [secret_key.ed25519_secret, :ed25519])
88+
89+
# Hash-based signature
90+
case HashSignature.sign(message, secret_key.hash_state) do
91+
{:ok, hash_sig, updated_hash_state} ->
92+
# Encode the hybrid signature as a tagged binary.
93+
encoded = encode_hybrid_signature(ed_sig, hash_sig)
94+
updated_secret = %{secret_key | hash_state: updated_hash_state}
95+
{:ok, encoded, updated_secret}
96+
97+
{:error, :keys_exhausted} ->
98+
# Fall back to Ed25519-only if hash-based keys are exhausted.
99+
encoded = encode_hybrid_signature(ed_sig, nil)
100+
{:ok, encoded, secret_key}
101+
end
102+
end
103+
104+
@doc """
105+
Sign a message, returning just the signature binary.
106+
107+
Convenience wrapper that discards the updated secret state. Use `sign/2`
108+
if you need to sign multiple messages with the same keypair.
109+
"""
110+
@spec sign!(binary(), secret_key()) :: hybrid_signature()
111+
def sign!(message, secret_key) do
112+
{:ok, sig, _updated} = sign(message, secret_key)
113+
sig
114+
end
115+
116+
# ---------------------------------------------------------------------------
117+
# Hybrid verification
118+
# ---------------------------------------------------------------------------
119+
120+
@doc """
121+
Verify a hybrid signature against a message and public key.
122+
123+
Returns `true` if EITHER the Ed25519 signature or the hash-based signature
124+
(or both) verify successfully. This provides forward security: even if one
125+
scheme is broken, the other still protects integrity.
126+
"""
127+
@spec verify(binary(), hybrid_signature(), public_key()) :: boolean()
128+
def verify(message, signature, public_key) when is_binary(message) and is_binary(public_key) do
129+
<<ed_pub::binary-size(@ed25519_pub_bytes),
130+
merkle_root::binary-size(@merkle_root_bytes)>> = public_key
131+
132+
case decode_hybrid_signature(signature) do
133+
{:ok, ed_sig, hash_sig} ->
134+
ed_valid = verify_ed25519(message, ed_sig, ed_pub)
135+
hash_valid = verify_hash_sig(message, hash_sig, merkle_root)
136+
ed_valid or hash_valid
137+
138+
:error ->
139+
false
140+
end
141+
end
142+
143+
# ---------------------------------------------------------------------------
144+
# Hash-based signing (direct access)
145+
# ---------------------------------------------------------------------------
146+
147+
@doc """
148+
Sign a message using only the hash-based scheme (Lamport/Merkle).
149+
150+
Useful for testing or when Ed25519 is not desired.
151+
"""
152+
@spec hash_sign(binary(), secret_key()) ::
153+
{:ok, binary(), secret_key()} | {:error, :keys_exhausted}
154+
def hash_sign(message, secret_key) when is_binary(message) do
155+
case HashSignature.sign(message, secret_key.hash_state) do
156+
{:ok, hash_sig, updated_state} ->
157+
encoded = :erlang.term_to_binary(hash_sig)
158+
{:ok, encoded, %{secret_key | hash_state: updated_state}}
159+
160+
error ->
161+
error
162+
end
163+
end
164+
165+
@doc """
166+
Verify a hash-only signature against a message and the Merkle root.
167+
"""
168+
@spec hash_verify(binary(), binary(), binary()) :: boolean()
169+
def hash_verify(message, encoded_sig, merkle_root)
170+
when is_binary(message) and is_binary(encoded_sig) and is_binary(merkle_root) do
171+
try do
172+
hash_sig = :erlang.binary_to_term(encoded_sig, [:safe])
173+
HashSignature.verify(message, hash_sig, merkle_root)
174+
rescue
175+
_ -> false
176+
end
177+
end
178+
179+
# ---------------------------------------------------------------------------
180+
# Stack-specific operations
181+
# ---------------------------------------------------------------------------
182+
183+
@doc """
184+
Sign a stack's content hash.
185+
186+
Produces a canonical binary representation of the stack (name, description,
187+
services sorted by name) and signs it.
188+
"""
189+
@spec sign_stack(map(), secret_key()) :: {:ok, binary(), secret_key()} | {:error, term()}
190+
def sign_stack(stack, secret_key) when is_map(stack) do
191+
canonical = canonical_stack_hash(stack)
192+
sign(canonical, secret_key)
193+
end
194+
195+
@doc """
196+
Verify a stack signature.
197+
"""
198+
@spec verify_stack(map(), binary(), public_key()) :: boolean()
199+
def verify_stack(stack, signature, public_key) when is_map(stack) do
200+
canonical = canonical_stack_hash(stack)
201+
verify(canonical, signature, public_key)
202+
end
203+
204+
# ---------------------------------------------------------------------------
205+
# Encoding / Decoding
206+
# ---------------------------------------------------------------------------
207+
208+
# Hybrid signature wire format:
209+
# <<version::8, ed_sig_len::16, ed_sig::binary, hash_sig_term::binary>>
210+
# version 1 = has both; version 2 = Ed25519-only (hash keys exhausted)
211+
212+
@sig_version_hybrid 1
213+
@sig_version_ed_only 2
214+
215+
defp encode_hybrid_signature(ed_sig, nil) do
216+
<<@sig_version_ed_only, byte_size(ed_sig)::16, ed_sig::binary>>
217+
end
218+
219+
defp encode_hybrid_signature(ed_sig, hash_sig) do
220+
hash_sig_bin = :erlang.term_to_binary(hash_sig)
221+
222+
<<@sig_version_hybrid, byte_size(ed_sig)::16, ed_sig::binary, hash_sig_bin::binary>>
223+
end
224+
225+
defp decode_hybrid_signature(<<@sig_version_hybrid, ed_len::16, rest::binary>>) do
226+
<<ed_sig::binary-size(ed_len), hash_sig_bin::binary>> = rest
227+
228+
try do
229+
hash_sig = :erlang.binary_to_term(hash_sig_bin, [:safe])
230+
{:ok, ed_sig, hash_sig}
231+
rescue
232+
_ -> :error
233+
end
234+
end
235+
236+
defp decode_hybrid_signature(<<@sig_version_ed_only, ed_len::16, ed_sig::binary-size(ed_len)>>) do
237+
{:ok, ed_sig, nil}
238+
end
239+
240+
defp decode_hybrid_signature(_), do: :error
241+
242+
# ---------------------------------------------------------------------------
243+
# Internal verification helpers
244+
# ---------------------------------------------------------------------------
245+
246+
defp verify_ed25519(message, ed_sig, ed_pub) do
247+
try do
248+
:crypto.verify(:eddsa, :none, message, ed_sig, [ed_pub, :ed25519])
249+
rescue
250+
_ -> false
251+
end
252+
end
253+
254+
defp verify_hash_sig(_message, nil, _root), do: false
255+
256+
defp verify_hash_sig(message, hash_sig, merkle_root) do
257+
try do
258+
HashSignature.verify(message, hash_sig, merkle_root)
259+
rescue
260+
_ -> false
261+
end
262+
end
263+
264+
# ---------------------------------------------------------------------------
265+
# Stack canonical form
266+
# ---------------------------------------------------------------------------
267+
268+
defp canonical_stack_hash(stack) do
269+
name = Map.get(stack, :name) || Map.get(stack, "name") || ""
270+
description = Map.get(stack, :description) || Map.get(stack, "description") || ""
271+
272+
services =
273+
(Map.get(stack, :services) || Map.get(stack, "services") || [])
274+
|> Enum.sort_by(fn svc ->
275+
Map.get(svc, :name) || Map.get(svc, "name") || ""
276+
end)
277+
|> Enum.map(fn svc ->
278+
svc_name = Map.get(svc, :name) || Map.get(svc, "name") || ""
279+
svc_kind = Map.get(svc, :kind) || Map.get(svc, "kind") || ""
280+
svc_port = Map.get(svc, :port) || Map.get(svc, "port") || ""
281+
"#{svc_name}:#{svc_kind}:#{svc_port}"
282+
end)
283+
|> Enum.join("|")
284+
285+
:crypto.hash(:sha256, "stapeln-stack:#{name}:#{description}:#{services}")
286+
end
287+
end

0 commit comments

Comments
 (0)