clanker 2 minggu lalu
induk
melakukan
3e83976389

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+builddir/

+ 30 - 0
meson.build

@@ -0,0 +1,30 @@
+project('statum', ['c', 'vala'],
+  version: '0.1',
+)
+
+vapi_dir = join_paths(meson.current_source_dir(), 'vapi')
+
+# Dependencies
+glib_dep = dependency('glib-2.0')
+gobject_dep = dependency('gobject-2.0')
+gio_dep = dependency('gio-2.0')
+gee_dep = dependency('gee-0.8')
+invercargill_dep = dependency('invercargill-1')
+inversion_dep = dependency('inversion-0.1')
+astralis_dep = dependency('astralis-0.1')
+json_glib_dep = dependency('json-glib-1.0')
+libxml_dep = dependency('libxml-2.0')
+invercargill_json_dep = dependency('invercargill-json')
+invercargill_sql_dep = dependency('invercargill-sql', required: true)
+invercargill_sql_inversion_dep = dependency('invercargill-sql-inversion', required: true)
+sqlite_dep = dependency('sqlite3')
+
+# libsodium is consumed via a bundled VAPI (libsodium does not ship one).
+sodium_vapi = files('vapi/libsodium.vapi')
+sodium_c_lib = meson.get_compiler('c').find_library('sodium', required: true)
+sodium_deps = declare_dependency(sources: sodium_vapi, dependencies: sodium_c_lib)
+
+# VAPI Directory
+add_project_arguments(['--vapidir', vapi_dir], language: 'vala')
+
+subdir('src')

+ 131 - 0
src/Cryptography/EncryptionProvider.vala

@@ -0,0 +1,131 @@
+using Invercargill;
+using InvercargillJson;
+
+namespace Statum.Cryptography {
+
+    /**
+     * Error domain for encryption operations.
+     */
+    public errordomain EncryptionError {
+        /** The blob could not be decrypted (e.g. corrupt or wrong key). */
+        DECRYPTION_FAILED,
+        /** The decrypted blob's signature did not verify. */
+        SIGNATURE_INVALID,
+        /** The blob was valid but was authored under a different namespace. */
+        NAMESPACE_MISMATCH,
+    }
+
+    /**
+     * Encrypts and decrypts the opaque `private` payloads carried by Statum
+     * DTOs (e.g. {@link Statum.Model.SnapshotDto.private} and
+     * {@link Statum.Model.ActionDto.private}).
+     *
+     * Data is "only readable by the application server": payloads are serialised
+     * to JSON, signed (Ed25519) and sealed (X25519-Seal) using libsodium, then
+     * returned as a base64 string suitable for embedding in a DTO. The reverse
+     * operation unseals, verifies the signature and re-parses the JSON.
+     *
+     * A `namespace` is mixed into the signed payload to prevent
+     * cross-protocol confusion (a snapshot-private blob cannot be read back as
+     * an action-private blob, and vice-versa). Inspired by Spry's
+     * `CryptographyProvider`.
+     */
+    public class EncryptionProvider : GLib.Object {
+
+        /** Default namespace used when none is supplied. */
+        public const string DEFAULT_NAMESPACE = "statum";
+
+        private uint8[] signing_secret_key;
+        private uint8[] signing_public_key;
+        private uint8[] sealing_secret_key;
+        private uint8[] sealing_public_key;
+
+        construct {
+            signing_secret_key = new uint8[Sodium.Asymmetric.Signing.SECRET_KEY_BYTES];
+            signing_public_key = new uint8[Sodium.Asymmetric.Signing.PUBLIC_KEY_BYTES];
+            Sodium.Asymmetric.Signing.generate_keypair(signing_public_key, signing_secret_key);
+
+            sealing_secret_key = new uint8[Sodium.Asymmetric.Sealing.SECRET_KEY_BYTES];
+            sealing_public_key = new uint8[Sodium.Asymmetric.Sealing.PUBLIC_KEY_BYTES];
+            Sodium.Asymmetric.Sealing.generate_keypair(sealing_public_key, sealing_secret_key);
+        }
+
+        /**
+         * Serialises a {@link Properties} object to compact JSON, signs and
+         * seals it, and returns the opaque base64 string for a DTO's `private`
+         * field.
+         *
+         * @param namespace The namespace to bind the payload to (defaults to
+         *        {@link DEFAULT_NAMESPACE}).
+         * @param data The structured private data to encrypt.
+         * @return A base64-encoded encrypted blob.
+         */
+        public string author_properties(string namespace, Properties data) throws GLib.Error {
+            var json = new JsonElement.from_properties(data).stringify();
+            return author_string(namespace, json);
+        }
+
+        /**
+         * Signs and seals an arbitrary string payload and returns the opaque
+         * base64 string.
+         *
+         * @param namespace The namespace to bind the payload to.
+         * @param data The plaintext to encrypt.
+         * @return A base64-encoded encrypted blob.
+         */
+        public string author_string(string namespace, string data) {
+            var namespaced = @"$namespace:$data";
+            var signed = Sodium.Asymmetric.Signing.sign(namespaced.data, signing_secret_key);
+            var sealed = Sodium.Asymmetric.Sealing.seal(signed, sealing_public_key);
+            return Base64.encode(sealed);
+        }
+
+        /**
+         * Decrypts a base64 blob, verifies it, and re-parses the JSON back into
+         * a {@link Properties} object.
+         *
+         * @param namespace The namespace the blob is expected to carry.
+         * @param blob The base64-encoded encrypted blob.
+         * @return The decrypted structured private data.
+         * @throws EncryptionError if decryption, signature verification or
+         *         namespace validation fails.
+         */
+        public Properties read_properties(string namespace, string blob) throws GLib.Error {
+            var json = read_string(namespace, blob);
+            return new JsonElement.from_string(json).as<Properties>();
+        }
+
+        /**
+         * Decrypts and verifies a base64 blob, returning the original plaintext
+         * string.
+         *
+         * @param namespace The namespace the blob is expected to carry.
+         * @param blob The base64-encoded encrypted blob.
+         * @return The decrypted plaintext.
+         * @throws EncryptionError if decryption, signature verification or
+         *         namespace validation fails.
+         */
+        public string read_string(string namespace, string blob) throws EncryptionError {
+            var bytes = Base64.decode(blob);
+            var signed = Sodium.Asymmetric.Sealing.unseal(bytes, sealing_public_key, sealing_secret_key);
+            if (signed == null) {
+                throw new EncryptionError.DECRYPTION_FAILED("Could not decrypt private blob");
+            }
+
+            var cleartext = Sodium.Asymmetric.Signing.verify((!)signed, signing_public_key);
+            if (cleartext == null) {
+                throw new EncryptionError.SIGNATURE_INVALID("Signature verification failed for private blob");
+            }
+
+            var namespaced = Wrap.byte_array((!)cleartext).to_raw_string();
+            var prefix = @"$namespace:";
+            if (!namespaced.has_prefix(prefix)) {
+                throw new EncryptionError.NAMESPACE_MISMATCH(@"Expected namespace \"$namespace\" but the blob carries a different namespace");
+            }
+
+            return namespaced.substring(prefix.length);
+        }
+
+    }
+
+}

