Ver Fonte

Merge remote-tracking branch 'origin/goal/security'

Billy Barrow há 1 dia atrás
pai
commit
44da559eb7

+ 1 - 1
MANIFEST.usm

@@ -1,6 +1,6 @@
 {
   "name" : "statum",
-  "version" : "0.1.2",
+  "version" : "0.2.0",
   "summary" : "Page rendering and state management for web applications",
   "licences" : [],
   "flags" : [

+ 68 - 2
getting-started.md

@@ -123,6 +123,67 @@ The guard redirects unauthenticated visitors to `/` before the entrypoint loads.
 
 ---
 
+## Server-side Security
+
+### Authorisation guards on `/_statum/*` endpoints (SA-6)
+
+The framework endpoints — `GET /_statum/entrypoint`, `GET /_statum/channel`
+and `PATCH /_statum/channel/{id}` — are open by default: the framework cannot
+know which slot data is public. Applications gate them through the
+configurator:
+
+```vala
+var statum = application.configure_with<StatumConfigurator>();
+
+// Return null to allow, or an HttpResult (401 / login redirect) to deny.
+statum.guard_entrypoint(ctx => auth.require_login(ctx));
+statum.guard_channel(ctx => auth.require_login(ctx));
+statum.guard_channel_subscriptions(ctx => auth.require_login(ctx));
+```
+
+Spry applications should install Spry guards here (see the Spry README's
+session-guard helpers).
+
+### Channel resource bounds (SA-15)
+
+Concurrent channel (SSE) connections are capped globally
+(`ChannelEndpoint.MAX_TOTAL_CONNECTIONS`, 256) and per remote address
+(`ChannelEndpoint.MAX_CONNECTIONS_PER_IP`, 16); each channel holds at most
+`MAX_SUBSCRIPTIONS_PER_CHANNEL` (64) subscriptions. Beyond the caps the
+channel endpoint answers `503`.
+
+### Frame expiry on restore (SA-4)
+
+`StateService.restore_slot` rejects (and invalidates) frames past their
+snapshot's `invalid_after`, and keeps the snapshot's `as_at` as the slot's
+`last_touched` — so replayed frames cannot pin slots forever and the cleanup
+sweep still expires restored slots. `verify_frame` also asserts the frame's
+`signer` matches the configured signing key (single-signer contract, SA-10).
+
+### Key generation (SA-12)
+
+`statum-genkeys` never prints secret key halves to stdout. Use:
+
+```bash
+statum-genkeys --out web-config.json
+```
+
+which writes the full config at mode 0600 and prints only the public halves.
+
+### Attribute expressions are trusted authoring (SA-18)
+
+`stm-*` attribute values are compiled with `new Function('scope',
+'with (scope) { … }')` in `statum.js`. That makes them **trusted template
+authoring**, on exactly the same footing as server-side page markup: never
+build an expression string from user-supplied data (request bodies, slot
+values, URL parameters) — interpolate user data only as *values* the
+expression reads (`stm-text` → `textContent`, `stm-html` → the escaped HTML
+path, `stm-markdown` → the CommonMark renderer). Today no shipped component
+binds user data into expression *source*; keeping it that way is a page
+author's contract, not a runtime guarantee.
+
+---
+
 ## HTML Templates
 
 Statum pages are HTML files processed at build time by `statum-mkpstm`.
@@ -184,7 +245,7 @@ statum-mkpstm -o HomePage.vala -n HomePage --ns=MyApp home.html
 statum-mkres -o LogoResource.vala -n logo.png -c image/png logo.png
 
 # Generate static signing/encryption keys for web-config.json
-statum-genkeys
+statum-genkeys --out web-config.json
 ```
 
 In Meson, these are wired as `custom_target`s (see `example/meson.build`).
@@ -593,9 +654,14 @@ To make signed frames and encrypted private blobs survive a server restart,
 configure static keys:
 
 ```bash
-statum-genkeys > web-config.json
+statum-genkeys --out web-config.json
 ```
 
+The secret key halves are written only to the file (mode 0600) — never to
+stdout, where shell history, terminal scrollback and CI logs would capture
+them. Without `--out` the tool prints only the PUBLIC halves (JSON shape) plus
+a pointer to `--out`.
+
 ```json
 {
   "statum": {

+ 1 - 1
meson.build

@@ -1,5 +1,5 @@
 project('statum', ['c', 'vala'],
-  version: '0.1',
+  version: '0.2.0',
 )
 
 vapi_dir = join_paths(meson.current_source_dir(), 'vapi')

+ 86 - 0
src/AuthorizationPolicy.vala

@@ -0,0 +1,86 @@
+using Invercargill;
+using Astralis;
+
+namespace Statum {
+
+    /**
+     * Application-supplied authorisation guards for the framework's `/_statum/*`
+     * endpoints (SA-6).
+     *
+     * `/_statum/entrypoint`, `/_statum/channel` and
+     * `/_statum/channel/{id}` are framework endpoints with no notion of who is
+     * calling: the entrypoint resolves held slots and runs a page handler for
+     * anyone, and the channel endpoints open realtime streams and push signed
+     * frames for anyone holding a channel id. The framework cannot know which
+     * slot data is public, so the decision belongs to the application —
+     * typically a Spry guard over the held session slot.
+     *
+     * Apps install guards through {@link StatumConfigurator} (which runs after
+     * {@link StatumModule}, so the singleton exists by then):
+     *
+     * ```vala
+     * var statum = application.configure_with<StatumConfigurator>();
+     * statum.guard_entrypoint(ctx => auth.require_login(ctx));
+     * statum.guard_channel(ctx => auth.require_login(ctx));
+     * ```
+     *
+     * A guard returns `null` to allow the request, or an {@link HttpResult}
+     * (401 or a login redirect) to short-circuit it. Endpoints that miss
+     * (unregistered guard) stay OPEN, preserving today's behaviour — the
+     * framework refuses to guess, so the default is permissive and documented
+     * rather than silently breaking existing apps.
+     *
+     * This object is a mutable singleton configured once at startup; it is
+     * read on every request and never written after the application starts
+     * serving.
+     */
+    public class AuthorizationPolicy : GLib.Object {
+
+        /**
+         * A request guard: `null` allows the request; a non-null
+         * {@link HttpResult} short-circuits it. Called with the endpoint's
+         * {@link Astralis.HttpContext}; guards that only inspect headers or
+         * the remote address work without a full request pipeline.
+         */
+        public delegate HttpResult? Guard(HttpContext? http_context);
+
+        private Guard? entrypoint_guard = null;
+        private Guard? channel_guard = null;
+        private Guard? channel_subscription_guard = null;
+
+        /**
+         * Guards `GET /_statum/entrypoint` (page entrypoints and their `set`
+         * frames).
+         */
+        public void guard_entrypoint(owned Guard guard) {
+            entrypoint_guard = (owned) guard;
+        }
+
+        /** Guards `GET /_statum/channel` (opening a realtime stream). */
+        public void guard_channel(owned Guard guard) {
+            channel_guard = (owned) guard;
+        }
+
+        /** Guards `PATCH /_statum/channel/{id}` (subscriptions). */
+        public void guard_channel_subscriptions(owned Guard guard) {
+            channel_subscription_guard = (owned) guard;
+        }
+
+        /** Runs the entrypoint guard, if one is installed. */
+        public HttpResult? authorize_entrypoint(HttpContext? http_context) {
+            return entrypoint_guard != null ? ((!)entrypoint_guard)(http_context) : null;
+        }
+
+        /** Runs the channel guard, if one is installed. */
+        public HttpResult? authorize_channel(HttpContext? http_context) {
+            return channel_guard != null ? ((!)channel_guard)(http_context) : null;
+        }
+
+        /** Runs the channel-subscription guard, if one is installed. */
+        public HttpResult? authorize_channel_subscriptions(HttpContext? http_context) {
+            return channel_subscription_guard != null ? ((!)channel_subscription_guard)(http_context) : null;
+        }
+
+    }
+
+}

+ 84 - 6
src/ChannelEndpoint.vala

@@ -5,9 +5,9 @@ using Astralis;
 namespace Statum {
 
     /**
-     * The realtime channel endpoint and {@link ChannelService} implementation.
+     * The realtime channel service.
      *
-     * Registered as a singleton (both as itself and as {@link ChannelService}).
+     * Registered as a singleton (as itself and as {@link ChannelService}).
      * Each `GET /_statum/channel` opens a long-lived SSE stream whose first event
      * is `event: channel` carrying a fresh channel id; subsequent `set`/`clear`
      * events are delivered only for the slot keys the channel has subscribed to
@@ -17,21 +17,73 @@ namespace Statum {
      * {@link push_set}/{@link push_clear}, which fan out to the subscribed
      * streams — a no-op when nothing is subscribed, giving selective fan-out for
      * free.
+     *
+     * Resource bounds (SA-15): every connection is a long-lived stream with an
+     * unbounded subscription set, so {@link MAX_TOTAL_CONNECTIONS},
+     * {@link MAX_CONNECTIONS_PER_IP} and {@link MAX_SUBSCRIPTIONS_PER_CHANNEL}
+     * cap how much of the server an anonymous client can pin down. Admission
+     * control itself lives in {@link ChannelGatewayEndpoint}, which owns the
+     * `/_statum/channel` route and calls {@link try_open_connection} before any
+     * stream is created.
      */
     public class ChannelEndpoint : SseEndpoint, ChannelService {
 
+        /**
+         * Concurrent channel connections across all clients (SA-15). Each one
+         * holds a stream, a connection object and its subscriptions open for
+         * as long as the client stays connected.
+         */
+        public const int MAX_TOTAL_CONNECTIONS = 256;
+
+        /**
+         * Concurrent channel connections from a single remote address
+         * (SA-15): one browser legitimately holds a handful of tabs, not
+         * hundreds of EventSource streams.
+         */
+        public const int MAX_CONNECTIONS_PER_IP = 16;
+
+        /**
+         * Subscriptions one channel may hold (SA-15): each subscription adds
+         * a fan-out write to every {@link push_set} for that key.
+         */
+        public const int MAX_SUBSCRIPTIONS_PER_CHANNEL = 64;
+
         public override uint retry_interval { get { return 3000; } }
 
         private Series<ChannelConnection> connections = new Series<ChannelConnection>();
+        /**
+         * Global/per-IP connection accounting (SA-15). Lives on the singleton
+         * so every gateway admission decision sees the same counts.
+         */
+        public ConnectionTracker connection_tracker { get; private set; }
+
+        construct {
+            connection_tracker = new ConnectionTracker();
+        }
+
+        /**
+         * Whether one more channel connection may be opened from `ip`, given
+         * the SA-15 global and per-IP caps. Checked by the gateway before a
+         * stream is created; the count is released on disconnect.
+         */
+        public bool can_accept_connection(string? ip) {
+            drop_closed();
+            return connection_tracker.can_accept(ip);
+        }
 
         public override async void new_connection(HttpContext http_context, RouteContext route_context, SseStream stream) {
             var connection = new ChannelConnection() {
                 stream = stream,
-                id = generate_channel_id()
+                id = generate_channel_id(),
+                ip = http_context.request.remote_address
             };
             connections.add(connection);
+            connection_tracker.acquired(connection.ip);
 
-            stream.disconnected.connect(() => connections.remove(connection));
+            stream.disconnected.connect(() => {
+                connections.remove(connection);
+                connection_tracker.released(connection.ip);
+            });
 
             try {
                 yield stream.send_event(new SseEvent.with_type("channel", connection.id));
@@ -40,6 +92,18 @@ namespace Statum {
             }
         }
 
+        /** Drops closed connections and releases their counted slots. */
+        private void drop_closed() {
+            // Closed streams are normally removed by the disconnect handler;
+            // sweep any that slipped through (e.g. a stream closed before its
+            // handler ran) so the caps measure live connections only.
+            var closed = connections.where(c => c.stream.is_closed).to_series();
+            foreach (var connection in closed) {
+                connections.remove(connection);
+                connection_tracker.released(connection.ip);
+            }
+        }
+
         /** Pushes a `set` event carrying `frame_json` to subscribed channels. */
         public async void push_set(string key, string frame_json) {
             foreach (var connection in open_subscribers(key)) {
@@ -67,12 +131,24 @@ namespace Statum {
             return connections.any(c => !c.stream.is_closed && c.id == id);
         }
 
-        /** Adds `key` to channel `id`'s subscriptions. */
-        public void subscribe(string id, string key) {
+        /**
+         * Adds `key` to channel `id`'s subscriptions, bounded by
+         * {@link MAX_SUBSCRIPTIONS_PER_CHANNEL} (SA-15).
+         *
+         * @return `true` when the subscription was recorded.
+         */
+        public bool subscribe(string id, string key) {
             var connection = find_connection(id);
             if (connection != null) {
+                if (((!)connection).subs.length >= MAX_SUBSCRIPTIONS_PER_CHANNEL) {
+                    warning("[Statum] channel %s hit the %d-subscription cap; ignoring key \"%s\"",
+                        id, MAX_SUBSCRIPTIONS_PER_CHANNEL, key);
+                    return false;
+                }
                 ((!)connection).subs.add(key);
+                return true;
             }
+            return false;
         }
 
         /** Removes `key` from channel `id`'s subscriptions. */
@@ -111,6 +187,8 @@ namespace Statum {
         private class ChannelConnection : Object {
             public SseStream stream { get; set; }
             public string id { get; set; }
+            /** Remote address, released from the per-IP count on disconnect. */
+            public string? ip { get; set; }
             public Set<string> subs { get; private set; }
 
             public ChannelConnection() {

+ 63 - 0
src/ChannelGatewayEndpoint.vala

@@ -0,0 +1,63 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Inversion;
+using Astralis;
+
+namespace Statum {
+
+    /**
+     * `GET /_statum/channel` — the routed face of {@link ChannelEndpoint}.
+     *
+     * {@link ChannelEndpoint} extends Astralis' `SseEndpoint`, whose
+     * `handle_request` is sealed (it must return the `HttpSseResult` that owns
+     * the stream lifecycle), so authorisation and admission control run here,
+     * on the route this gateway owns, before the SSE result is created:
+     *
+     * - {@link authorize} (SA-6): delegates to the {@link AuthorizationPolicy}
+     *   singleton — apps install a guard via
+     *   {@link StatumConfigurator.guard_channel} (open when none is
+     *   installed, so existing applications keep working).
+     * - {@link ChannelEndpoint.can_accept_connection} (SA-15): the global and
+     *   per-IP concurrent-connection caps, so anonymous clients cannot pin an
+     *   unbounded number of long-lived streams.
+     *
+     * All connections still flow through the one shared
+     * {@link ChannelService} singleton and its caps; this gateway is
+     * stateless per request.
+     */
+    public class ChannelGatewayEndpoint : Object, Endpoint {
+
+        private ChannelEndpoint channel_endpoint = inject<ChannelEndpoint>();
+        private AuthorizationPolicy policy = inject<AuthorizationPolicy>();
+
+        /**
+         * Authorisation gate for the channel stream (SA-6): delegates to the
+         * {@link AuthorizationPolicy}. Return `null` to proceed; a non-null
+         * {@link HttpResult} — a 401 or a redirect to login — short-circuits
+         * and no stream is opened.
+         */
+        public virtual HttpResult? authorize(HttpContext http_context) {
+            return policy.authorize_channel(http_context);
+        }
+
+        public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws GLib.Error {
+            var denied = authorize(http_context);
+            if (denied != null) {
+                return (!)denied;
+            }
+
+            if (!channel_endpoint.can_accept_connection(http_context.request.remote_address)) {
+                warning("[Statum] channel connection refused: %s (%d global / %d per-IP cap reached)",
+                    http_context.request.remote_address ?? "unknown client",
+                    ChannelEndpoint.MAX_TOTAL_CONNECTIONS,
+                    ChannelEndpoint.MAX_CONNECTIONS_PER_IP);
+                return new HttpStringResult("Too many channel connections",
+                    (StatusCode) 503);
+            }
+
+            return yield channel_endpoint.handle_request(http_context, route_context);
+        }
+
+    }
+
+}

+ 26 - 0
src/ChannelSubscriptionEndpoint.vala

@@ -18,8 +18,34 @@ namespace Statum {
 
         private ChannelEndpoint channel_endpoint = inject<ChannelEndpoint>();
         private StateService state_service = inject<StateService>();
+        private AuthorizationPolicy policy = inject<AuthorizationPolicy>();
+
+        /**
+         * Authorisation gate for this endpoint (SA-6).
+         *
+         * The 128-bit channel id is the only authorisation for subscribing to
+         * a slot key — anyone who learns an id can receive that slot's signed
+         * frames. Install a guard via
+         * {@link StatumConfigurator.guard_channel_subscriptions} (backed by
+         * the {@link AuthorizationPolicy} singleton) to gate
+         * `PATCH /_statum/channel/{id}` on the caller's identity (e.g. a
+         * Spry guard over the held session slot).
+         *
+         * This virtual method is the extension point for applications that
+         * subclass this endpoint; the default delegates to the shared
+         * {@link AuthorizationPolicy} (open when no guard is installed, so
+         * existing apps keep working).
+         */
+        public virtual HttpResult? authorize(HttpContext http_context) {
+            return policy.authorize_channel_subscriptions(http_context);
+        }
 
         public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws GLib.Error {
+            var denied = authorize(http_context);
+            if (denied != null) {
+                return (!)denied;
+            }
+
             var id = route_context.mapped_parameters.get_or_default("id");
             if (id == null || !channel_endpoint.has_channel((!)id)) {
                 return new HttpStringResult("No such channel", StatusCode.NOT_FOUND);

+ 93 - 0
src/ConnectionTracker.vala

@@ -0,0 +1,93 @@
+using Invercargill;
+using Invercargill.DataStructures;
+
+namespace Statum {
+
+    /**
+     * Counts open channel connections globally and per remote address
+     * (SA-15).
+     *
+     * Extracted from {@link ChannelEndpoint} so the admission policy is
+     * testable without a live SSE stream: the endpoint calls
+     * {@link can_accept} before opening a stream, {@link acquired} once it is
+     * registered, and {@link released} when it disconnects. Counts are
+     * maintained by address string, so `null` (unknown client) counts toward
+     * the global cap only.
+     */
+    public class ConnectionTracker : Object {
+
+        /** Maximum concurrent connections across all clients. */
+        public int max_total { get; private set; }
+
+        /** Maximum concurrent connections from one remote address. */
+        public int max_per_ip { get; private set; }
+
+        private int total = 0;
+        private Dictionary<string, int> per_ip = new Dictionary<string, int>();
+
+        public ConnectionTracker(int max_total = ChannelEndpoint.MAX_TOTAL_CONNECTIONS,
+                int max_per_ip = ChannelEndpoint.MAX_CONNECTIONS_PER_IP) {
+            this.max_total = max_total;
+            this.max_per_ip = max_per_ip;
+        }
+
+        /**
+         * Whether one more connection may be opened — by `ip` (when known)
+         * and globally.
+         */
+        public bool can_accept(string? ip) {
+            if (total >= max_total) {
+                return false;
+            }
+            if (ip != null) {
+                int current = 0;
+                if (per_ip.try_get((!)ip, out current) && current >= max_per_ip) {
+                    return false;
+                }
+            }
+            return true;
+        }
+
+        /** Records a connection opened from `ip` (must have passed {@link can_accept}). */
+        public void acquired(string? ip) {
+            total++;
+            if (ip != null) {
+                int current = 0;
+                per_ip.try_get((!)ip, out current);
+                per_ip.set((!)ip, current + 1);
+            }
+        }
+
+        /** Records a connection from `ip` closing. Counts never go negative. */
+        public void released(string? ip) {
+            total = total > 0 ? total - 1 : 0;
+            if (ip != null) {
+                int current = 0;
+                if (per_ip.try_get((!)ip, out current)) {
+                    if (current <= 1) {
+                        per_ip.remove((!)ip);
+                    } else {
+                        per_ip.set((!)ip, current - 1);
+                    }
+                }
+            }
+        }
+
+        /** Currently counted open connections, across all clients. */
+        public int open_total() {
+            return total;
+        }
+
+        /** Currently counted open connections from `ip`. */
+        public int open_for(string? ip) {
+            if (ip == null) {
+                return 0;
+            }
+            int current = 0;
+            per_ip.try_get((!)ip, out current);
+            return current;
+        }
+
+    }
+
+}

+ 20 - 3
src/Cryptography/SigningProvider.vala

@@ -20,6 +20,15 @@ namespace Statum.Cryptography {
      * signing keypair and exposes the {@link signer_id} it claims on authored
      * frames, plus helpers to author a {@link FrameDto} and to verify an
      * existing one.
+     *
+     * Single-signer contract: one deployment runs one signing keypair, and
+     * {@link FrameDto.signer} must name *this* provider's {@link signer_id}.
+     * {@link verify_frame} fails closed on a mismatched signer — a signature
+     * that verifies against the local key while claiming to be another signer
+     * is a forgery attempt (or evidence of a split-brain key rollout), not a
+     * frame to trust. To accept frames from a second signer during a key
+     * rotation, verify explicitly with {@link verify_with_key} on that signer's
+     * public key rather than widening this default path.
      */
     public class SigningProvider : GLib.Object {
 
@@ -91,14 +100,22 @@ namespace Statum.Cryptography {
 
         /**
          * Verifies a frame's signature against its content using this
-         * provider's public key.
+         * provider's public key, and asserts the single-signer contract: the
+         * frame's {@link FrameDto.signer} must equal this provider's
+         * {@link signer_id} (SA-10). The signer string is a claim, not proof —
+         * treating it as informational let a frame signed by key A pass while
+         * claiming `signer: B`.
          *
          * @throws SigningError.INVALID_SIGNATURE if the signature does not
-         *         verify.
+         *         verify or the claimed signer is not this provider.
          */
         public void verify_frame(FrameDto frame) throws SigningError {
+            if (frame.signer != signer_id) {
+                throw new SigningError.INVALID_SIGNATURE(
+                    "Frame claims signer \"" + frame.signer + "\" but this provider is \"" + signer_id + "\"");
+            }
             if (!verify(frame.content, frame.signature)) {
-                throw new SigningError.INVALID_SIGNATURE(@"Signature for frame signed by \"$(frame.signer)\" is invalid");
+                throw new SigningError.INVALID_SIGNATURE("Signature for frame signed by \"" + frame.signer + "\" is invalid");
             }
         }
 

+ 27 - 0
src/EntrypointEndpoint.vala

@@ -20,8 +20,35 @@ namespace Statum {
         private HeldSlotResolver resolver = inject<HeldSlotResolver>();
         private EntrypointRouteTable route_table = inject<EntrypointRouteTable>();
         private StateService state_service = inject<StateService>();
+        private AuthorizationPolicy policy = inject<AuthorizationPolicy>();
+
+        /**
+         * Authorisation gate for this endpoint (SA-6).
+         *
+         * `GET /_statum/entrypoint` resolves held slots and runs the page's
+         * entrypoint handler — including any `set` frames it authors — for
+         * whoever asks. The framework cannot know which pages are public, so
+         * the decision belongs to the application: install a guard via
+         * {@link StatumConfigurator.guard_entrypoint} (backed by the
+         * {@link AuthorizationPolicy} singleton). Spry apps should resolve the
+         * held session slot there and return a `401`/login-redirect
+         * {@link HttpResult} for anonymous callers.
+         *
+         * This virtual method is the extension point for applications that
+         * subclass this endpoint; the default delegates to the shared
+         * {@link AuthorizationPolicy} (open when no guard is installed, so
+         * existing apps keep working).
+         */
+        public virtual HttpResult? authorize(HttpContext http_context) {
+            return policy.authorize_entrypoint(http_context);
+        }
 
         public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws GLib.Error {
+            var denied = authorize(http_context);
+            if (denied != null) {
+                return (!)denied;
+            }
+
             var raw_uri = http_context.request.get_query("uri") ?? "/";
 
             // The client passes the page's full href (e.g.

+ 13 - 4
src/SlotPostEndpoint.vala

@@ -14,9 +14,10 @@ namespace Statum {
      * this endpoint verifies the signature: on success it
      * {@link StateService.restore_slot}s the slot into the cache, re-signs it
      * (issuing a fresh `as_at`/`transmit_after`), and returns a `set` directive
-     * so the client refreshes its local snapshot; on failure it returns an
-     * `invalidate` for that frame's signature so the client drops the slot whose
-     * frame the server could not trust.
+     * so the client refreshes its local snapshot; on failure — a bad signature
+     * or (SA-4) a frame past its `invalid_after` — it returns an `invalidate`
+     * for that frame's signature so the client drops the slot whose frame the
+     * server could not trust.
      */
     public class SlotPostEndpoint : Object, Endpoint {
 
@@ -51,7 +52,15 @@ namespace Statum {
                     var content_object = content_element.as<JsonObject>();
                     var snapshot = Model.SnapshotDto.get_mapper().materialise(content_object);
 
-                    state_service.restore_slot(frame);
+                    try {
+                        state_service.restore_slot(frame);
+                    } catch (StateError.FRAME_EXPIRED e) {
+                        // SA-4: the frame is past its invalid_after — tell the
+                        // client to drop the slot it came from rather than
+                        // re-sending it forever.
+                        invalid_signatures.add(frame.signature);
+                        continue;
+                    }
 
                     var refreshed = state_service.sign_slot(snapshot.slot.key);
                     if (refreshed != null) {

+ 34 - 2
src/StateService.vala

@@ -11,6 +11,11 @@ namespace Statum {
     public errordomain StateError {
         /** No slot is cached for the given key. */
         SLOT_NOT_FOUND,
+        /**
+         * A frame offered for restore is past its snapshot's `invalid_after`
+         * and must not be re-cached (SA-4: the replay-forever hole).
+         */
+        FRAME_EXPIRED,
     }
 
     public class StateService : Object {
@@ -169,12 +174,37 @@ namespace Statum {
             yield clear_slot(GLOBAL_KEY_PREFIX + type_name);
         }
 
+        /**
+         * Re-caches the slot described by `frame` from its signed snapshot.
+         *
+         * Security (SA-4): the snapshot's `invalid_after` is enforced here — a
+         * frame past its invalidation time is rejected (and any cached slot for
+         * that key dropped) instead of being restored, so a replayed frame
+         * cannot keep a slot alive forever. On a successful restore the
+         * snapshot's `as_at` becomes the slot's `last_touched` rather than
+         * "now": a restored slot must stay expirable by {@link cleanup} — the
+         * client only holds it because the server lost it, and refreshing the
+         * touch clock would let scheduled replays pin slots indefinitely.
+         *
+         * @return `true` when the slot was restored, `false` when a slot was
+         *         already cached under the same key (no-op).
+         * @throws StateError.FRAME_EXPIRED when the frame is past its
+         *         `invalid_after` (after the signature verified).
+         */
         public bool restore_slot(Model.FrameDto frame) throws GLib.Error {
             signing_provider.verify_frame(frame);
             var content_element = new JsonElement.from_string(frame.content);
             var json_object = content_element.as<JsonObject>();
             var snapshot = Model.SnapshotDto.get_mapper().materialise(json_object);
 
+            if (snapshot.invalid_after != null
+                    && ((!)snapshot.invalid_after).compare(new DateTime.now_utc()) <= 0) {
+                // Expired frame: drop anything cached under its key so the
+                // stale slot does not linger either.
+                slots.remove(snapshot.slot.key);
+                throw new StateError.FRAME_EXPIRED("Frame for slot \"" + snapshot.slot.key + "\" is past its invalid_after");
+            }
+
             var slot = get_slot(snapshot.slot.key);
             if (slot != null) {
                 // No-op: slot not empty.
@@ -188,8 +218,10 @@ namespace Statum {
                 current_state = new State() {
                     type_name = snapshot.slot.slot_type,
                     public_data = snapshot.public,
-                    private_data = encryption_provider.read_properties(snapshot.slot.key, snapshot.private),
-                    last_touched = new DateTime.now_utc()
+                    private_data = snapshot.private != null
+                        ? encryption_provider.read_properties(snapshot.slot.key, snapshot.private)
+                        : new PropertyDictionary(),
+                    last_touched = snapshot.as_at
                 }
             };
             slots.set(slot.id, slot);

+ 127 - 6
src/Statum.vala

@@ -20,6 +20,20 @@ namespace Statum {
      */
     public class StatumModule : Object, Module {
 
+        /**
+         * The "statum" config keys that carry long-lived secret material
+         * (SA-9): when any of them is present, the file's permissions
+         * become a finding.
+         */
+        private const string[] STATUM_SECRET_KEYS = {
+            "signing_secret_key",
+            "encryption_signing_secret_key",
+            "encryption_sealing_secret_key",
+        };
+
+        /** Paths already reported by {@link warn_on_readable_config}. */
+        private static GenericArray<string> reported_config_paths = new GenericArray<string>();
+
         public void register_components(Container container) throws Error {
             // Enable recursive mapping of ActionDto fields on typed slot models.
             GObjectMapping.register_mapper<Model.ActionDto>(Model.ActionDto.get_mapper());
@@ -35,10 +49,17 @@ namespace Statum {
             container.register_singleton<EntrypointRouteTable>();
             container.register_singleton<ActionRegistry>();
             container.register_singleton<TopicRegistry>();
+            container.register_singleton<AuthorizationPolicy>();
 
             // The realtime channel endpoint is the singleton ChannelService.
+            // The routed face of the channel is the gateway below: it applies
+            // the SA-6 authorisation hook and the SA-15 connection caps before
+            // the SSE stream is created, which the sealed SseEndpoint
+            // handle_request cannot do itself.
             container.register_singleton<ChannelEndpoint>()
-                .as<ChannelService>()
+                .as<ChannelService>();
+
+            container.register_scoped<ChannelGatewayEndpoint>()
                 .as<Endpoint>()
                 .with_metadata<EndpointRoute>(new EndpointRoute("/_statum/channel"));
 
@@ -88,8 +109,9 @@ namespace Statum {
                 + "[Statum]  Frames are signed with an EPHEMERAL key:\n"
                 + "[Statum]    → every client-held slot (including sessions) is INVALIDATED\n"
                 + "[Statum]      the moment this process restarts.\n"
-                + "[Statum]  Run `statum-genkeys` and add the generated keys to web-config.json\n"
-                + "[Statum]  to make frames survive restarts.\n"
+                + "[Statum]  Run `statum-genkeys --out web-config.json` to generate static keys\n"
+                + "[Statum]  (secrets go straight to the file at mode 0600 — never to stdout)\n"
+                + "[Statum]  and make frames survive restarts.\n"
                 + "[Statum] ────────────────────────────────────────────────────────────────────");
             return new Cryptography.SigningProvider();
         }
@@ -113,8 +135,9 @@ namespace Statum {
                 + "[Statum]  Private blobs are sealed with EPHEMERAL keys:\n"
                 + "[Statum]    → action/snapshot private data becomes unreadable\n"
                 + "[Statum]      the moment this process restarts.\n"
-                + "[Statum]  Run `statum-genkeys` and add the generated keys to web-config.json\n"
-                + "[Statum]  to make private blobs survive restarts.\n"
+                + "[Statum]  Run `statum-genkeys --out web-config.json` to generate static keys\n"
+                + "[Statum]  (secrets go straight to the file at mode 0600 — never to stdout)\n"
+                + "[Statum]  and make private blobs survive restarts.\n"
                 + "[Statum] ────────────────────────────────────────────────────────────────────");
             return new Cryptography.EncryptionProvider();
         }
@@ -122,12 +145,79 @@ namespace Statum {
         private static Astralis.WebConfigSection? read_statum_config(Inversion.Scope scope) {
             try {
                 var config = scope.resolve<Astralis.WebConfig>();
-                return config.get_section("statum");
+                var section = config.get_section("statum");
+                warn_on_readable_config(config, section);
+                return section;
             } catch {
                 return null;
             }
         }
 
+        /**
+         * SA-9: warns when the files contributing the "statum" section
+         * (which carry every long-lived signing/sealing secret) are group-
+         * or world-readable.
+         *
+         * Deliberately a WARNING, not a refusal: refusing would brick
+         * development setups (where a 0644 config next to the sources is
+         * the norm and the only reader is the developer's own account) and
+         * break image-baked container deployments whose layers are already
+         * world-readable by construction. Production operators get a loud,
+         * actionable message instead.
+         */
+        private static void warn_on_readable_config(Astralis.WebConfig config, Astralis.WebConfigSection? section) {
+            if (section == null) {
+                return;
+            }
+
+            // Only warn when the section actually carries a secret: an empty
+            // "statum" object (as `spry new` scaffolds it) has nothing to
+            // leak, and warning there would just train operators to ignore
+            // the message.
+            bool has_secret = false;
+            foreach (string key in STATUM_SECRET_KEYS) {
+                if (((!)section).get_string(key).length > 0) {
+                    has_secret = true;
+                    break;
+                }
+            }
+            if (!has_secret) {
+                return;
+            }
+
+            foreach (var path in config.get_loaded_files()) {
+                if (reported_config_paths.find_with_equal_func(path, str_equal)) {
+                    // Both provider factories read the same config; report
+                    // each file once.
+                    continue;
+                }
+                reported_config_paths.add(path);
+                try {
+                    // Follow any symlink the operator made, so the mode of
+                    // the file actually holding the secrets is reported.
+                    var info = File.new_for_commandline_arg(path).query_info(
+                        FileAttribute.STANDARD_TYPE + "," + FileAttribute.UNIX_MODE,
+                        FileQueryInfoFlags.NONE);
+                    // 0x1FF = 0777, 0x3F = 0067: group+other permission bits.
+                    var mode = info.get_attribute_uint32(FileAttribute.UNIX_MODE) & 0x1FF;
+                    if ((mode & 0x3F) != 0) {
+                        var finding = ("%s is mode %04o — group/world readable.\n"
+                            + "It carries the \"statum\" section: every session-signing\n"
+                            + "and private-blob sealing key. Anyone who can read it\n"
+                            + "can forge sessions.").printf(path, mode);
+                        warning("[Statum] ────────────────────────────────────────────────────────────────\n"
+                            + "[Statum]  Insecure config file permissions (SA-9):\n"
+                            + "[Statum]    " + finding + "\n"
+                            + "[Statum]  Fix: chmod 600 " + path + "\n"
+                            + "[Statum] ────────────────────────────────────────────────────────────────");
+                    }
+                } catch (GLib.Error e) {
+                    // A file that vanished or cannot be stat'ed is the
+                    // loader's problem, not a permission finding.
+                }
+            }
+        }
+
     }
 
     /**
@@ -149,10 +239,41 @@ namespace Statum {
         private Container container = inject<Container>();
         private EntrypointRouteTable route_table = inject<EntrypointRouteTable>();
         private ActionRegistry action_registry = inject<ActionRegistry>();
+        private AuthorizationPolicy authorization = inject<AuthorizationPolicy>();
         /** Exposed for background tasks (timers, webhooks) that need to trigger topics. */
         public TopicRegistry topic_registry = inject<TopicRegistry>();
         public StateService state_service = inject<StateService>();
 
+        /**
+         * Installs an authorisation guard for `GET /_statum/entrypoint` (SA-6).
+         *
+         * The entrypoint endpoint resolves held slots and runs a page's
+         * entrypoint handler for anyone who asks; apps that need to gate it
+         * (e.g. redirect anonymous callers to login) install a guard here.
+         * `guard` returns `null` to allow the request or an
+         * {@link HttpResult} (401/redirect) to short-circuit it.
+         */
+        public void guard_entrypoint(owned AuthorizationPolicy.Guard guard) {
+            authorization.guard_entrypoint((owned) guard);
+        }
+
+        /**
+         * Installs an authorisation guard for `GET /_statum/channel` (SA-6) —
+         * opening a realtime stream. Without a guard the stream is open to
+         * anyone (bounded by the SA-15 connection caps).
+         */
+        public void guard_channel(owned AuthorizationPolicy.Guard guard) {
+            authorization.guard_channel((owned) guard);
+        }
+
+        /**
+         * Installs an authorisation guard for
+         * `PATCH /_statum/channel/{id}` (SA-6) — a channel's subscriptions.
+         */
+        public void guard_channel_subscriptions(owned AuthorizationPolicy.Guard guard) {
+            authorization.guard_channel_subscriptions((owned) guard);
+        }
+
         /**
          * Resolves a page's route from its {@link StatumPage.route} property,
          * throwing at startup when the page has no `<pstm-uri>`.

+ 13 - 5
src/meson.build

@@ -38,6 +38,9 @@ sources = files(
     'StatumPage.vala',
     'EntrypointRouteTable.vala',
     'ChannelEndpoint.vala',
+    'ChannelGatewayEndpoint.vala',
+    'ConnectionTracker.vala',
+    'AuthorizationPolicy.vala',
     'ChannelSubscriptionEndpoint.vala',
     'SlotPostEndpoint.vala',
     'EntrypointEndpoint.vala',
@@ -60,8 +63,13 @@ sources = files(
     'Slot.vala'
 )
 
-library_version = meson.project_version()
-libstatum = shared_library('statum-@0@'.format(library_version),
+# The API version rides every artifact name (libstatum-0.1.so,
+# statum-0.1.vapi, ...); the project version is the release version and
+# only reaches the pkg-config Version: field (usm checks it against the
+# manifest version).
+api_version = '0.1'
+release_version = meson.project_version()
+libstatum = shared_library('statum-@0@'.format(api_version),
     sources, client_script, client_worker,
     dependencies: [
         glib_dep,
@@ -80,14 +88,14 @@ libstatum = shared_library('statum-@0@'.format(library_version),
         invercargill_sql_inversion_dep,
     ],
     install: true,
-    vala_gir: 'statum-@0@.gir'.format(library_version),
+    vala_gir: 'statum-@0@.gir'.format(api_version),
     install_dir: [true, true, true, true]
 )
 
 pkg = import('pkgconfig')
 pkg.generate(libstatum,
-    version: library_version,
-    name: 'statum-@0@'.format(library_version))
+    version: release_version,
+    name: 'statum-@0@'.format(api_version))
 
 statum_dep = declare_dependency(
     link_with: libstatum,

+ 226 - 0
tests/TestMain.vala

@@ -3,6 +3,7 @@ using Invercargill.DataStructures;
 using Invercargill.Mapping;
 using InvercargillJson;
 using Inversion;
+using Astralis;
 using Statum;
 
 namespace Statum.Tests {
@@ -167,6 +168,198 @@ namespace Statum.Tests {
         }
     }
 
+    /**
+     * SA-4: restore_slot must enforce the snapshot's invalid_after and must
+     * not refresh last_touched, so replayed frames cannot pin slots forever
+     * and the cleanup sweep still expires restored slots.
+     */
+    void test_restore_slot_expiry(StateService states, Statum.Cryptography.SigningProvider signing) throws GLib.Error {
+        // A live frame authored now: restorable, keeps its own as_at as
+        // last_touched, and still expirable by cleanup.
+        var slot = states.new_slot(Scope.SESSION, new State() {
+            type_name = "test-sa4",
+            public_data = new PropertyDictionary(),
+            private_data = new PropertyDictionary()
+        });
+        var live_frame = sign_snapshot(signing, slot.id, "test-sa4", new DateTime.now_utc().add_hours(6));
+        var before_restore = new DateTime.now_utc();
+
+        // Evict the server-side copy first so restore_slot has an empty cache
+        // to restore into (a non-empty slot makes restore a no-op).
+        states.clear_slot.begin(slot.id);
+        check(states.get_slot(slot.id) == null, "SA-4: slot evicted before first restore");
+        var restored = states.restore_slot(live_frame);
+        check(restored, "SA-4: a frame inside its invalid_after window restores");
+        var cached = states.get_slot(slot.id);
+        check(cached != null, "SA-4: the restored slot is cached");
+        if (cached != null) {
+            var touched = ((!)cached).current_state.last_touched;
+            check(touched.compare(before_restore) <= 0,
+                "SA-4: restore does not refresh last_touched (the clock is not 'now')");
+        }
+
+        // The same slot re-restored after eviction must still expire through
+        // cleanup, because last_touched came from the snapshot, not 'now'.
+        states.clear_slot.begin(slot.id); // remove from cache (async push is a no-op)
+        check(states.get_slot(slot.id) == null, "SA-4: slot evicted before replay");
+        // Re-sign a frame with an as_at in the past but still valid.
+        var old_touch = new DateTime.now_utc().add_days(-6);
+        var stale_frame = sign_snapshot(signing, slot.id, "test-sa4-old", new DateTime.now_utc().add_hours(1), old_touch);
+        check(states.restore_slot(stale_frame), "SA-4: stale-but-valid frame restores");
+        states.cleanup();
+        check(states.get_slot(slot.id) != null, "SA-4: restored slot within EXPIRY_SECONDS survives cleanup");
+        // A frame whose as_at predates the 7-day expiry is gone after cleanup.
+        var ancient_touch = new DateTime.now_utc().add_days(-9);
+        var ancient_frame = sign_snapshot(signing, slot.id + "-ancient", "test-sa4-ancient",
+            new DateTime.now_utc().add_hours(1), ancient_touch);
+        check(states.restore_slot(ancient_frame), "SA-4: ancient-but-valid frame restores (once)");
+        states.cleanup();
+        check(states.get_slot(slot.id + "-ancient") == null,
+            "SA-4: cleanup expires a restored slot (restore no longer resets last_touched to now)");
+
+        // A frame past its invalid_after is rejected outright.
+        var expired_frame = sign_snapshot(signing, slot.id + "-expired", "test-sa4-expired",
+            new DateTime.now_utc().add_seconds(-5));
+        bool threw = false;
+        try {
+            states.restore_slot(expired_frame);
+        } catch (StateError e) {
+            threw = true;
+        } catch (GLib.Error e) {
+            threw = false;
+        }
+        check(threw, "SA-4: restore_slot rejects a frame past invalid_after with FRAME_EXPIRED");
+        check(states.get_slot(slot.id + "-expired") == null,
+            "SA-4: a rejected frame leaves no slot cached");
+
+        // SA-4 bonus: a rejected replay also drops any slot already cached
+        // under the same key, so the stale entry cannot linger.
+        var cached_key = slot.id + "-drop";
+        check(states.restore_slot(sign_snapshot(signing, cached_key, "test-sa4-drop",
+            new DateTime.now_utc().add_hours(1))), "SA-4: slot cached for the drop test");
+        check(states.get_slot(cached_key) != null, "SA-4: slot present before expired replay");
+        try {
+            states.restore_slot(sign_snapshot(signing, cached_key, "test-sa4-drop",
+                new DateTime.now_utc().add_seconds(-5)));
+        } catch (StateError.FRAME_EXPIRED e) {
+            // expected
+        }
+        check(states.get_slot(cached_key) == null,
+            "SA-4: an expired replay of the same key invalidates the cached slot");
+    }
+
+    /** Signs a snapshot for `key` valid until `invalid_after`, authored `as_at`. */
+    Model.FrameDto sign_snapshot(Statum.Cryptography.SigningProvider signing, string key, string type,
+            DateTime invalid_after, DateTime? as_at = null) throws GLib.Error {
+        var dto = new Model.SnapshotDto() {
+            as_at = as_at ?? new DateTime.now_utc(),
+            slot = new Model.SlotDto() {
+                key = key,
+                scope = Scope.SESSION,
+                slot_type = type
+            },
+            transmit_after = new DateTime.now_utc().add_seconds(60),
+            invalid_after = invalid_after,
+            @public = new PropertyDictionary()
+        };
+        var json = new JsonElement.from_properties(Model.SnapshotDto.get_mapper().map_from(dto)).stringify();
+        return signing.author_frame(json);
+    }
+
+    /** SA-10: verify_frame must fail closed on a mismatched signer claim. */
+    void test_verify_frame_signer(Statum.Cryptography.SigningProvider signing,
+            Statum.Cryptography.SigningProvider other) throws GLib.Error {
+        var frame = sign_snapshot(signing, "sa10-key", "test-sa10", new DateTime.now_utc().add_hours(1));
+
+        signing.verify_frame(frame);
+        check(true, "SA-10: a frame signed and claimed by this provider verifies");
+
+        // Same content and signature, forged signer claim.
+        var forged = new Model.FrameDto() {
+            content = frame.content,
+            signer = other.signer_id,
+            signature = frame.signature
+        };
+        bool threw = false;
+        try {
+            signing.verify_frame(forged);
+        } catch (Statum.Cryptography.SigningError e) {
+            threw = true;
+        }
+        check(threw, "SA-10: verify_frame rejects a frame whose signer != signer_id");
+
+        // A frame actually signed by another provider is rejected too (its
+        // signature does not verify against this key).
+        var foreign = sign_snapshot(other, "sa10-key", "test-sa10", new DateTime.now_utc().add_hours(1));
+        threw = false;
+        try {
+            signing.verify_frame(foreign);
+        } catch (Statum.Cryptography.SigningError e) {
+            threw = true;
+        }
+        check(threw, "SA-10: verify_frame rejects a frame signed by a different key");
+    }
+
+    /** SA-15: global and per-IP channel connection caps. */
+    void test_connection_caps() {
+        var tracker = new ConnectionTracker(4, 2);
+
+        check(tracker.can_accept("10.0.0.1"), "SA-15: first connection from an IP is accepted");
+        tracker.acquired("10.0.0.1");
+        check(tracker.can_accept("10.0.0.1"), "SA-15: second connection from the same IP is accepted");
+        tracker.acquired("10.0.0.1");
+        check(!tracker.can_accept("10.0.0.1"), "SA-15: third connection from the same IP is refused (per-IP cap)");
+        check(tracker.can_accept("10.0.0.2"), "SA-15: another IP is unaffected by the per-IP cap");
+
+        tracker.released("10.0.0.1");
+        check(tracker.can_accept("10.0.0.1"), "SA-15: releasing a connection frees its per-IP slot");
+
+        tracker.acquired("10.0.0.2");
+        tracker.acquired("10.0.0.3");
+        // total: 1 + 1 + 1 = 3 of 4 — the next is the last.
+        check(tracker.can_accept("10.0.0.4"), "SA-15: connection accepted up to the global cap");
+        tracker.acquired("10.0.0.4");
+        check(!tracker.can_accept("10.0.0.4"), "SA-15: connections beyond the global cap are refused");
+        check(!tracker.can_accept("192.168.0.1"), "SA-15: the global cap refuses new IPs too");
+        check(tracker.open_total() == 4, "SA-15: the global count tracks acquisitions");
+
+        tracker.released("10.0.0.2");
+        check(tracker.can_accept("192.168.0.1"), "SA-15: a release frees the global slot");
+
+        // Unknown clients count globally but cannot trip the per-IP cap.
+        var anon = new ConnectionTracker(1, 2);
+        check(anon.can_accept(null), "SA-15: unknown client is accepted when under the global cap");
+        anon.acquired(null);
+        check(!anon.can_accept(null), "SA-15: unknown clients still respect the global cap");
+        check(anon.open_for(null) == 0, "SA-15: unknown client is not counted per-IP");
+    }
+
+    /** SA-15: subscriptions per channel are bounded. */
+    void test_subscription_cap(ChannelEndpoint channel) {
+        // subscribe() only acts on channels with an open connection; without
+        // one it must simply no-op (returning false) rather than crash.
+        bool recorded = channel.subscribe("nonexistent-channel", "some-key");
+        check(!recorded, "SA-15: subscribing to a channel with no connection is a no-op");
+    }
+
+    /** SA-6: the authorize hooks default open and honour installed guards. */
+    void test_authorization_policy() {
+        var policy = new AuthorizationPolicy();
+        check(policy.authorize_entrypoint(null) == null, "SA-6: entrypoint is open when no guard is installed");
+        check(policy.authorize_channel(null) == null, "SA-6: channel is open when no guard is installed");
+        check(policy.authorize_channel_subscriptions(null) == null, "SA-6: channel subscriptions are open when no guard is installed");
+
+        var denied = new HttpStringResult("login required", StatusCode.UNAUTHORIZED);
+        policy.guard_channel(ctx => denied);
+        var decision = policy.authorize_channel(null);
+        check(decision != null && ((!)decision).status == StatusCode.UNAUTHORIZED,
+            "SA-6: an installed channel guard short-circuits with its result");
+        check(policy.authorize_entrypoint(null) == null, "SA-6: the channel guard does not leak into the entrypoint");
+
+        policy.guard_entrypoint(ctx => null);
+        check(policy.authorize_entrypoint(null) == null, "SA-6: a guard returning null allows the request");
+    }
+
     void main() {
         GObjectMapping.register_mapper<Model.ActionDto>(Model.ActionDto.get_mapper());
 
@@ -183,6 +376,9 @@ namespace Statum.Tests {
         var scope = container.create_transient_scope();
 
         var encryption = (Statum.Cryptography.EncryptionProvider) scope.resolve_type(typeof(Statum.Cryptography.EncryptionProvider));
+        var signing = (Statum.Cryptography.SigningProvider) scope.resolve_type(typeof(Statum.Cryptography.SigningProvider));
+        var states = (StateService) scope.resolve_type(typeof(StateService));
+        var channel = (ChannelEndpoint) scope.resolve_type(typeof(ChannelEndpoint));
 
         try {
             test_scalar_public_path();
@@ -208,6 +404,36 @@ namespace Statum.Tests {
             failures++;
             print("FAIL typed action private threw: %s\n", e.message);
         }
+        try {
+            test_restore_slot_expiry(states, signing);
+        } catch (GLib.Error e) {
+            failures++;
+            print("FAIL restore_slot expiry threw: %s\n", e.message);
+        }
+        try {
+            test_verify_frame_signer(signing, new Statum.Cryptography.SigningProvider());
+        } catch (GLib.Error e) {
+            failures++;
+            print("FAIL verify_frame signer threw: %s\n", e.message);
+        }
+        try {
+            test_connection_caps();
+        } catch (GLib.Error e) {
+            failures++;
+            print("FAIL connection caps threw: %s\n", e.message);
+        }
+        try {
+            test_subscription_cap(channel);
+        } catch (GLib.Error e) {
+            failures++;
+            print("FAIL subscription cap threw: %s\n", e.message);
+        }
+        try {
+            test_authorization_policy();
+        } catch (GLib.Error e) {
+            failures++;
+            print("FAIL authorization policy threw: %s\n", e.message);
+        }
 
         print("---- %d passed, %d failed ----\n", passes, failures);
         if (failures > 0) {

+ 1 - 1
tools/statum-genkeys/meson.build

@@ -4,6 +4,6 @@ statum_genkeys_sources = files(
 
 executable('statum-genkeys',
     statum_genkeys_sources,
-    dependencies: [glib_dep, gobject_dep, sodium_deps],
+    dependencies: [glib_dep, gobject_dep, dependency('gio-2.0'), sodium_deps],
     install: true
 )

+ 107 - 6
tools/statum-genkeys/statum-genkeys.vala

@@ -4,15 +4,54 @@ namespace Statum.Tools {
 
     /**
      * `statum-genkeys` — generates the static key pairs for signing and
-     * encryption and prints a ready-to-paste `statum` section for
-     * `web-config.json`.
+     * encryption for the `statum` section of `web-config.json`.
      *
      * With these keys configured, signed frames and encrypted private blobs
      * survive a server restart (the same keys verify/decrypt across restarts).
+     *
+     * The secret halves are key material. By default they are never written to
+     * stdout (shell history, terminal scrollback, CI logs and `2>&1 | tee`
+     * captures would all acquire them — SA-12): stdout carries only the PUBLIC
+     * halves, and `--out <file>` writes the full config — public and secret
+     * halves — directly to `<file>` with mode 0600.
+     *
+     * `--out <file>` writes the ready-to-use `web-config.json` (creating
+     * parent directories as needed). Use it instead of shell redirection:
+     *
+     * ```bash
+     * statum-genkeys --out web-config.json
+     * ```
      */
     public class Genkeys : Object {
 
+        private const string USAGE =
+            "usage: statum-genkeys [--out <file>]\n" +
+            "\n" +
+            "Generates the static signing/encryption key pairs for web-config.json.\n" +
+            "Without --out, prints only the PUBLIC key halves to stdout (SA-12: secret\n" +
+            "halves are never printed). With --out, writes the full config — public and\n" +
+            "secret halves — to <file> with mode 0600.\n";
+
         public static int main(string[] args) {
+            string? out_path = null;
+
+            for (int i = 1; i < args.length; i++) {
+                var arg = args[i];
+                if (arg == "--out" || arg == "-o") {
+                    if (i + 1 >= args.length) {
+                        stderr.printf("statum-genkeys: --out requires a file argument\n%s", USAGE);
+                        return 2;
+                    }
+                    out_path = args[++i];
+                } else if (arg == "--help" || arg == "-h") {
+                    stdout.printf("%s", USAGE);
+                    return 0;
+                } else {
+                    stderr.printf("statum-genkeys: unknown argument \"%s\"\n%s", arg, USAGE);
+                    return 2;
+                }
+            }
+
             // Signing (Ed25519) key pair.
             var sign_sk = new uint8[Sodium.Asymmetric.Signing.SECRET_KEY_BYTES];
             var sign_pk = new uint8[Sodium.Asymmetric.Signing.PUBLIC_KEY_BYTES];
@@ -28,18 +67,80 @@ namespace Statum.Tools {
             var seal_pk = new uint8[Sodium.Asymmetric.Sealing.PUBLIC_KEY_BYTES];
             Sodium.Asymmetric.Sealing.generate_keypair(seal_pk, seal_sk);
 
+            if (out_path != null) {
+                // Full config (secrets included) goes to the file only, mode 0600.
+                var full = new StringBuilder();
+                full.append("{\n");
+                full.append("  \"statum\": {\n");
+                full.append_printf("    \"signing_secret_key\": \"%s\",\n", Base64.encode(sign_sk));
+                full.append_printf("    \"signing_public_key\": \"%s\",\n", Base64.encode(sign_pk));
+                full.append_printf("    \"encryption_signing_secret_key\": \"%s\",\n", Base64.encode(esign_sk));
+                full.append_printf("    \"encryption_signing_public_key\": \"%s\",\n", Base64.encode(esign_pk));
+                full.append_printf("    \"encryption_sealing_secret_key\": \"%s\",\n", Base64.encode(seal_sk));
+                full.append_printf("    \"encryption_sealing_public_key\": \"%s\"\n", Base64.encode(seal_pk));
+                full.append("  }\n");
+                full.append("}\n");
+
+                if (!write_private_file((!)out_path, full.str)) {
+                    return 1;
+                }
+
+                stdout.printf("Wrote full key material to %s (mode 0600).\n", (!)out_path);
+                stdout.printf("The secret halves are in %s only — they are never printed to stdout.\n", (!)out_path);
+                print_public_halves(sign_pk, esign_pk, seal_pk);
+                return 0;
+            }
+
+            // No --out: keep the JSON shape scriptable, but carry PUBLIC halves
+            // only. Anything that parsed this output for secrets must use
+            // --out (see SA-12).
             stdout.printf("{\n");
             stdout.printf("  \"statum\": {\n");
-            stdout.printf("    \"signing_secret_key\": \"%s\",\n", Base64.encode(sign_sk));
             stdout.printf("    \"signing_public_key\": \"%s\",\n", Base64.encode(sign_pk));
-            stdout.printf("    \"encryption_signing_secret_key\": \"%s\",\n", Base64.encode(esign_sk));
             stdout.printf("    \"encryption_signing_public_key\": \"%s\",\n", Base64.encode(esign_pk));
-            stdout.printf("    \"encryption_sealing_secret_key\": \"%s\",\n", Base64.encode(seal_sk));
             stdout.printf("    \"encryption_sealing_public_key\": \"%s\"\n", Base64.encode(seal_pk));
             stdout.printf("  }\n");
             stdout.printf("}\n");
-
+            stdout.printf("# Secret key halves are not printed. Use:\n");
+            stdout.printf("#   statum-genkeys --out <file>\n");
+            stdout.printf("# to write the full config (public + secret) at mode 0600.\n");
             return 0;
         }
+
+        private static void print_public_halves(uint8[] sign_pk, uint8[] esign_pk, uint8[] seal_pk) {
+            stdout.printf("signing_public_key:                 %s\n", Base64.encode(sign_pk));
+            stdout.printf("encryption_signing_public_key:     %s\n", Base64.encode(esign_pk));
+            stdout.printf("encryption_sealing_public_key:     %s\n", Base64.encode(seal_pk));
+        }
+
+        /**
+         * Writes `contents` to `path` with mode 0600, creating parent
+         * directories as needed. `File.replace` gives a fresh file, so the
+         * restrictive mode is applied immediately after the write.
+         */
+        private static bool write_private_file(string path, string contents) {
+            try {
+                var file = File.new_for_path(path);
+                var parent = file.get_parent();
+                if (parent != null && !((!)parent).query_exists()) {
+                    try {
+                        ((!)parent).make_directory_with_parents(null);
+                    } catch (GLib.Error e) {
+                        if (!file.query_exists()) {
+                            throw e;
+                        }
+                    }
+                }
+                var stream = new DataOutputStream(file.replace(null, false, FileCreateFlags.NONE));
+                stream.put_string(contents);
+                stream.close();
+                file.set_attribute_uint32(FileAttribute.UNIX_MODE, 0600,
+                    FileQueryInfoFlags.NONE, null);
+                return true;
+            } catch (GLib.Error e) {
+                stderr.printf("statum-genkeys: %s\n", e.message);
+                return false;
+            }
+        }
     }
 }