Przeglądaj źródła

refactor(crypto): simplify cryptography API with namespace-based security

Refactor CryptographyProvider to use simplified author/read methods with
namespace validation to prevent cross-protocol attacks. Extract component
context blob operations into dedicated ComponentContextService and move
Base64 encoding responsibility to calling services (SessionService,
AuthorisationTokenService).

- Remove TokenValidationResult and complex token methods from CryptographyProvider
- Add namespace parameter to author/read for protocol isolation
- Create ComponentContextService for HTMX component context handling
- Update SessionService and AuthorisationTokenService to use new namespaced API
- Fix integer type casting in SqlUserRepository existence checks
- Remove duplicate CryptographyProvider registration in UsersExample
Billy Barrow 1 miesiąc temu
rodzic
commit
6d42575198

+ 2 - 1
.gitignore

@@ -1 +1,2 @@
-data/
+data/
+spry_auth.db

+ 1 - 1
examples/UsersExample.vala

@@ -977,7 +977,7 @@ private async void start_application(int port) throws Error {
     application.add_singleton<AuthorisationContext>();
     
     // Seed initial data (admin user, test user)
-    application.add_singleton<CryptographyProvider>();
+    // Note: CryptographyProvider is already registered by SpryModule
     seed_initial_data.begin(application.container);
     
     // Register template with route prefix

+ 23 - 25
src/Authentication/SessionService.vala

@@ -134,6 +134,8 @@ namespace Spry.Authentication {
      */
     public class SessionService : GLib.Object {
 
+        private const string NAMESPACE = "session-token";
+
         private SessionRepository _repository = inject<SessionRepository>();
         private CryptographyProvider _crypto = inject<CryptographyProvider>();
         private Authorisation.AuthorisationTokenService? _token_service = inject<Authorisation.AuthorisationTokenService>();
@@ -237,13 +239,13 @@ namespace Spry.Authentication {
          * Generates a signed and encrypted session token.
          *
          * Creates a JSON payload with session_id, user_id, expires_at
-         * and uses CryptographyProvider.sign_then_seal_token().
+         * and uses CryptographyProvider.author() with the session-token namespace.
          *
          * Note: For integration with the Authorisation system, use
          * generate_authorisation_token() which creates an AuthorisationToken.
          *
          * @param session The session to generate a token for
-         * @return The encrypted token string
+         * @return The encrypted token string (URL-safe Base64)
          */
         public string generate_session_token(Session session) {
             stdout.printf("SESSION DEBUG: generate_session_token() called for session: %s\n", session.id);
@@ -262,11 +264,17 @@ namespace Spry.Authentication {
             var payload = Json.to_string(node, false);
             stdout.printf("SESSION DEBUG: Token payload JSON: %s\n", payload);
 
-            // Sign and seal the token with expiry
-            stdout.printf("SESSION DEBUG: Calling sign_then_seal_token()...\n");
-            var token = _crypto.sign_then_seal_token(payload, session.expires_at);
-            stdout.printf("SESSION DEBUG: Token generated successfully (length: %d)\n", token.length);
-            return token;
+            // Sign and seal the token
+            try {
+                stdout.printf("SESSION DEBUG: Calling author()...\n");
+                var blob = _crypto.author(NAMESPACE, payload);
+                var token = Base64.encode(blob).replace("+", "-").replace("/", "_");
+                stdout.printf("SESSION DEBUG: Token generated successfully (length: %d)\n", token.length);
+                return token;
+            } catch (Error e) {
+                warning("Failed to generate session token: %s", e.message);
+                return "";
+            }
         }
 
         /**
@@ -297,33 +305,23 @@ namespace Spry.Authentication {
          * Validates a session token and returns the result.
          *
          * This method:
-         * - Uses CryptographyProvider.unseal_then_verify_token()
+         * - Uses CryptographyProvider.read() with session-token namespace
          * - Checks expiry
          * - Loads session from storage
          * - Verifies session exists and matches token data
          *
-         * @param token The encrypted token string
+         * @param token The encrypted token string (URL-safe Base64)
          * @return A SessionValidationResult with session and user info
          */
         public async SessionValidationResult validate_session_token_async(string token) throws Error {
+            // Decode Base64
+            var decoded = Base64.decode(token.replace("-", "+").replace("_", "/"));
+            
             // Decrypt and verify the token
-            var token_result = _crypto.unseal_then_verify_token(token);
-
-            if (!token_result.is_valid) {
-                return new SessionValidationResult.failure(
-                    token_result.error_message ?? "Invalid token"
-                );
-            }
-
-            // Check if token is expired (crypto provider handles this but double-check)
-            if (token_result.is_expired) {
-                return new SessionValidationResult.failure("Session has expired");
-            }
+            var payload = _crypto.read(NAMESPACE, decoded);
 
-            // Parse the payload
-            var payload = token_result.payload;
             if (payload == null) {
-                return new SessionValidationResult.failure("Empty token payload");
+                return new SessionValidationResult.failure("Could not decrypt token");
             }
 
             var json = new JsonElement.from_string((!)payload);
@@ -334,7 +332,7 @@ namespace Spry.Authentication {
             var expires_at_str = obj.get("expires_at").as<string>();
             var expires_at = new DateTime.from_iso8601(expires_at_str, new TimeZone.utc());
 
-            // Check expiry again
+            // Check expiry
             if (expires_at.compare(new DateTime.now_utc()) <= 0) {
                 return new SessionValidationResult.failure("Session has expired");
             }

+ 3 - 3
src/Authentication/SqlUserRepository.vala

@@ -189,7 +189,7 @@ namespace Spry.Authentication {
                 return false;
             }
 
-            return scalar.as<int>() > 0;
+            return scalar.as<int64?>() > 0;
         }
 
         public async bool exists_by_email(string email) throws Error {
@@ -203,7 +203,7 @@ namespace Spry.Authentication {
                 return false;
             }
 
-            return scalar.as<int>() > 0;
+            return scalar.as<int64?>() > 0;
         }
 
         // =========================================================================
@@ -249,7 +249,7 @@ namespace Spry.Authentication {
                 return false;
             }
 
-            return scalar.as<int>() > 0;
+            return scalar.as<int64?>() > 0;
         }
 
         public async Vector<string> get_permissions(string user_id) throws Error {

+ 0 - 1
src/Authentication/UserService.vala

@@ -27,7 +27,6 @@ namespace Spry.Authentication {
     public class UserService : GLib.Object {
 
         private UserRepository _repository = inject<UserRepository>();
-        private CryptographyProvider _crypto = inject<CryptographyProvider>();
 
         // =========================================================================
         // User Creation

+ 55 - 53
src/Authorisation/AuthorisationTokenService.vala

@@ -7,15 +7,17 @@ namespace Spry.Authorisation {
      * Service for generating and validating authorisation tokens.
      * 
      * Token Format:
-     * - JSON payload containing identity data and metadata
+     * - JSON payload containing identity data and metadata (including expiry)
      * - Signed with Ed25519 (server signing key)
      * - Encrypted with X25519 (server sealing key)
      * - Base64url encoded
      * 
-     * This service uses the same encryption approach as SessionService.
+     * This service handles expiry validation internally after decryption.
      */
     public class AuthorisationTokenService : GLib.Object {
 
+        private const string NAMESPACE = "auth-token";
+
         private CryptographyProvider _crypto = inject<CryptographyProvider>();
 
         // Configuration
@@ -45,7 +47,7 @@ namespace Spry.Authorisation {
          * 
          * @param identity The identity to create a token for
          * @param expires_at Optional custom expiry (defaults to token_duration from now)
-         * @return The encrypted token string
+         * @return The encrypted token string (URL-safe Base64)
          */
         public string generate_token(Identity identity, DateTime? expires_at = null) {
             // Calculate expiry
@@ -61,24 +63,34 @@ namespace Spry.Authorisation {
             var token = new AuthorisationToken.from_identity(identity, duration);
 
             // Serialize to JSON
-            var json_obj = token.to_json();
-            var node = new Json.Node(Json.NodeType.OBJECT);
-            node.set_object(json_obj);
-            var json_str = Json.to_string(node, false);
+            var json_str = token.to_json_string();
 
             // Sign and seal using CryptographyProvider
-            return _crypto.sign_then_seal_token(json_str, token_expiry);
+            try {
+                var blob = _crypto.author(NAMESPACE, json_str);
+                return Base64.encode(blob).replace("+", "-").replace("/", "_");
+            } catch (Error e) {
+                warning("Failed to generate token: %s", e.message);
+                return "";
+            }
         }
 
         /**
          * Generates a token from an existing AuthorisationToken.
          * 
          * @param token The token to serialize and encrypt
-         * @return The encrypted token string
+         * @return The encrypted token string (URL-safe Base64)
          */
         public string generate_token_from_token(AuthorisationToken token) {
             var json_str = token.to_json_string();
-            return _crypto.sign_then_seal_token(json_str, token.expires_at);
+            
+            try {
+                var blob = _crypto.author(NAMESPACE, json_str);
+                return Base64.encode(blob).replace("+", "-").replace("/", "_");
+            } catch (Error e) {
+                warning("Failed to generate token: %s", e.message);
+                return "";
+            }
         }
 
         // =========================================================================
@@ -89,40 +101,32 @@ namespace Spry.Authorisation {
          * Validates a token string and returns the parsed token.
          * 
          * This method:
-         * - Uses CryptographyProvider.unseal_then_verify_token()
+         * - Decrypts and verifies the token using CryptographyProvider
          * - Checks expiry
          * - Parses the JSON payload
          * 
-         * @param token_string The encrypted token string
+         * @param token_string The encrypted token string (URL-safe Base64)
          * @return The AuthorisationToken, or null if invalid/expired
          */
         public AuthorisationToken? parse_token(string token_string) {
             try {
+                // Decode Base64
+                var decoded = Base64.decode(token_string.replace("-", "+").replace("_", "/"));
+                
                 // Decrypt and verify the token
-                var result = _crypto.unseal_then_verify_token(token_string);
-
-                if (!result.is_valid) {
-                    return null;
-                }
-
-                // Check if token is expired
-                if (result.is_expired) {
-                    return null;
-                }
+                var json_str = _crypto.read(NAMESPACE, decoded);
 
-                // Get the payload
-                var payload = result.payload;
-                if (payload == null) {
+                if (json_str == null) {
                     return null;
                 }
 
                 // Parse the JSON
-                var token = AuthorisationToken.from_json_string((!)payload);
+                var token = AuthorisationToken.from_json_string((!)json_str);
                 if (token == null) {
                     return null;
                 }
 
-                // Double-check expiry from the token itself
+                // Check expiry from the token itself
                 if (token.is_expired()) {
                     return null;
                 }
@@ -136,39 +140,37 @@ namespace Spry.Authorisation {
         /**
          * Validates a token and returns detailed validation result.
          * 
-         * @param token_string The encrypted token string
+         * @param token_string The encrypted token string (URL-safe Base64)
          * @return A TokenValidationResult with status and token data
          */
         public TokenValidationResult validate_token(string token_string) {
-            // Decrypt and verify the token
-            var crypto_result = _crypto.unseal_then_verify_token(token_string);
-
-            if (!crypto_result.is_valid) {
-                return new TokenValidationResult.failure(
-                    crypto_result.error_message ?? "Invalid token"
-                );
-            }
-
-            if (crypto_result.is_expired) {
-                return new TokenValidationResult.failure("Token has expired", true);
-            }
+            try {
+                // Decode Base64
+                var decoded = Base64.decode(token_string.replace("-", "+").replace("_", "/"));
+                
+                // Decrypt and verify
+                var json_str = _crypto.read(NAMESPACE, decoded);
+
+                if (json_str == null) {
+                    return new TokenValidationResult.failure("Could not decrypt token");
+                }
 
-            var payload = crypto_result.payload;
-            if (payload == null) {
-                return new TokenValidationResult.failure("Empty token payload");
-            }
+                var token = AuthorisationToken.from_json_string((!)json_str);
+                if (token == null) {
+                    return new TokenValidationResult.failure("Failed to parse token payload");
+                }
 
-            var token = AuthorisationToken.from_json_string((!)payload);
-            if (token == null) {
-                return new TokenValidationResult.failure("Failed to parse token payload");
-            }
+                // Check expiry from the token itself
+                if (token.is_expired()) {
+                    return new TokenValidationResult.failure("Token has expired", true);
+                }
 
-            // Double-check expiry from the token itself
-            if (token.is_expired()) {
-                return new TokenValidationResult.failure("Token has expired", true);
+                return new TokenValidationResult.success(token);
+            } catch (CryptoError e) {
+                return new TokenValidationResult.failure(e.message);
+            } catch (Error e) {
+                return new TokenValidationResult.failure("Invalid token: %s".printf(e.message));
             }
-
-            return new TokenValidationResult.success(token);
         }
     }
 

+ 2 - 2
src/Component.vala

@@ -63,7 +63,7 @@ namespace Spry {
         private PathProvider _path_provider = inject<PathProvider>();
         private ContinuationProvider _continuation_provider = inject<ContinuationProvider>();
         private ComponentFactory _component_factory = inject<ComponentFactory>();
-        private CryptographyProvider _cryptography_provider = inject<CryptographyProvider>();
+        private ComponentContextService _context_service = inject<ComponentContextService>();
         private Catalogue<string, Renderable> _children = new Catalogue<string, Renderable>();
         private Dictionary<string, Component> _child_components = new Dictionary<string, Component>();
         private HashSet<Component> _global_sources = new HashSet<Component>();
@@ -312,7 +312,7 @@ namespace Spry {
                         context_key = context_key,
                         data = data
                     };
-                    var context_blob = _cryptography_provider.author_component_context_blob(context);
+                    var context_blob = _context_service.author_context_blob(context);
                     node.set_attribute("hx-get", _path_provider.get_action_path_with_context(component_name, component_action, context_blob));
                 }
                 else {

+ 115 - 0
src/ComponentContextService.vala

@@ -0,0 +1,115 @@
+using Invercargill;
+using Invercargill.Mapping;
+using Invercargill.DataStructures;
+using InvercargillJson;
+using Inversion;
+
+namespace Spry {
+
+    /**
+     * Service for managing component context blobs.
+     * 
+     * Component context is used to preserve component state across HTTP requests.
+     * When a component action is triggered, the context properties are serialized,
+     * signed, and encrypted into a blob that is included in the action URL.
+     * 
+     * This service handles:
+     * - Serialization/deserialization of ComponentContext to/from JSON
+     * - Signing and encryption via CryptographyProvider
+     * - URL-safe Base64 encoding for transport
+     */
+    public class ComponentContextService : Object {
+
+        private const string NAMESPACE = "component-context";
+
+        private CryptographyProvider _crypto = inject<CryptographyProvider>();
+
+        /**
+         * Authors a component context blob.
+         * 
+         * Serializes the context to JSON, signs and encrypts it using
+         * the CryptographyProvider, and returns a URL-safe Base64 string.
+         * 
+         * @param context The component context to author
+         * @return A URL-safe Base64 encoded blob string
+         * @throws Error on serialization or encryption failure
+         */
+        public string author_context_blob(ComponentContext context) throws Error {
+            var mapper = ComponentContext.get_mapper();
+            var properties = mapper.map_from(context);
+            var json = new JsonElement.from_properties(properties);
+            var json_string = json.stringify();
+            
+            var blob = _crypto.author(NAMESPACE, json_string);
+            return Base64.encode(blob).replace("+", "-").replace("/", "_");
+        }
+
+        /**
+         * Reads a component context blob.
+         * 
+         * Decodes the URL-safe Base64, decrypts and verifies using
+         * the CryptographyProvider, and deserializes to a ComponentContext.
+         * 
+         * @param blob The URL-safe Base64 encoded blob string
+         * @return The deserialized ComponentContext
+         * @throws Error on decryption, signature, or deserialization failure
+         */
+        public ComponentContext read_context_blob(string blob) throws Error {
+            var decoded = Base64.decode(blob.replace("-", "+").replace("_", "/"));
+            var json_string = _crypto.read(NAMESPACE, decoded);
+            
+            if (json_string == null) {
+                throw new ComponentError.INVALID_CONTEXT("Could not decrypt context blob");
+            }
+            
+            var mapper = ComponentContext.get_mapper();
+            var json = new JsonElement.from_string((!)json_string);
+            return mapper.materialise(json.as<JsonObject>());
+        }
+    }
+
+    /**
+     * Component context data preserved across HTTP requests.
+     * 
+     * Contains the component type, unique context key, timestamp,
+     * and serialized property data.
+     */
+    public class ComponentContext : Object {
+
+        /**
+         * The type name of the component.
+         */
+        public string type_name { get; set; }
+
+        /**
+         * Unique key for this context instance.
+         */
+        public string context_key { get; set; }
+
+        /**
+         * Timestamp when the context was created.
+         */
+        public DateTime timestamp { get; set; }
+
+        /**
+         * Serialized property data from the component.
+         */
+        public Properties data { get; set; }
+
+        /**
+         * Gets the property mapper for ComponentContext serialization.
+         * 
+         * Uses compact property names to minimize blob size.
+         */
+        public static PropertyMapper<ComponentContext> get_mapper() {
+            return PropertyMapper.build_for<ComponentContext>(cfg => {
+                cfg.map<string>("c", o => o.type_name, (o, v) => o.type_name = v);
+                cfg.map<string>("k", o => o.context_key, (o, v) => o.context_key = v);
+                cfg.map<string>("t", o => o.timestamp.format_iso8601(), (o, v) => o.timestamp = new DateTime.from_iso8601(v, new TimeZone.utc()));
+                cfg.map<Properties>("d", o => o.data, (o, v) => o.data = v);
+                cfg.set_constructor(() => new ComponentContext());
+            });
+        }
+    }
+
+}

+ 2 - 2
src/ComponentEndpoint.vala

@@ -9,7 +9,7 @@ namespace Spry {
     public class ComponentEndpoint : Object, Endpoint {
 
         private PathProvider component_uri_provider = inject<PathProvider>();
-        private CryptographyProvider cryptograpy_provider = inject<CryptographyProvider>();
+        private ComponentContextService context_service = inject<ComponentContextService>();
         private Scope scope = inject<Scope>();
 
         public async Astralis.HttpResult handle_request (HttpContext http_context, RouteContext route_context) throws Error {
@@ -29,7 +29,7 @@ namespace Spry {
             if(context_blob != null) {
                 ComponentContext context;
                 try {
-                    context = cryptograpy_provider.read_component_context_blob (context_blob);
+                    context = context_service.read_context_blob(context_blob);
                 }
                 catch(Error e) {
                     warning(@"Invalid component context: $(e.message)");

+ 62 - 164
src/CryptographyProvider.vala

@@ -1,58 +1,17 @@
 using Invercargill;
-using Invercargill.Mapping;
-using Invercargill.DataStructures;
-using InvercargillJson;
+
 namespace Spry {
 
     /**
-     * Result of token validation containing the payload and status information.
+     * CryptographyProvider provides low-level cryptographic operations.
+     * 
+     * This service handles signing and encryption using libsodium:
+     * - Ed25519 for digital signatures
+     * - X25519 for public-key encryption (sealing)
+     * 
+     * All data is signed then sealed (encrypt-then-MAC pattern).
+     * Namespaces are used to prevent cross-protocol attacks.
      */
-    public class TokenValidationResult : Object {
-        /**
-         * Whether the token was successfully decrypted and verified.
-         */
-        public bool is_valid { get; construct set; }
-
-        /**
-         * The decrypted payload string, or null if validation failed.
-         */
-        public string? payload { get; construct set; }
-
-        /**
-         * Error message describing why validation failed, or null if valid.
-         */
-        public string? error_message { get; construct set; }
-
-        /**
-         * Whether the token has expired (only meaningful when is_valid is true).
-         */
-        public bool is_expired { get; construct set; }
-
-        /**
-         * Creates a successful validation result.
-         */
-        public TokenValidationResult.success(string payload, bool expired = false) {
-            Object(
-                is_valid: true,
-                payload: payload,
-                error_message: null,
-                is_expired: expired
-            );
-        }
-
-        /**
-         * Creates a failed validation result.
-         */
-        public TokenValidationResult.failure(string error_message, bool expired = false) {
-            Object(
-                is_valid: false,
-                payload: null,
-                error_message: error_message,
-                is_expired: expired
-            );
-        }
-    }
-
     public class CryptographyProvider : Object {
 
         private uint8[] signing_secret_key;
@@ -61,7 +20,6 @@ namespace Spry {
         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);
@@ -69,137 +27,77 @@ namespace Spry {
             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);
-
-        }
-
-        public string author_component_context_blob(ComponentContext context) throws Error {
-
-            var mapper = ComponentContext.get_mapper();
-            var properties = mapper.map_from(context);
-            var json = new JsonElement.from_properties(properties);
-            var blob = json.stringify();
-            var signed = Sodium.Asymmetric.Signing.sign(blob.data, signing_secret_key);
-            var @sealed = Sodium.Asymmetric.Sealing.seal(signed, sealing_public_key);
-            return Base64.encode(@sealed).replace("+", "-").replace("/", "_");
-        }
-
-        public ComponentContext read_component_context_blob(string blob) throws Error {
-
-            var mapper = ComponentContext.get_mapper();
-            var signed = Sodium.Asymmetric.Sealing.unseal(Base64.decode(blob.replace("-", "+").replace("_", "/")), sealing_public_key, sealing_secret_key);
-            if(signed == null) {
-                throw new ComponentError.INVALID_CONTEXT("Could not unseal context");
-            }
-            var cleartext = Sodium.Asymmetric.Signing.verify(signed, signing_public_key);
-            if(signed == null) {
-                throw new ComponentError.INVALID_CONTEXT("Invalid context signature");
-            }
-            var json = new JsonElement.from_string(Wrap.byte_array(cleartext).to_raw_string());
-            var context = mapper.materialise(json.as<JsonObject>());
-            return context;
         }
 
         /**
-         * Creates a signed and encrypted session token.
-         *
-         * The token is created by first signing the payload with Ed25519,
-         * then encrypting the signed data with X25519-Seal. The result is
-         * URL-safe Base64 encoded.
-         *
-         * @param payload The JSON payload string containing session data
-         * @param expires_at Optional expiry timestamp to include in the token
-         * @return A URL-safe Base64 encoded token string
+         * Authors (signs and encrypts) data with a namespace.
+         * 
+         * The namespace is prepended to the data before signing to prevent
+         * cross-protocol attacks. The resulting blob contains:
+         * - The namespace (for verification during read)
+         * - The original data
+         * 
+         * The data is first signed with Ed25519, then encrypted with X25519-Seal.
+         * 
+         * @param namespace A unique identifier for the type of data being authored
+         * @param data The plaintext data to sign and encrypt
+         * @return The encrypted blob as binary data
          */
-        public string sign_then_seal_token(string payload, DateTime? expires_at = null) {
-            // Build the token JSON with optional expiry
-            string token_json;
-            if (expires_at != null) {
-                var exp_str = ((!)expires_at).format_iso8601();
-                // Escape the payload JSON for embedding in a JSON string
-                var escaped_payload = payload.replace("\\", "\\\\").replace("\"", "\\\"");
-                token_json = @"{\"payload\":\"$escaped_payload\",\"exp\":\"$exp_str\"}";
-            } else {
-                var escaped_payload = payload.replace("\\", "\\\\").replace("\"", "\\\"");
-                token_json = @"{\"payload\":\"$escaped_payload\"}";
-            }
-
-            // Sign then seal (same pattern as component context blobs)
-            var signed = Sodium.Asymmetric.Signing.sign(token_json.data, signing_secret_key);
+        public uint8[] author(string namespace, string data) throws Error {
+            // Prepend namespace to data
+            var namespaced_data = @"$namespace:$data";
+            
+            // Sign then seal
+            var signed = Sodium.Asymmetric.Signing.sign(namespaced_data.data, signing_secret_key);
             var @sealed = Sodium.Asymmetric.Sealing.seal(signed, sealing_public_key);
-
-            // URL-safe Base64 encoding
-            return Base64.encode(@sealed).replace("+", "-").replace("/", "_");
+            
+            return @sealed;
         }
 
         /**
-         * Decrypts and verifies a session token.
-         *
-         * The token is decrypted by first unsealing with X25519-Seal,
-         * then verifying the signature with Ed25519. If an expiry is
-         * present in the token, it is checked against the current time.
-         *
-         * @param token The URL-safe Base64 encoded token string
-         * @return A TokenValidationResult containing the validation outcome
+         * Reads (decrypts and verifies) data with namespace validation.
+         * 
+         * The blob is decrypted and the signature verified. If the namespace
+         * in the blob doesn't match the expected namespace, an error is thrown.
+         * 
+         * @param namespace The expected namespace for this data
+         * @param data The encrypted blob to decrypt and verify
+         * @return The original plaintext data (without namespace prefix), or null if validation fails
+         * @throws Error if decryption, signature verification, or namespace validation fails
          */
-        public TokenValidationResult unseal_then_verify_token(string token) {
-            // URL-safe Base64 decode
-            var decoded = Base64.decode(token.replace("-", "+").replace("_", "/"));
-
-            // Unseal the token
-            var signed = Sodium.Asymmetric.Sealing.unseal(decoded, sealing_public_key, sealing_secret_key);
+        public string? read(string namespace, uint8[] data) throws Error {
+            // Unseal the data
+            var signed = Sodium.Asymmetric.Sealing.unseal(data, sealing_public_key, sealing_secret_key);
             if (signed == null) {
-                return new TokenValidationResult.failure("Could not unseal token");
+                return null;
             }
-
+            
             // Verify the signature
             var cleartext = Sodium.Asymmetric.Signing.verify(signed, signing_public_key);
             if (cleartext == null) {
-                return new TokenValidationResult.failure("Invalid token signature");
+                return null;
             }
-
-            // Parse the token payload
-            try {
-                var json_string = Wrap.byte_array(cleartext).to_raw_string();
-                var json = new JsonElement.from_string(json_string);
-                var obj = json.as<JsonObject>();
-
-                // Check expiry if present
-                if (obj.has("exp")) {
-                    var exp_str = obj.get("exp").as<string>();
-                    var expires_at = new DateTime.from_iso8601(exp_str, new TimeZone.utc());
-                    if (expires_at.compare(new DateTime.now_utc()) <= 0) {
-                        // Token has expired - we still return the payload but mark as expired
-                        var payload_str = obj.get("payload").as<string>();
-                        return new TokenValidationResult.success(payload_str, true);
-                    }
-                }
-
-                // Extract and return the payload
-                var payload_str = obj.get("payload").as<string>();
-                return new TokenValidationResult.success(payload_str);
-            } catch (Error e) {
-                return new TokenValidationResult.failure("Invalid token format: %s".printf(e.message));
+            
+            // Convert to string
+            var namespaced_data = Wrap.byte_array(cleartext).to_raw_string();
+            
+            // Validate and strip namespace prefix
+            var expected_prefix = @"$namespace:";
+            if (!namespaced_data.has_prefix(expected_prefix)) {
+                throw new CryptoError.NAMESPACE_MISMATCH(@"Expected namespace '$namespace' but found different namespace");
             }
+            
+            return namespaced_data.substring(expected_prefix.length);
         }
     }
 
-    public class ComponentContext {
-
-        public string type_name { get; set; }
-        public string context_key { get; set; }
-        public DateTime timestamp { get; set; }
-        public Properties data { get; set; }
-
-        public static PropertyMapper<ComponentContext> get_mapper() {
-            return PropertyMapper.build_for<ComponentContext>(cfg => {
-                cfg.map<string>("c", o => o.type_name, (o, v) => o.type_name = v);
-                cfg.map<string>("k", o => o.context_key, (o, v) => o.context_key = v);
-                cfg.map<string>("t", o => o.timestamp.format_iso8601(), (o, v) => o.timestamp = new DateTime.from_iso8601(v, new TimeZone.utc()));
-                cfg.map<Properties>("d", o => o.data, (o, v) => o.data = v);
-                cfg.set_constructor(() => new ComponentContext());
-            });
-        }
-
+    /**
+     * Error domain for cryptographic operations.
+     */
+    public errordomain CryptoError {
+        NAMESPACE_MISMATCH,
+        DECRYPTION_FAILED,
+        SIGNATURE_INVALID
     }
 
-}
+}

+ 1 - 0
src/Spry.vala

@@ -7,6 +7,7 @@ namespace Spry {
         public void register_components (Container container) throws Error {
             container.register_singleton<PathProvider>();
             container.register_startup<CryptographyProvider>();
+            container.register_startup<ComponentContextService>();
             container.register_scoped<ComponentFactory>();
 
             container.register_startup<ContinuationProvider>()

+ 1 - 0
src/meson.build

@@ -12,6 +12,7 @@ sources = files(
     'ContinuationProvider.vala',
     'ContinuationContext.vala',
     'CryptographyProvider.vala',
+    'ComponentContextService.vala',
     'Static/StaticResource.vala',
     'Static/MemoryStaticResource.vala',
     'Static/FileStaticResource.vala',