+ 112 - 0
src/Cryptography/SigningProvider.vala

@@ -0,0 +1,112 @@
+using Statum.Model;
+
+namespace Statum.Cryptography {
+
+    /**
+     * Error domain for signing operations.
+     */
+    public errordomain SigningError {
+        /** A frame signature did not verify against its content. */
+        INVALID_SIGNATURE,
+    }
+
+    /**
+     * Signs and verifies the integrity of Statum {@link FrameDto}s using
+     * detached Ed25519 signatures (libsodium `crypto_sign_detached`).
+     *
+     * A frame's {@link FrameDto.signature} is a base64-encoded detached
+     * signature over its {@link FrameDto.content}, produced by the signer
+     * identified by {@link FrameDto.signer}. This provider owns a single
+     * 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.
+     */
+    public class SigningProvider : GLib.Object {
+
+        private uint8[] secret_key;
+        private uint8[] public_key;
+
+        /**
+         * The signer id written into the {@link Frame.signer} field of frames
+         * authored by this provider.
+         */
+        public string signer_id { get; set; }
+
+        construct {
+            secret_key = new uint8[Sodium.Asymmetric.Signing.SECRET_KEY_BYTES];
+            public_key = new uint8[Sodium.Asymmetric.Signing.PUBLIC_KEY_BYTES];
+            Sodium.Asymmetric.Signing.generate_keypair(public_key, secret_key);
+            signer_id = Base64.encode(public_key);
+        }
+
+        /**
+         * Produces a base64-encoded detached signature over `content`.
+         *
+         * @param content The literal frame content to sign.
+         * @return The base64-encoded signature, suitable for
+         *         {@link FrameDto.signature}.
+         */
+        public string sign(string content) {
+            var signature = Sodium.Asymmetric.Signing.sign_detached(content.data, secret_key);
+            return Base64.encode(signature);
+        }
+
+        /**
+         * Verifies a base64-encoded detached signature against `content` using
+         * this provider's public key.
+         *
+         * @return `true` if the signature is valid for `content`.
+         */
+        public bool verify(string content, string signature_b64) {
+            return verify_with_key(content, signature_b64, public_key);
+        }
+
+        /**
+         * Verifies a base64-encoded detached signature against `content` using
+         * an arbitrary Ed25519 public key (e.g. the key of a different signer).
+         *
+         * @return `true` if the signature is valid for `content`.
+         */
+        public bool verify_with_key(string content, string signature_b64, uint8[] verification_public_key) {
+            var signature = Base64.decode(signature_b64);
+            return Sodium.Asymmetric.Signing.verify_detached(signature, content.data, verification_public_key);
+        }
+
+        /**
+         * Authors a {@link FrameDto}: signs `content` with this provider's key
+         * and fills in the {@link FrameDto.signer} and {@link FrameDto.signature}
+         * fields.
+         *
+         * @param frame The frame whose content should be signed. Its
+         *        {@link FrameDto.content} must already be set; the `signer` and
+         *        `signature` fields are overwritten.
+         */
+        public void author_frame(FrameDto frame) {
+            frame.signer = signer_id;
+            frame.signature = sign(frame.content);
+        }
+
+        /**
+         * Verifies a frame's signature against its content using this
+         * provider's public key.
+         *
+         * @throws SigningError.INVALID_SIGNATURE if the signature does not
+         *         verify.
+         */
+        public void verify_frame(FrameDto frame) throws SigningError {
+            if (!verify(frame.content, frame.signature)) {
+                throw new SigningError.INVALID_SIGNATURE(@"Signature for frame signed by \"$(frame.signer)\" is invalid");
+            }
+        }
+
+        /**
+         * The Ed25519 public key bytes that correspond to this provider's
+         * secret key, for distribution to verifiers.
+         */
+        public uint8[] get_public_key() {
+            return public_key;
+        }
+
+    }
+
+}

+ 47 - 0
src/Model/ActionDto.vala

@@ -0,0 +1,47 @@
+using Invercargill;
+using Invercargill.Mapping;
+
+namespace Statum.Model {
+
+    /**
+     * A Statum action.
+     *
+     * An action describes an HTTP request the frontend can perform. Actions are
+     * embedded (by path) inside a snapshot's {@link SnapshotDto.public} data and
+     * invoked via `stm-action`. The response to an action is a JSON array of
+     * {@link DirectiveDto}s.
+     */
+    public class ActionDto : Object {
+
+        /** The URI to request. */
+        public string uri { get; set; }
+
+        /** The HTTP verb to use. */
+        public string method { get; set; }
+
+        /**
+         * Base64-encoded data only readable by the application server (see
+         * {@link Statum.Cryptography.EncryptionProvider}), carried to the server
+         * via the `X-Statum-Private` header, or `null`/undefined if the action
+         * carries no private payload.
+         */
+        public string? @private { get; set; }
+
+        /**
+         * The {@link PropertyMapper} used to (de)serialise an {@link ActionDto}.
+         */
+        public static PropertyMapper<ActionDto> get_mapper() {
+            return PropertyMapper.build_for<ActionDto>(cfg => {
+                cfg.map<string>("uri", o => o.uri, (o, v) => o.uri = v);
+                cfg.map<string>("method", o => o.method, (o, v) => o.method = v);
+                cfg.map<string?>("private", o => o.@private, (o, v) => o.@private = v)
+                    .undefined_when(o => o.@private == null)
+                    .when_undefined(o => o.@private = null)
+                    .when_null(o => o.@private = null);
+                cfg.set_constructor(() => new ActionDto());
+            });
+        }
+
+    }
+
+}

