|
| 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