|
|
@@ -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
|
|
|
}
|
|
|
|
|
|
-}
|
|
|
+}
|