+ 363 - 0
src/Model/DirectiveDto.vala

@@ -0,0 +1,363 @@
+using Gee;
+using Invercargill;
+using Invercargill.Mapping;
+using InvercargillJson;
+
+namespace Statum.Model {
+
+    /**
+     * A Statum directive.
+     *
+     * Directives are the commands the server returns to the frontend (as a
+     * JSON array served with the `application/vnd.statum+json` content type).
+     * Each directive is a JSON object discriminated by its `type` member.
+     *
+     * This base class is abstract; use one of the concrete subclasses and the
+     * {@link from_json} / {@link to_json} helpers (or the array helpers) to move
+     * between {@link DirectiveDto} instances and their JSON representation.
+     */
+    public abstract class DirectiveDto : Object {
+
+        /** The wire `type` value that discriminates this directive. */
+        public abstract string discriminator { get; }
+
+        /**
+         * Returns the directive's own field properties (excluding `type`),
+         * used to assemble the JSON object. Subclasses back this with their
+         * {@link PropertyMapper}.
+         */
+        protected abstract Properties build_field_properties() throws GLib.Error;
+
+        /**
+         * Serialises this directive to a {@link JsonObject} (including the
+         * `type` discriminator).
+         */
+        public JsonObject to_json_object() throws GLib.Error {
+            var obj = new JsonObject();
+            obj.set_native<string>("type", discriminator);
+            foreach (var field in build_field_properties()) {
+                obj.set(field.key, new JsonElement.from_element(field.value));
+            }
+            return obj;
+        }
+
+        /**
+         * Serialises this directive to a {@link JsonElement} (an object).
+         */
+        public JsonElement to_json() throws GLib.Error {
+            return to_json_object().as_element();
+        }
+
+        /**
+         * Deserialises a directive from a {@link JsonObject} by dispatching on
+         * its `type` member.
+         *
+         * @throws ModelError.UNKNOWN_DIRECTIVE if `type` is not recognised.
+         */
+        public static DirectiveDto from_json(JsonObject json) throws GLib.Error {
+            var type = json.get_string("type");
+            if (type == null) {
+                throw new ModelError.UNKNOWN_DIRECTIVE("Directive is missing its \"type\" member");
+            }
+            switch ((!)type) {
+                case "navigate": return NavigateDirectiveDto.get_mapper().materialise(json);
+                case "post": return PostDirectiveDto.get_mapper().materialise(json);
+                case "set": return SetDirectiveDto.get_mapper().materialise(json);
+                case "clear": return ClearDirectiveDto.get_mapper().materialise(json);
+                case "error": return ErrorDirectiveDto.get_mapper().materialise(json);
+                case "subscribe": return SubscribeDirectiveDto.get_mapper().materialise(json);
+                case "unsubscribe": return UnsubscribeDirectiveDto.get_mapper().materialise(json);
+                case "notify": return NotifyDirectiveDto.get_mapper().materialise(json);
+                default:
+                    throw new ModelError.UNKNOWN_DIRECTIVE(@"Unrecognised directive type \"$((!)type)\"");
+            }
+        }
+
+        /**
+         * Serialises a list of directives to the JSON array form used by every
+         * directive-returning endpoint.
+         */
+        public static JsonElement serialize_array(Gee.List<DirectiveDto> directives) throws GLib.Error {
+            var array = new JsonArray();
+            foreach (var directive in directives) {
+                array.add(directive.to_json_object().as_element());
+            }
+            return new JsonElement.from_elements(array);
+        }
+
+        /**
+         * Deserialises a JSON array of directives.
+         */
+        public static Gee.List<DirectiveDto> deserialize_array(JsonElement element) throws GLib.Error {
+            var result = new ArrayList<DirectiveDto>();
+            var array = element.as<JsonArray>();
+            foreach (var item in array) {
+                result.add(from_json(item.as<JsonObject>()));
+            }
+            return result;
+        }
+
+    }
+
+    /**
+     * Redirects the frontend to a new URI.
+     */
+    public class NavigateDirectiveDto : DirectiveDto {
+
+        /** The URI to navigate to. */
+        public string uri { get; set; }
+
+        public override string discriminator { get { return "navigate"; } }
+
+        protected override Properties build_field_properties() throws GLib.Error {
+            return NavigateDirectiveDto.get_mapper().map_from(this);
+        }
+
+        public NavigateDirectiveDto() { }
+
+        public NavigateDirectiveDto.with_uri(string uri) {
+            this.uri = uri;
+        }
+
+        public static PropertyMapper<NavigateDirectiveDto> get_mapper() {
+            return PropertyMapper.build_for<NavigateDirectiveDto>(cfg => {
+                cfg.map<string>("uri", o => o.uri, (o, v) => o.uri = v);
+                cfg.set_constructor(() => new NavigateDirectiveDto());
+            });
+        }
+
+    }
+
+    /**
+     * Performs a full-page form POST navigation to a URI. A post directive is
+     * always executed after all other directives in the same response.
+     */
+    public class PostDirectiveDto : DirectiveDto {
+
+        /** The URI to perform a form post to. */
+        public string uri { get; set; }
+
+        /** Key/value pairs for the form data. */
+        public Properties data { get; set; }
+
+        public override string discriminator { get { return "post"; } }
+
+        protected override Properties build_field_properties() throws GLib.Error {
+            return PostDirectiveDto.get_mapper().map_from(this);
+        }
+
+        public PostDirectiveDto() { }
+
+        public PostDirectiveDto.with(string uri, Properties data) {
+            this.uri = uri;
+            this.data = data;
+        }
+
+        public static PropertyMapper<PostDirectiveDto> get_mapper() {
+            return PropertyMapper.build_for<PostDirectiveDto>(cfg => {
+                cfg.map<string>("uri", o => o.uri, (o, v) => o.uri = v);
+                cfg.map<Properties>("data", o => o.data, (o, v) => o.data = v);
+                cfg.set_constructor(() => new PostDirectiveDto());
+            });
+        }
+
+    }
+
+    /**
+     * Sets a slot to a new snapshot (wrapped in a frame). Set directives are
+     * idempotent on the client, keyed by the frame signature.
+     */
+    public class SetDirectiveDto : DirectiveDto {
+
+        /** A frame containing a snapshot. */
+        public FrameDto snapshot_frame { get; set; }
+
+        public override string discriminator { get { return "set"; } }
+
+        protected override Properties build_field_properties() throws GLib.Error {
+            return SetDirectiveDto.get_mapper().map_from(this);
+        }
+
+        public SetDirectiveDto() { }
+
+        public SetDirectiveDto.with_frame(FrameDto snapshot_frame) {
+            this.snapshot_frame = snapshot_frame;
+        }
+
+        public static PropertyMapper<SetDirectiveDto> get_mapper() {
+            return PropertyMapper.build_for<SetDirectiveDto>(cfg => {
+                cfg.map_properties_with<FrameDto>("snapshot_frame",
+                    o => o.snapshot_frame,
+                    (o, v) => o.snapshot_frame = v,
+                    FrameDto.get_mapper());
+                cfg.set_constructor(() => new SetDirectiveDto());
+            });
+        }
+
+    }
+
+    /**
+     * Clears a slot.
+     */
+    public class ClearDirectiveDto : DirectiveDto {
+
+        /** The key of the slot to clear. */
+        public string key { get; set; }
+
+        public override string discriminator { get { return "clear"; } }
+
+        protected override Properties build_field_properties() throws GLib.Error {
+            return ClearDirectiveDto.get_mapper().map_from(this);
+        }
+
+        public ClearDirectiveDto() { }
+
+        public ClearDirectiveDto.with_key(string key) {
+            this.key = key;
+        }
+
+        public static PropertyMapper<ClearDirectiveDto> get_mapper() {
+            return PropertyMapper.build_for<ClearDirectiveDto>(cfg => {
+                cfg.map<string>("key", o => o.key, (o, v) => o.key = v);
+                cfg.set_constructor(() => new ClearDirectiveDto());
+            });
+        }
+
+    }
+
+    /**
+     * Reports an error to the frontend. Causes the originating `statum.act`
+     * promise to reject.
+     */
+    public class ErrorDirectiveDto : DirectiveDto {
+
+        /** A human-readable error message, if any. */
+        public string? message { get; set; }
+
+        /** An application-defined error code, if any. */
+        public string? code { get; set; }
+
+        /** Additional key/value error details, if any. */
+        public Properties? data { get; set; }
+
+        public override string discriminator { get { return "error"; } }
+
+        protected override Properties build_field_properties() throws GLib.Error {
+            return ErrorDirectiveDto.get_mapper().map_from(this);
+        }
+
+        public ErrorDirectiveDto() { }
+
+        public static PropertyMapper<ErrorDirectiveDto> get_mapper() {
+            return PropertyMapper.build_for<ErrorDirectiveDto>(cfg => {
+                cfg.map<string?>("message", o => o.message, (o, v) => o.message = v)
+                    .undefined_when(o => o.message == null)
+                    .when_undefined(o => o.message = null)
+                    .when_null(o => o.message = null);
+                cfg.map<string?>("code", o => o.code, (o, v) => o.code = v)
+                    .undefined_when(o => o.code == null)
+                    .when_undefined(o => o.code = null)
+                    .when_null(o => o.code = null);
+                cfg.map<Properties?>("data", o => o.data, (o, v) => o.data = v)
+                    .undefined_when(o => o.data == null)
+                    .when_undefined(o => o.data = null)
+                    .when_null(o => o.data = null);
+                cfg.set_constructor(() => new ErrorDirectiveDto());
+            });
+        }
+
+    }
+
+    /**
+     * Subscribes the client's realtime channel to a slot.
+     */
+    public class SubscribeDirectiveDto : DirectiveDto {
+
+        /** The key of the slot to subscribe to. */
+        public string key { get; set; }
+
+        public override string discriminator { get { return "subscribe"; } }
+
+        protected override Properties build_field_properties() throws GLib.Error {
+            return SubscribeDirectiveDto.get_mapper().map_from(this);
+        }
+
+        public SubscribeDirectiveDto() { }
+
+        public SubscribeDirectiveDto.with_key(string key) {
+            this.key = key;
+        }
+
+        public static PropertyMapper<SubscribeDirectiveDto> get_mapper() {
+            return PropertyMapper.build_for<SubscribeDirectiveDto>(cfg => {
+                cfg.map<string>("key", o => o.key, (o, v) => o.key = v);
+                cfg.set_constructor(() => new SubscribeDirectiveDto());
+            });
+        }
+
+    }
+
+    /**
+     * Unsubscribes the client's realtime channel from a slot.
+     */
+    public class UnsubscribeDirectiveDto : DirectiveDto {
+
+        /** The key of the slot to unsubscribe from. */
+        public string key { get; set; }
+
+        public override string discriminator { get { return "unsubscribe"; } }
+
+        protected override Properties build_field_properties() throws GLib.Error {
+            return UnsubscribeDirectiveDto.get_mapper().map_from(this);
+        }
+
+        public UnsubscribeDirectiveDto() { }
+
+        public UnsubscribeDirectiveDto.with_key(string key) {
+            this.key = key;
+        }
+
+        public static PropertyMapper<UnsubscribeDirectiveDto> get_mapper() {
+            return PropertyMapper.build_for<UnsubscribeDirectiveDto>(cfg => {
+                cfg.map<string>("key", o => o.key, (o, v) => o.key = v);
+                cfg.set_constructor(() => new UnsubscribeDirectiveDto());
+            });
+        }
+
+    }
+
+    /**
+     * Surfaces a transient notification to the user.
+     */
+    public class NotifyDirectiveDto : DirectiveDto {
+
+        /** An application-defined notification category string (e.g. "info"). */
+        public string kind { get; set; }
+
+        /** A human-readable message string. */
+        public string message { get; set; }
+
+        public override string discriminator { get { return "notify"; } }
+
+        protected override Properties build_field_properties() throws GLib.Error {
+            return NotifyDirectiveDto.get_mapper().map_from(this);
+        }
+
+        public NotifyDirectiveDto() { }
+
+        public NotifyDirectiveDto.with(string kind, string message) {
+            this.kind = kind;
+            this.message = message;
+        }
+
+        public static PropertyMapper<NotifyDirectiveDto> get_mapper() {
+            return PropertyMapper.build_for<NotifyDirectiveDto>(cfg => {
+                cfg.map<string>("kind", o => o.kind, (o, v) => o.kind = v);
+                cfg.map<string>("message", o => o.message, (o, v) => o.message = v);
+                cfg.set_constructor(() => new NotifyDirectiveDto());
+            });
+        }
+
+    }
+
+}

