Bladeren bron

Merge branch 'goal/locksmith' of Tilo15/Statum into master

Billy Barrow 1 dag geleden
bovenliggende
commit
7bd0ccb841

+ 1 - 0
.gitignore

@@ -1 +1,2 @@
 builddir/
+example/web-config.json

+ 2 - 2
MANIFEST.usm

@@ -25,7 +25,7 @@
       "lib:libinvercargill-json.so",
       "lib:libinversion-0.1.so",
       "lib:libastralis-0.1.so",
-      "lib:libsodium.so.26",
+      "lib:liblocksmith-1.so.0",
       "lib:libxml2.so.2"
     ],
     "build" : [
@@ -41,7 +41,7 @@
       "pc:json-glib-1.0.pc",
       "pc:libxml-2.0.pc",
       "pc:sqlite3.pc",
-      "pc:libsodium.pc",
+      "pc:locksmith-1.pc",
       "pc:invercargill-1.pc",
       "pc:invercargill-json.pc",
       "pc:inversion-0.1.pc",

+ 1 - 10
meson.build

@@ -2,8 +2,6 @@ project('statum', ['c', 'vala'],
   version: '0.2.0',
 )
 
-vapi_dir = join_paths(meson.current_source_dir(), 'vapi')
-
 # Dependencies
 glib_dep = dependency('glib-2.0')
 gobject_dep = dependency('gobject-2.0')
@@ -18,14 +16,7 @@ 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')
+locksmith_dep = dependency('locksmith-1')
 
 # Tools are built first (from source — never required to be installed) so the
 # library can use them at its own build time (e.g. to embed the client scripts).

+ 1 - 2
src/ChannelEndpoint.vala

@@ -175,8 +175,7 @@ namespace Statum {
         }
 
         private static string generate_channel_id() {
-            var bytes = new uint8[16];
-            Sodium.Random.random_bytes(bytes);
+            var bytes = Locksmith.Random.bytes(16).to_array();
             var builder = new StringBuilder();
             foreach (var b in bytes) {
                 builder.append_printf("%02x", b);

+ 29 - 26
src/Cryptography/EncryptionProvider.vala

@@ -22,7 +22,7 @@ namespace Statum.Cryptography {
      * {@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
+     * to JSON, signed (Ed25519) and sealed (X25519-Seal) using Locksmith, then
      * returned as a base64 string suitable for embedding in a DTO. The reverse
      * operation unseals, verifies the signature and re-parses the JSON.
      *
@@ -36,32 +36,34 @@ namespace Statum.Cryptography {
         /** 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;
+        private Locksmith.Signing.KeyPair signing;
+        private Locksmith.Sealing.KeyPair sealing;
 
         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);
+            signing = Locksmith.Signing.KeyPair.generate();
+            sealing = Locksmith.Sealing.KeyPair.generate();
         }
 
         /**
          * Constructs the provider with explicit key pairs (e.g. read from static
-         * configuration so private blobs survive a restart).
+         * configuration so private blobs survive a restart). Each pair is built
+         * from its secret key, so the public halves are derived
+         * deterministically; a configured public key that disagrees with the
+         * derived one triggers a loud warning (config error) while the derived
+         * key — the one that actually signs and unseals — is trusted.
+         *
+         * @throws Locksmith.Error.KEY_FORMAT if either secret key has the wrong
+         *         length.
          */
         public EncryptionProvider.with_keys(
                 uint8[] signing_secret_key, uint8[] signing_public_key,
-                uint8[] sealing_secret_key, uint8[] sealing_public_key) {
-            this.signing_secret_key = signing_secret_key;
-            this.signing_public_key = signing_public_key;
-            this.sealing_secret_key = sealing_secret_key;
-            this.sealing_public_key = sealing_public_key;
+                uint8[] sealing_secret_key, uint8[] sealing_public_key) throws Locksmith.Error {
+            signing = new Locksmith.Signing.KeyPair.from_byte_array(signing_secret_key);
+            warn_on_public_key_mismatch("encryption signing",
+                signing_public_key, signing.public_key.key.to_array());
+            sealing = new Locksmith.Sealing.KeyPair.from_byte_array(sealing_secret_key);
+            warn_on_public_key_mismatch("encryption sealing",
+                sealing_public_key, sealing.public_key.key.to_array());
         }
 
         /**
@@ -89,9 +91,9 @@ namespace Statum.Cryptography {
          */
         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);
+            var signed = signing.sign(new ByteBuffer.from_byte_array(namespaced.data));
+            var sealed = sealing.public_key.seal(signed);
+            return Base64.encode(sealed.to_array());
         }
 
         /**
@@ -131,18 +133,19 @@ namespace Statum.Cryptography {
          *         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) {
+            BinaryData signed;
+            try {
+                signed = sealing.unseal(new ByteBuffer.from_byte_array(Base64.decode(blob)));
+            } catch (Locksmith.Error e) {
                 throw new EncryptionError.DECRYPTION_FAILED("Could not decrypt private blob");
             }
 
-            var cleartext = Sodium.Asymmetric.Signing.verify((!)signed, signing_public_key);
+            var cleartext = signing.public_key.verify(signed);
             if (cleartext == null) {
                 throw new EncryptionError.SIGNATURE_INVALID("Signature verification failed for private blob");
             }
 
-            var namespaced = Wrap.byte_array((!)cleartext).to_raw_string();
+            var namespaced = ((!)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");

+ 62 - 18
src/Cryptography/SigningProvider.vala

@@ -1,4 +1,5 @@
 using Statum.Model;
+using Invercargill.DataStructures;
 
 namespace Statum.Cryptography {
 
@@ -12,7 +13,7 @@ namespace Statum.Cryptography {
 
     /**
      * Signs and verifies the integrity of Statum {@link FrameDto}s using
-     * detached Ed25519 signatures (libsodium `crypto_sign_detached`).
+     * detached Ed25519 signatures (Locksmith `Signing.KeyPair.sign_detached`).
      *
      * A frame's {@link FrameDto.signature} is a base64-encoded detached
      * signature over its {@link FrameDto.content}, produced by the signer
@@ -32,8 +33,7 @@ namespace Statum.Cryptography {
      */
     public class SigningProvider : GLib.Object {
 
-        private uint8[] secret_key;
-        private uint8[] public_key;
+        private Locksmith.Signing.KeyPair keypair;
 
         /**
          * The signer id written into the {@link Frame.signer} field of frames
@@ -42,10 +42,8 @@ namespace Statum.Cryptography {
         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);
+            keypair = Locksmith.Signing.KeyPair.generate();
+            signer_id = keypair.public_key.to_base64();
         }
 
         /**
@@ -56,8 +54,8 @@ namespace Statum.Cryptography {
          *         {@link FrameDto.signature}.
          */
         public string sign(string content) {
-            var signature = Sodium.Asymmetric.Signing.sign_detached(content.data, secret_key);
-            return Base64.encode(signature);
+            var signature = keypair.sign_detached(new ByteBuffer.from_byte_array(content.data));
+            return Base64.encode(signature.to_array());
         }
 
         /**
@@ -67,18 +65,28 @@ namespace Statum.Cryptography {
          * @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);
+            return verify_with_key(content, signature_b64, get_public_key());
         }
 
         /**
          * Verifies a base64-encoded detached signature against `content` using
          * an arbitrary Ed25519 public key (e.g. the key of a different signer).
          *
+         * A malformed verification key (wrong length) verifies nothing.
+         *
          * @return `true` if the signature is valid for `content`.
          */
         public bool verify_with_key(string content, string signature_b64, uint8[] verification_public_key) {
+            Locksmith.Signing.PublicKey verifier;
+            try {
+                verifier = new Locksmith.Signing.PublicKey.from_byte_array(verification_public_key);
+            } catch (Locksmith.Error e) {
+                return false;
+            }
             var signature = Base64.decode(signature_b64);
-            return Sodium.Asymmetric.Signing.verify_detached(signature, content.data, verification_public_key);
+            return verifier.verify_detached(
+                new ByteBuffer.from_byte_array(content.data),
+                new ByteBuffer.from_byte_array(signature));
         }
 
         /**
@@ -121,13 +129,20 @@ namespace Statum.Cryptography {
 
         /**
          * Constructs the provider with an explicit key pair (e.g. read from
-         * static configuration so frames survive a restart). The `signer_id` is
-         * derived from the public key.
+         * static configuration so frames survive a restart). The keypair is
+         * built from the secret key — an Ed25519 secret key is seed‖pk, so the
+         * public half is derived deterministically. If the supplied public key
+         * does not match the derived one, the pair disagrees with the secret
+         * (a config error) and a loud warning is printed; the *derived* key is
+         * what signs and verifies, so it is trusted.
+         *
+         * @throws Locksmith.Error.KEY_FORMAT if `secret_key` has the wrong
+         *         length for an Ed25519 secret key.
          */
-        public SigningProvider.with_keys(uint8[] secret_key, uint8[] public_key) {
-            this.secret_key = secret_key;
-            this.public_key = public_key;
-            signer_id = Base64.encode(public_key);
+        public SigningProvider.with_keys(uint8[] secret_key, uint8[] public_key) throws Locksmith.Error {
+            keypair = new Locksmith.Signing.KeyPair.from_byte_array(secret_key);
+            warn_on_public_key_mismatch("signing", public_key, keypair.public_key.key.to_array());
+            signer_id = keypair.public_key.to_base64();
         }
 
         /**
@@ -135,9 +150,38 @@ namespace Statum.Cryptography {
          * secret key, for distribution to verifiers.
          */
         public uint8[] get_public_key() {
-            return public_key;
+            return keypair.public_key.key.to_array();
         }
 
     }
 
+    /**
+     * Prints a loud warning when a configured public key does not match the
+     * one derived from the configured secret key: the `web-config.json`
+     * key pair is internally inconsistent, and the derived half is what
+     * the provider actually uses.
+     */
+    internal void warn_on_public_key_mismatch(string purpose, uint8[] configured_pk, uint8[] derived_pk) {
+        var differs = configured_pk.length != derived_pk.length;
+        if (!differs) {
+            for (int i = 0; i < derived_pk.length; i++) {
+                if (configured_pk[i] != derived_pk[i]) {
+                    differs = true;
+                    break;
+                }
+            }
+        }
+        if (!differs) {
+            return;
+        }
+        warning("[Statum] ────────────────────────────────────────────────────────────────────\n"
+            + @"[Statum]  Configured $purpose public key disagrees with the public key\n"
+            + "[Statum]  derived from the configured secret key:\n"
+            + @"[Statum]    configured: $(Base64.encode(configured_pk)) ($(configured_pk.length) bytes)\n"
+            + @"[Statum]    derived:    $(Base64.encode(derived_pk)) ($(derived_pk.length) bytes)\n"
+            + "[Statum]  The DERIVED public key is in use; fix web-config.json\n"
+            + "[Statum]  (regenerate with `statum-genkeys --out web-config.json`).\n"
+            + "[Statum] ────────────────────────────────────────────────────────────────────");
+    }
+
 }

+ 1 - 2
src/StateService.vala

@@ -37,8 +37,7 @@ namespace Statum {
         private Dictionary<string, Slot> slots = new Dictionary<string, Slot>();
 
         public Slot new_slot(Scope slot_scope, State initial_state) {
-            var id = new uint8[32];
-            Sodium.Random.random_bytes(id);
+            var id = Locksmith.Random.bytes(32).to_array();
 
             initial_state.last_touched = new DateTime.now_utc();
             var slot = new Slot() {

+ 26 - 4
src/Statum.vala

@@ -101,7 +101,11 @@ namespace Statum {
                 var sk = ((!)section).get_string("signing_secret_key");
                 var pk = ((!)section).get_string("signing_public_key");
                 if (sk.length > 0 && pk.length > 0) {
-                    return new Cryptography.SigningProvider.with_keys(Base64.decode(sk), Base64.decode(pk));
+                    try {
+                        return new Cryptography.SigningProvider.with_keys(Base64.decode(sk), Base64.decode(pk));
+                    } catch (Locksmith.Error e) {
+                        warn_on_malformed_keys("signing key", e);
+                    }
                 }
             }
             warning("[Statum] ────────────────────────────────────────────────────────────────────\n"
@@ -125,9 +129,13 @@ namespace Statum {
                 var esk = s.get_string("encryption_sealing_secret_key");
                 var epk = s.get_string("encryption_sealing_public_key");
                 if (ssk.length > 0 && spk.length > 0 && esk.length > 0 && epk.length > 0) {
-                    return new Cryptography.EncryptionProvider.with_keys(
-                        Base64.decode(ssk), Base64.decode(spk),
-                        Base64.decode(esk), Base64.decode(epk));
+                    try {
+                        return new Cryptography.EncryptionProvider.with_keys(
+                            Base64.decode(ssk), Base64.decode(spk),
+                            Base64.decode(esk), Base64.decode(epk));
+                    } catch (Locksmith.Error e) {
+                        warn_on_malformed_keys("encryption keys", e);
+                    }
                 }
             }
             warning("[Statum] ────────────────────────────────────────────────────────────────────\n"
@@ -142,6 +150,20 @@ namespace Statum {
             return new Cryptography.EncryptionProvider();
         }
 
+        /**
+         * Graceful degradation for malformed static keys (wrong length): the
+         * provider falls back to EPHEMERAL keys so the process still boots,
+         * loudly.
+         */
+        private static void warn_on_malformed_keys(string what, Locksmith.Error e) {
+            warning("[Statum] ────────────────────────────────────────────────────────────────────\n"
+                + @"[Statum]  Configured $what in web-config.json are malformed ($(e.message)).\n"
+                + "[Statum]  Falling back to EPHEMERAL keys — client-held frames and\n"
+                + "[Statum]  private blobs will NOT survive a restart.\n"
+                + "[Statum]  Run `statum-genkeys --out web-config.json` to regenerate.\n"
+                + "[Statum] ────────────────────────────────────────────────────────────────────");
+        }
+
         private static Astralis.WebConfigSection? read_statum_config(Inversion.Scope scope) {
             try {
                 var config = scope.resolve<Astralis.WebConfig>();

+ 2 - 1
src/meson.build

@@ -82,7 +82,7 @@ libstatum = shared_library('statum-@0@'.format(api_version),
         libxml_dep,
         inversion_dep,
         astralis_dep,
-        sodium_deps,
+        locksmith_dep,
         invercargill_sql_dep,
         sqlite_dep,
         invercargill_sql_inversion_dep,
@@ -111,5 +111,6 @@ statum_dep = declare_dependency(
         libxml_dep,
         inversion_dep,
         astralis_dep,
+        locksmith_dep,
     ]
 )

+ 226 - 0
tests/TestMain.vala

@@ -300,6 +300,202 @@ namespace Statum.Tests {
         check(threw, "SA-10: verify_frame rejects a frame signed by a different key");
     }
 
+    // -----------------------------------------------------------------
+    // Locksmith port (goal 44): wire compatibility and restart survival.
+    // Key material is generated through Locksmith directly — mirroring
+    // `statum-genkeys` without shelling out to it.
+    // -----------------------------------------------------------------
+
+    /** Raw key bytes in the shape `web-config.json` carries. */
+    public class KeyMaterial : Object {
+        public uint8[] signing_sk = new uint8[0];
+        public uint8[] signing_pk = new uint8[0];
+        public uint8[] enc_signing_sk = new uint8[0];
+        public uint8[] enc_signing_pk = new uint8[0];
+        public uint8[] sealing_sk = new uint8[0];
+        public uint8[] sealing_pk = new uint8[0];
+    }
+
+    KeyMaterial generate_key_material() {
+        var material = new KeyMaterial();
+        var signing = Locksmith.Signing.KeyPair.generate();
+        material.signing_sk = signing.to_secret_key().to_array();
+        material.signing_pk = signing.public_key.key.to_array();
+        var enc_signing = Locksmith.Signing.KeyPair.generate();
+        material.enc_signing_sk = enc_signing.to_secret_key().to_array();
+        material.enc_signing_pk = enc_signing.public_key.key.to_array();
+        var sealing = Locksmith.Sealing.KeyPair.generate();
+        material.sealing_sk = sealing.to_secret_key().to_array();
+        material.sealing_pk = sealing.public_key.key.to_array();
+        return material;
+    }
+
+    Statum.Cryptography.SigningProvider signing_from_keys(KeyMaterial material) throws Locksmith.Error {
+        return new Statum.Cryptography.SigningProvider.with_keys(material.signing_sk, material.signing_pk);
+    }
+
+    Statum.Cryptography.EncryptionProvider encryption_from_keys(KeyMaterial material) throws Locksmith.Error {
+        return new Statum.Cryptography.EncryptionProvider.with_keys(
+            material.enc_signing_sk, material.enc_signing_pk,
+            material.sealing_sk, material.sealing_pk);
+    }
+
+    /**
+     * Restart survival: providers rebuilt from the same raw key bytes (the
+     * `web-config.json` static-key path) verify frames and unseal private
+     * blobs that were authored before the "restart".
+     */
+    void test_locksmith_restart_survival() throws GLib.Error {
+        var material = generate_key_material();
+
+        Statum.Cryptography.SigningProvider first_signing = null;
+        Statum.Cryptography.EncryptionProvider first_encryption = null;
+        try {
+            first_signing = signing_from_keys(material);
+            first_encryption = encryption_from_keys(material);
+        } catch (Locksmith.Error e) {
+            check(false, "restart: with_keys accepts freshly generated Locksmith key material (" + e.message + ")");
+            return;
+        }
+        check(true, "restart: with_keys accepts freshly generated Locksmith key material");
+
+        // Author a frame and a sealed private blob with the "before" pair.
+        var frame = first_signing.author_frame("{\"slot\":\"restart-survival\"}");
+        var model = sample_private();
+        var blob = first_encryption.author_properties("statum-tests", GObjectMapping.to_properties(model));
+
+        // Fresh provider instances from the same raw bytes model a restart.
+        Statum.Cryptography.SigningProvider second_signing;
+        Statum.Cryptography.EncryptionProvider second_encryption;
+        try {
+            second_signing = signing_from_keys(material);
+            second_encryption = encryption_from_keys(material);
+        } catch (Locksmith.Error e) {
+            check(false, "restart: with_keys rebuilds providers from the same raw key bytes (" + e.message + ")");
+            return;
+        }
+        check(true, "restart: with_keys rebuilds providers from the same raw key bytes");
+
+        second_signing.verify_frame(frame);
+        check(true, "restart: a frame authored before the restart verifies through a rebuilt provider");
+
+        var back = (LoginPrivate) GObjectMapping.from_properties(typeof(LoginPrivate),
+            second_encryption.read_properties("statum-tests", blob));
+        assert_private_matches(model, back, "restart");
+
+        check(first_signing.signer_id == second_signing.signer_id,
+            "restart: signer_id is identical across provider instances");
+        check(Base64.decode(frame.signature).length == 64,
+            "restart: frame signatures are 64-byte detached Ed25519 signatures");
+    }
+
+    /**
+     * Wire stability of `signer_id`: Locksmith's `to_base64` and GLib's
+     * `Base64.encode` agree on the raw 32-byte public key, so frames already
+     * held by clients (localStorage) keep verifying and keep naming the same
+     * signer after the Locksmith port.
+     */
+    void test_locksmith_signer_id_wire_stability() throws GLib.Error {
+        var ephemeral = new Statum.Cryptography.SigningProvider();
+        var raw = ephemeral.get_public_key();
+        check(raw.length == 32, "wire: get_public_key returns a raw 32-byte Ed25519 public key");
+        check(ephemeral.signer_id == Base64.encode(raw),
+            "wire: signer_id is GLib Base64.encode of the raw public key (ephemeral provider)");
+
+        var material = generate_key_material();
+        try {
+            var configured = signing_from_keys(material);
+            check(configured.signer_id == Base64.encode(configured.get_public_key()),
+                "wire: signer_id is GLib Base64.encode of the raw public key (configured provider)");
+            check(configured.signer_id == Base64.encode(material.signing_pk),
+                "wire: a configured provider's signer_id matches its configured public key bytes");
+        } catch (Locksmith.Error e) {
+            check(false, "wire: configured provider constructs (" + e.message + ")");
+        }
+    }
+
+    /**
+     * Foreign and malformed verification keys verify nothing and crash
+     * nothing (no GLib criticals).
+     */
+    void test_locksmith_foreign_and_malformed_keys() throws GLib.Error {
+        var material = generate_key_material();
+        var provider = signing_from_keys(material);
+        var frame = provider.author_frame("{\"slot\":\"foreign\"}");
+
+        check(provider.verify_with_key(frame.content, frame.signature, material.signing_pk),
+            "foreign: the provider's own public key verifies its signature");
+        var foreign = Locksmith.Signing.KeyPair.generate();
+        check(!provider.verify_with_key(frame.content, frame.signature, foreign.public_key.key.to_array()),
+            "foreign: an unrelated third key's public key verifies nothing");
+
+        var malformed = new uint8[31];
+        check(!provider.verify_with_key(frame.content, frame.signature, malformed),
+            "malformed: a wrong-length (31-byte) verification key verifies nothing");
+    }
+
+    /**
+     * `with_keys` pk-mismatch policy: the public key DERIVED from the secret
+     * wins. A supplied config pk that disagrees with the secret is a config
+     * error (loud warning at construction — not captured here); the derived
+     * key is the one that signs, names the signer and verifies.
+     */
+    void test_locksmith_pk_mismatch_policy() throws GLib.Error {
+        var material = generate_key_material();
+        var wrong_pk = Locksmith.Signing.KeyPair.generate().public_key.key.to_array();
+
+        var provider = new Statum.Cryptography.SigningProvider.with_keys(material.signing_sk, wrong_pk);
+        check(provider.signer_id == Base64.encode(material.signing_pk),
+            "mismatch: signer_id is the pk derived from the secret, not the supplied one");
+        check(provider.signer_id != Base64.encode(wrong_pk),
+            "mismatch: the supplied wrong public key is not the signer_id");
+
+        var frame = provider.author_frame("{\"slot\":\"mismatch\"}");
+        check(provider.verify_with_key(frame.content, frame.signature, material.signing_pk),
+            "mismatch: a signature by this provider verifies against the derived pk");
+        check(!provider.verify_with_key(frame.content, frame.signature, wrong_pk),
+            "mismatch: the same signature fails against the supplied wrong pk");
+    }
+
+    /**
+     * Error semantics survived the Locksmith port: namespace binding rejects
+     * cross-namespace reads, garbage fails to decrypt, and a tampered blob
+     * never reads back — all as `EncryptionError`, never a crash.
+     */
+    void test_locksmith_error_semantics() throws GLib.Error {
+        var encryption = new Statum.Cryptography.EncryptionProvider();
+
+        var blob = encryption.author_string("statum-a", "{\"n\":1}");
+        bool mismatched = false;
+        try {
+            encryption.read_string("statum-b", blob);
+        } catch (Statum.Cryptography.EncryptionError.NAMESPACE_MISMATCH e) {
+            mismatched = true;
+        }
+        check(mismatched, "errors: a blob authored under namespace \"a\" and read under \"b\" throws NAMESPACE_MISMATCH");
+
+        var garbage = Base64.encode(new uint8[] { 1, 2, 3, 4, 5 });
+        bool undecryptable = false;
+        try {
+            encryption.read_string("statum-a", garbage);
+        } catch (Statum.Cryptography.EncryptionError.DECRYPTION_FAILED e) {
+            undecryptable = true;
+        }
+        check(undecryptable, "errors: base64-encoded garbage throws DECRYPTION_FAILED");
+
+        var bytes = Base64.decode(blob);
+        bytes[bytes.length - 1] = bytes[bytes.length - 1] ^ 0xff;
+        bool tampering_threw = false;
+        string detail = "read back without throwing";
+        try {
+            encryption.read_string("statum-a", Base64.encode(bytes));
+        } catch (Statum.Cryptography.EncryptionError e) {
+            tampering_threw = true;
+            detail = e.message;
+        }
+        check(tampering_threw, "errors: a byte-flipped blob throws EncryptionError, not a crash (" + detail + ")");
+    }
+
     /** SA-15: global and per-IP channel connection caps. */
     void test_connection_caps() {
         var tracker = new ConnectionTracker(4, 2);
@@ -416,6 +612,36 @@ namespace Statum.Tests {
             failures++;
             print("FAIL verify_frame signer threw: %s\n", e.message);
         }
+        try {
+            test_locksmith_restart_survival();
+        } catch (GLib.Error e) {
+            failures++;
+            print("FAIL locksmith restart survival threw: %s\n", e.message);
+        }
+        try {
+            test_locksmith_signer_id_wire_stability();
+        } catch (GLib.Error e) {
+            failures++;
+            print("FAIL locksmith signer_id wire stability threw: %s\n", e.message);
+        }
+        try {
+            test_locksmith_foreign_and_malformed_keys();
+        } catch (GLib.Error e) {
+            failures++;
+            print("FAIL locksmith foreign/malformed keys threw: %s\n", e.message);
+        }
+        try {
+            test_locksmith_pk_mismatch_policy();
+        } catch (GLib.Error e) {
+            failures++;
+            print("FAIL locksmith pk-mismatch policy threw: %s\n", e.message);
+        }
+        try {
+            test_locksmith_error_semantics();
+        } catch (GLib.Error e) {
+            failures++;
+            print("FAIL locksmith error semantics threw: %s\n", e.message);
+        }
         try {
             test_connection_caps();
         } catch (GLib.Error e) {

+ 3 - 1
tests/meson.build

@@ -1,5 +1,7 @@
 # Statum test suite: scalar/list serialization round-trips (public + encrypted
-# paths) and the cross-namespace typed-action private-data reproduction (S-8).
+# paths), the cross-namespace typed-action private-data reproduction (S-8),
+# the signing/encryption regression cases (SA-*), and the Locksmith
+# wire-compat + restart-survival cases for the crypto providers.
 # Prints PASS/FAIL lines and exits non-zero on failure.
 
 statum_tests = executable('statum-tests',

+ 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, dependency('gio-2.0'), sodium_deps],
+    dependencies: [glib_dep, gobject_dep, dependency('gio-2.0'), locksmith_dep],
     install: true
 )

+ 18 - 24
tools/statum-genkeys/statum-genkeys.vala

@@ -1,4 +1,4 @@
-using Sodium;
+using Locksmith;
 
 namespace Statum.Tools {
 
@@ -53,31 +53,25 @@ namespace Statum.Tools {
             }
 
             // 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];
-            Sodium.Asymmetric.Signing.generate_keypair(sign_pk, sign_sk);
+            var sign = Signing.KeyPair.generate();
 
             // Encryption signing (Ed25519) key pair.
-            var esign_sk = new uint8[Sodium.Asymmetric.Signing.SECRET_KEY_BYTES];
-            var esign_pk = new uint8[Sodium.Asymmetric.Signing.PUBLIC_KEY_BYTES];
-            Sodium.Asymmetric.Signing.generate_keypair(esign_pk, esign_sk);
+            var esign = Signing.KeyPair.generate();
 
             // Encryption sealing (X25519) key pair.
-            var seal_sk = new uint8[Sodium.Asymmetric.Sealing.SECRET_KEY_BYTES];
-            var seal_pk = new uint8[Sodium.Asymmetric.Sealing.PUBLIC_KEY_BYTES];
-            Sodium.Asymmetric.Sealing.generate_keypair(seal_pk, seal_sk);
+            var seal = Sealing.KeyPair.generate();
 
             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_printf("    \"signing_secret_key\": \"%s\",\n", sign.to_secret_key_base64());
+                full.append_printf("    \"signing_public_key\": \"%s\",\n", sign.public_key.to_base64());
+                full.append_printf("    \"encryption_signing_secret_key\": \"%s\",\n", esign.to_secret_key_base64());
+                full.append_printf("    \"encryption_signing_public_key\": \"%s\",\n", esign.public_key.to_base64());
+                full.append_printf("    \"encryption_sealing_secret_key\": \"%s\",\n", seal.to_secret_key_base64());
+                full.append_printf("    \"encryption_sealing_public_key\": \"%s\"\n", seal.public_key.to_base64());
                 full.append("  }\n");
                 full.append("}\n");
 
@@ -87,7 +81,7 @@ namespace Statum.Tools {
 
                 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);
+                print_public_halves(sign.public_key.to_base64(), esign.public_key.to_base64(), seal.public_key.to_base64());
                 return 0;
             }
 
@@ -96,9 +90,9 @@ namespace Statum.Tools {
             // --out (see SA-12).
             stdout.printf("{\n");
             stdout.printf("  \"statum\": {\n");
-            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));
+            stdout.printf("    \"signing_public_key\": \"%s\",\n", sign.public_key.to_base64());
+            stdout.printf("    \"encryption_signing_public_key\": \"%s\",\n", esign.public_key.to_base64());
+            stdout.printf("    \"encryption_sealing_public_key\": \"%s\"\n", seal.public_key.to_base64());
             stdout.printf("  }\n");
             stdout.printf("}\n");
             stdout.printf("# Secret key halves are not printed. Use:\n");
@@ -107,10 +101,10 @@ namespace Statum.Tools {
             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));
+        private static void print_public_halves(string sign_pk, string esign_pk, string seal_pk) {
+            stdout.printf("signing_public_key:                 %s\n", sign_pk);
+            stdout.printf("encryption_signing_public_key:     %s\n", esign_pk);
+            stdout.printf("encryption_sealing_public_key:     %s\n", seal_pk);
         }
 
         /**

+ 0 - 335
vapi/libsodium.vapi

@@ -1,335 +0,0 @@
-/* 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;
-      }
-    }
-  
-  }