+ 49 - 0
src/Model/FrameDto.vala

@@ -0,0 +1,49 @@
+using Invercargill;
+using Invercargill.Mapping;
+
+namespace Statum.Model {
+
+    /**
+     * A Statum frame.
+     *
+     * A frame wraps an opaque `content` string together with the `signer` id of
+     * the party that authored it and a base64-encoded detached `signature`
+     * over that content.
+     *
+     * Frames are the unit of integrity for Statum: snapshots (and other
+     * content) are carried inside a frame so their authenticity can be
+     * verified before they are trusted by the server.
+     */
+    public class FrameDto : Object {
+
+        /**
+         * The literal content carried by the frame.
+         *
+         * For a snapshot this is the serialised {@link SnapshotDto} object.
+         */
+        public string content { get; set; }
+
+        /** The identifier of the signer that produced {@link signature}. */
+        public string signer { get; set; }
+
+        /**
+         * A base64-encoded detached Ed25519 signature over {@link content},
+         * produced by {@link Statum.Cryptography.SigningProvider}.
+         */
+        public string signature { get; set; }
+
+        /**
+         * The {@link PropertyMapper} used to (de)serialise a {@link FrameDto}.
+         */
+        public static PropertyMapper<FrameDto> get_mapper() {
+            return PropertyMapper.build_for<FrameDto>(cfg => {
+                cfg.map<string>("content", o => o.content, (o, v) => o.content = v);
+                cfg.map<string>("signer", o => o.signer, (o, v) => o.signer = v);
+                cfg.map<string>("signature", o => o.signature, (o, v) => o.signature = v);
+                cfg.set_constructor(() => new FrameDto());
+            });
+        }
+
+    }
+
+}

+ 78 - 0
src/Model/Scope.vala

@@ -0,0 +1,78 @@
+namespace Statum.Model {
+
+    /**
+     * The persistence scope of a {@link Slot}.
+     *
+     * Controls how (and whether) the frontend persists a slot's snapshot
+     * across navigations, tabs and browser sessions.
+     *
+     * See `model.md` for the client-side backing store used by each scope.
+     */
+    public enum Scope {
+        /** Persists between browser sessions, backed by `localStorage`. */
+        DEVICE,
+        /** Persists within the browser session, backed by `localStorage`. */
+        SESSION,
+        /** Persists within the current tab across navigations (`sessionStorage`). */
+        FLOW,
+        /** Persists only while the current page is loaded (held in memory). */
+        PAGE,
+        /** Never persists and is never sent back to the server (held in memory). */
+        TRANSIENT;
+
+        /**
+         * The wire representation of the scope (lower-cased scope name).
+         */
+        public string to_string() {
+            switch (this) {
+                case DEVICE: return "device";
+                case SESSION: return "session";
+                case FLOW: return "flow";
+                case PAGE: return "page";
+                case TRANSIENT: return "transient";
+                default: assert_not_reached();
+            }
+        }
+
+        /**
+         * Parses a scope from its wire representation.
+         *
+         * @param value The lower-cased scope name.
+         * @return The matching {@link Scope}, or `null` if `value` is not a
+         *         recognised scope.
+         */
+        public static Scope? from_string(string value) {
+            switch (value.down()) {
+                case "device": return DEVICE;
+                case "session": return SESSION;
+                case "flow": return FLOW;
+                case "page": return PAGE;
+                case "transient": return TRANSIENT;
+                default: return null;
+            }
+        }
+
+        /**
+         * Parses a scope from its wire representation, throwing if it is
+         * unrecognised. Intended for use inside {@link Invercargill.Mapping}
+         * property setters.
+         */
+        public static Scope parse(string value) throws GLib.Error {
+            var scope = from_string(value);
+            if (scope == null) {
+                throw new ModelError.INVALID_SCOPE(@"Unrecognised scope \"$value\"");
+            }
+            return (!)scope;
+        }
+
+    }
+
+    /**
+     * Error domain for Statum model (de)serialisation failures.
+     */
+    public errordomain ModelError {
+        INVALID_SCOPE,
+        UNKNOWN_DIRECTIVE,
+    }
+
+}

+ 49 - 0
src/Model/SlotDto.vala

@@ -0,0 +1,49 @@
+using Invercargill;
+using Invercargill.Mapping;
+
+namespace Statum.Model {
+
+    /**
+     * A Statum slot.
+     *
+     * A slot is an addressable container for a single snapshot. Its `key` is a
+     * server-issued identifier used on the wire (in headers and directives),
+     * its `scope` controls client-side persistence, and its `slot_type` is an
+     * application-defined name under which the frontend exposes the slot's
+     * current {@link SnapshotDto.public} data.
+     *
+     * Statum assumes at most one slot per type on the client.
+     */
+    public class SlotDto : Object {
+
+        /** Server-issued identifier for the slot. */
+        public string key { get; set; }
+
+        /** Client-side persistence scope for the slot. */
+        public Scope scope { get; set; }
+
+        /**
+         * Application-defined type name for the slot.
+         *
+         * Named `slot_type` (rather than `type`) because GObject reserves a
+         * property named `type`; the JSON key remains `type` via the mapper.
+         */
+        public string slot_type { get; set; }
+
+        /**
+         * The {@link PropertyMapper} used to (de)serialise a {@link SlotDto}
+         * to/from a {@link Invercargill.Properties} object (and therefore
+         * JSON via Invercargill-Json).
+         */
+        public static PropertyMapper<SlotDto> get_mapper() {
+            return PropertyMapper.build_for<SlotDto>(cfg => {
+                cfg.map<string>("key", o => o.key, (o, v) => o.key = v);
+                cfg.map<string>("scope", o => o.scope.to_string(), (o, v) => o.scope = Scope.parse(v));
+                cfg.map<string>("type", o => o.slot_type, (o, v) => o.slot_type = v);
+                cfg.set_constructor(() => new SlotDto());
+            });
+        }
+
+    }
+
+}

+ 97 - 0
src/Model/SnapshotDto.vala

@@ -0,0 +1,97 @@
+using Invercargill;
+using Invercargill.Mapping;
+
+namespace Statum.Model {
+
+    /**
+     * A Statum snapshot.
+     *
+     * A snapshot captures an aspect of the application state at a particular
+     * point in time (`as_at`). It is usually wrapped in a {@link FrameDto} so
+     * that it can be authenticated and, if its {@link transmit_after} window
+     * has elapsed, sent back to the server.
+     */
+    public class SnapshotDto : Object {
+
+        /** ISO 8601 timestamp at which the snapshot was authored. */
+        public DateTime as_at { get; set; }
+
+        /** The slot the snapshot belongs to. */
+        public SlotDto slot { get; set; }
+
+        /**
+         * ISO 8601 timestamp after which the server cache expires and the
+         * snapshot must be re-sent, or `null` for a unidirectional snapshot
+         * that never needs to be sent to the server. When equal to
+         * {@link as_at} the snapshot must always be sent.
+         */
+        public DateTime? transmit_after { get; set; }
+
+        /**
+         * ISO 8601 timestamp after which the snapshot should be considered
+         * invalid and no longer used, or `null` if unspecified.
+         */
+        public DateTime? invalid_after { get; set; }
+
+        /**
+         * Base64-encoded data only readable by the application server (see
+         * {@link Statum.Cryptography.EncryptionProvider}), or `null` if the
+         * snapshot carries no private payload.
+         */
+        public string? @private { get; set; }
+
+        /**
+         * The user-facing, application-defined key/values that the JS and HTML
+         * APIs bind to. May also embed {@link ActionDto} references by path.
+         */
+        public Properties @public { get; set; }
+
+        /**
+         * The {@link PropertyMapper} used to (de)serialise a {@link SnapshotDto}.
+         *
+         * Timestamps are carried as ISO 8601 strings and optional fields are
+         * omitted entirely when unset.
+         */
+        public static PropertyMapper<SnapshotDto> get_mapper() {
+            return PropertyMapper.build_for<SnapshotDto>(cfg => {
+                cfg.map<string>("as_at",
+                    o => o.as_at.format_iso8601(),
+                    (o, v) => o.as_at = new DateTime.from_iso8601(v, new TimeZone.utc()));
+
+                cfg.map_properties_with<SlotDto>("slot",
+                    o => o.slot,
+                    (o, v) => o.slot = v,
+                    SlotDto.get_mapper());
+
+                cfg.map<string?>("transmit_after",
+                    o => o.transmit_after != null ? ((!)o.transmit_after).format_iso8601() : null,
+                    (o, v) => o.transmit_after = v != null ? new DateTime.from_iso8601((!)v, new TimeZone.utc()) : null)
+                    .undefined_when(o => o.transmit_after == null)
+                    .when_undefined(o => o.transmit_after = null)
+                    .when_null(o => o.transmit_after = null);
+
+                cfg.map<string?>("invalid_after",
+                    o => o.invalid_after != null ? ((!)o.invalid_after).format_iso8601() : null,
+                    (o, v) => o.invalid_after = v != null ? new DateTime.from_iso8601((!)v, new TimeZone.utc()) : null)
+                    .undefined_when(o => o.invalid_after == null)
+                    .when_undefined(o => o.invalid_after = null)
+                    .when_null(o => o.invalid_after = null);
+
+                cfg.map<string?>("private",
+                    o => o.@private,
+                    (o, v) => o.@private = v)
+                    .undefined_when(o => o.@private == null)
+                    .when_undefined(o => o.@private = null)
+                    .when_null(o => o.@private = null);
+
+                cfg.map<Properties>("public",
+                    o => o.@public,
+                    (o, v) => o.@public = v);
+
+                cfg.set_constructor(() => new SnapshotDto());
+            });
+        }
+
+    }
+
+}

+ 20 - 0
src/Statum.vala

@@ -0,0 +1,20 @@
+using Inversion;
+
+namespace Statum {
+
+    /**
+     * Inversion module that registers the core Statum framework services.
+     *
+     * Register this module with the application container to wire up the
+     * signing provider used to author and verify Statum frames.
+     */
+    public class StatumModule : Object, Module {
+
+        public void register_components(Container container) throws Error {
+            container.register_singleton<Cryptography.SigningProvider>();
+            container.register_singleton<Cryptography.EncryptionProvider>();
+        }
+
+    }
+
+}

+ 57 - 0
src/meson.build

@@ -0,0 +1,57 @@
+sources = files(
+    'Statum.vala',
+    'Model/Scope.vala',
+    'Model/SlotDto.vala',
+    'Model/FrameDto.vala',
+    'Model/SnapshotDto.vala',
+    'Model/ActionDto.vala',
+    'Model/DirectiveDto.vala',
+    'Cryptography/SigningProvider.vala',
+    'Cryptography/EncryptionProvider.vala',
+)
+
+library_version = meson.project_version()
+libstatum = shared_library('statum-@0@'.format(library_version),
+    sources,
+    dependencies: [
+        glib_dep,
+        gobject_dep,
+        gio_dep,
+        gee_dep,
+        invercargill_dep,
+        invercargill_json_dep,
+        json_glib_dep,
+        libxml_dep,
+        inversion_dep,
+        astralis_dep,
+        sodium_deps,
+        invercargill_sql_dep,
+        sqlite_dep,
+        invercargill_sql_inversion_dep,
+    ],
+    install: true,
+    vala_gir: 'statum-@0@.gir'.format(library_version),
+    install_dir: [true, true, true, true]
+)
+
+pkg = import('pkgconfig')
+pkg.generate(libstatum,
+    version: library_version,
+    name: 'statum-@0@'.format(library_version))
+
+statum_dep = declare_dependency(
+    link_with: libstatum,
+    include_directories: include_directories('.'),
+    dependencies: [
+        glib_dep,
+        gobject_dep,
+        gio_dep,
+        gee_dep,
+        invercargill_dep,
+        invercargill_json_dep,
+        json_glib_dep,
+        libxml_dep,
+        inversion_dep,
+        astralis_dep,
+    ]
+)

+ 335 - 0
vapi/libsodium.vapi

@@ -0,0 +1,335 @@
+/* Vala Bindings for LibSodium
+ * Copyright (c) 2020 Billy Barrow <billyb@pcthingz.com>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+
+ [CCode (cheader_filename = "sodium.h", lower_case_cprefix = "sodium_")]
+ namespace Sodium {
+ 
+   namespace Random {
+     [CCode (cname = "randombytes_SEEDBYTES")]
+     public const size_t SEED_BYTES;
+   
+     [CCode (cname = "randombytes_random")]
+     public uint32 random();
+   
+     [CCode (cname = "randombytes_uniform")]
+     public uint32 random_uniform(uint32 upper_bound);
+   
+     [CCode (cname = "randombytes_buf")]
+     public void random_bytes(uint8[] buffer);
+   
+     [CCode (cname = "randombytes_buf_deterministic")]
+     public void random_bytes_deterministic(uint8[] buffer, uint8[] seed);
+   }
+ 
+   namespace Symmetric {
+     [CCode (cname = "crypto_secretbox_KEYBYTES")]
+     public const size_t KEY_BYTES;
+ 
+     [CCode (cname = "crypto_secretbox_NONCEBYTES")]
+     public const size_t NONCE_BYTES;
+ 
+     [CCode (cname = "crypto_secretbox_MACBYTES")]
+     public const size_t MAC_BYTES;
+ 
+     [CCode (cname = "crypto_secretbox_keygen")]
+     private void key_gen([CCode (array_length = false)]uint8[] key);
+ 
+     public uint8[] generate_key() {
+       uint8[] key = new uint8[KEY_BYTES];
+       key_gen(key);
+       return key;
+     }
+ 
+     [CCode (cname = "crypto_secretbox_easy")]
+     private void secretbox(
+       [CCode (array_length = false)]uint8[] ciphertext,
+       uint8[] message,
+       [CCode (array_length = false)]uint8[] nonce,
+       [CCode (array_length = false)]uint8[] key
+     );
+ 
+     public uint8[] encrypt(uint8[] message, uint8[] key, uint8[] nonce)
+       requires (key.length == KEY_BYTES) 
+       requires (nonce.length == NONCE_BYTES)
+     {
+       // Initialise array for ciphertext
+       size_t ciphertext_size = MAC_BYTES + message.length;
+       uint8[] ciphertext = new uint8[ciphertext_size];
+ 
+       // Encrypt
+       secretbox(ciphertext, message, nonce, key);
+ 
+       // Return ciphertext
+       return ciphertext;
+     }
+ 
+     [CCode (cname = "crypto_secretbox_open_easy")]
+     private int secretbox_open(
+       [CCode (array_length = false)]uint8[] message,
+       uint8[] ciphertext,
+       [CCode (array_length = false)]uint8[] nonce,
+       [CCode (array_length = false)]uint8[] key
+     );
+ 
+     public uint8[]? decrypt(uint8[] ciphertext, uint8[] key, uint8[] nonce)
+       requires (ciphertext.length > MAC_BYTES)
+       requires (key.length == KEY_BYTES) 
+       requires (nonce.length == NONCE_BYTES)
+     {
+       // Initialise array for message
+       size_t message_size = ciphertext.length - MAC_BYTES;
+       uint8[] message = new uint8[message_size];
+ 
+       // Decrypt
+       int status = secretbox_open(message, ciphertext, nonce, key);
+ 
+       // Did it work?
+       if(status != 0) {
+         // No, return null
+         return null;
+       }
+ 
+       return message;
+     }
+   }
+   
+   namespace Asymmetric {
+ 
+     namespace Signing {
+ 
+         [CCode (cname = "crypto_sign_PUBLICKEYBYTES")]
+         public const size_t PUBLIC_KEY_BYTES;
+ 
+         [CCode (cname = "crypto_sign_SECRETKEYBYTES")]
+         public const size_t SECRET_KEY_BYTES;
+ 
+         [CCode (cname = "crypto_sign_BYTES")]
+         public const size_t MAX_HEADER_BYTES;
+ 
+         [CCode (cname = "crypto_sign_keypair")]
+         public void generate_keypair(
+             [CCode (array_length = false)]uint8[] public_key,
+             [CCode (array_length = false)]uint8[] secret_key)
+             requires (public_key.length == PUBLIC_KEY_BYTES)
+             requires (secret_key.length == SECRET_KEY_BYTES);
+             
+         [CCode (cname = "crypto_sign")]
+         private void sign_message(
+             [CCode (array_length = false)] uint8[] signed_message,
+             out int signature_length,
+             uint8[] message,
+             [CCode (array_length = false)] uint8[] secret_key
+         );
+ 
+         public uint8[] sign(
+             uint8[] message,
+             uint8[] secret_key)
+             requires (secret_key.length == SECRET_KEY_BYTES)
+         {
+             int signature_length;
+             uint8[] signed_message = new uint8[MAX_HEADER_BYTES + message.length];
+             sign_message(signed_message, out signature_length, message, secret_key);
+             signed_message.resize(signature_length);
+ 
+             return signed_message;
+         }
+ 
+         [CCode (cname = "crypto_sign_open")]
+         private int sign_open(
+             [CCode (array_length = false)] uint8[] message,
+             out int message_length,
+             uint8[] signed_message,
+             [CCode (array_length = false)] uint8[] public_key
+         );
+ 
+          public uint8[]? verify(
+              uint8[] signed_message,
+              uint8[] public_key)
+              requires (public_key.length == PUBLIC_KEY_BYTES)
+          {
+              int message_length;
+              uint8[] message = new uint8[signed_message.length];
+              if(sign_open(message, out message_length, signed_message, public_key) != 0) {
+                  return null;
+              }
+              message.resize(message_length);
+
+              return message;
+          }
+
+          [CCode (cname = "crypto_sign_detached")]
+          private void sign_detached_message(
+              [CCode (array_length = false)] uint8[] signature,
+              out int signature_length,
+              uint8[] message,
+              [CCode (array_length = false)] uint8[] secret_key
+          );
+
+          /**
+           * Signs a message and returns only the detached signature.
+           *
+           * The signature is MAX_HEADER_BYTES long and does not include the
+           * message itself, allowing the message and signature to be
+           * transported separately (e.g. a Statum frame's content/signature).
+           */
+          public uint8[] sign_detached(
+              uint8[] message,
+              uint8[] secret_key)
+              requires (secret_key.length == SECRET_KEY_BYTES)
+          {
+              uint8[] signature = new uint8[MAX_HEADER_BYTES];
+              int signature_length;
+              sign_detached_message(signature, out signature_length, message, secret_key);
+              signature.resize(signature_length);
+
+              return signature;
+          }
+
+          [CCode (cname = "crypto_sign_verify_detached")]
+          private int verify_detached_message(
+              [CCode (array_length = false)] uint8[] signature,
+              [CCode (array_length = false)] uint8[] message,
+              size_t message_length,
+              [CCode (array_length = false)] uint8[] public_key
+          );
+
+          /**
+           * Verifies a detached signature against a message.
+           *
+           * @return true if the signature is valid for the message.
+           */
+          public bool verify_detached(
+              uint8[] signature,
+              uint8[] message,
+              uint8[] public_key)
+              requires (public_key.length == PUBLIC_KEY_BYTES)
+              requires (signature.length == MAX_HEADER_BYTES)
+          {
+              return verify_detached_message(signature, message, (size_t)message.length, public_key) == 0;
+          }
+  
+      }
+ 
+     namespace Sealing {
+ 
+         [CCode (cname = "crypto_box_PUBLICKEYBYTES")]
+         public const size_t PUBLIC_KEY_BYTES;
+ 
+         [CCode (cname = "crypto_box_SECRETKEYBYTES")]
+         public const size_t SECRET_KEY_BYTES;
+ 
+         [CCode (cname = "crypto_box_SEALBYTES")]
+         public const size_t HEADER_BYTES;
+ 
+         [CCode (cname = "crypto_box_keypair")]
+         public void generate_keypair(
+             [CCode (array_length = false)]uint8[] public_key,
+             [CCode (array_length = false)]uint8[] secret_key)
+             requires (public_key.length == PUBLIC_KEY_BYTES)
+             requires (secret_key.length == SECRET_KEY_BYTES);
+ 
+         [CCode (cname = "crypto_box_seal")]
+         private void seal_message(
+             [CCode (array_length = false)] uint8[] ciphertext,
+             uint8[] message,
+             [CCode (array_length = false)] uint8[] public_key
+         );
+ 
+         public uint8[] seal(uint8[] message, uint8[] public_key)
+             requires (public_key.length == PUBLIC_KEY_BYTES)
+         {
+             uint8[] ciphertext = new uint8[HEADER_BYTES + message.length];
+             seal_message(ciphertext, message, public_key);
+             return ciphertext;
+         }
+ 
+         [CCode (cname = "crypto_box_seal_open")]
+         private int seal_open(
+             [CCode (array_length = false)] uint8[] message,
+             uint8[] ciphertext,
+             [CCode (array_length = false)] uint8[] public_key,
+             [CCode (array_length = false)] uint8[] secret_key
+         );
+ 
+         public uint8[]? unseal(
+             uint8[] ciphertext,
+             uint8[] public_key,
+             uint8[] secret_key) 
+             requires (public_key.length == PUBLIC_KEY_BYTES)
+             requires (secret_key.length == SECRET_KEY_BYTES)
+             requires (ciphertext.length > HEADER_BYTES)
+         {
+             uint8[] message = new uint8[ciphertext.length - HEADER_BYTES];
+             if(seal_open(message, ciphertext, public_key, secret_key) != 0){
+                 return null;
+             }
+              return message;
+          }
+          
+      }
+  
+    }
+    
+    namespace PasswordHashing {
+      [CCode (cname = "crypto_pwhash_STRBYTES")]
+      public const size_t STR_BYTES;
+    
+      [CCode (cname = "crypto_pwhash_OPSLIMIT_INTERACTIVE")]
+      public const size_t OPSLIMIT_INTERACTIVE;
+    
+      [CCode (cname = "crypto_pwhash_MEMLIMIT_INTERACTIVE")]
+      public const size_t MEMLIMIT_INTERACTIVE;
+    
+      [CCode (cname = "crypto_pwhash_OPSLIMIT_MODERATE")]
+      public const size_t OPSLIMIT_MODERATE;
+    
+      [CCode (cname = "crypto_pwhash_MEMLIMIT_MODERATE")]
+      public const size_t MEMLIMIT_MODERATE;
+    
+      [CCode (cname = "crypto_pwhash_OPSLIMIT_SENSITIVE")]
+      public const size_t OPSLIMIT_SENSITIVE;
+    
+      [CCode (cname = "crypto_pwhash_MEMLIMIT_SENSITIVE")]
+      public const size_t MEMLIMIT_SENSITIVE;
+    
+      [CCode (cname = "crypto_pwhash_str")]
+      private int pwhash_str(
+        [CCode (array_length = false)] uint8[] out,
+        string passwd,
+        size_t passwdlen,
+        ulong opslimit,
+        size_t memlimit
+      );
+    
+      public string? hash(string password, ulong opslimit = OPSLIMIT_MODERATE, size_t memlimit = MEMLIMIT_MODERATE) {
+        uint8[] out_buf = new uint8[STR_BYTES];
+        if (pwhash_str(out_buf, password, password.length, opslimit, memlimit) != 0) {
+          return null;
+        }
+        // Null-terminated string
+        return (string)out_buf;
+      }
+    
+      [CCode (cname = "crypto_pwhash_str_verify")]
+      private int pwhash_str_verify(string hash, string password, size_t passwdlen);
+    
+      public bool check(string hash, string password) {
+        return pwhash_str_verify(hash, password, password.length) == 0;
+      }
+    }
+  
+  }