Code/Endpoints/NetworkStorageEncryptionV2.cs

NetworkStorage encryption helpers for protocol version 2. Implements sealing requests by generating an AES-256-GCM key wrapped with RSA-OAEP-SHA256, produces AAD, and unseals encrypted v2 responses using the stored AES key context. Also validates and reads runtime RSA public keys from signed config and normalizes fingerprints.

Native Interop
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.Json;

namespace Sandbox;

// Encryption v2: RSA-OAEP wraps a fresh AES-256 key per request and the response is sealed under that key. The server
// request key comes only from a trusted signed config (see NetworkStorageRuntimeSecurityConfigLoad.cs).
public static partial class NetworkStorage
{
	private const string V2Algorithm = "rsa-oaep-256+aes-256-gcm";
	private const string V2ResponseAadLabel = "sboxcool.network-storage.encrypted-response.v2";

	// The server request key from a trusted config's `encryption` block.
	internal sealed record RuntimeEncryptionKey( string KeyId, string Fingerprint, byte[] Modulus, byte[] Exponent );

	// What a response needs to be opened: the request's AES key, nonce, and keyId.
	internal sealed record EncryptedResponseContext( byte[] AesKey, string Nonce, string KeyId );

	// Returns why an endpoint call must not be sent, or null when it may proceed. Once a trusted config is held, it
	// decides the mode. Until then a refusal blocks every call, and a pin blocks every call with no trusted config.
	private static SecurityConfigRefusal EndpointSecurityRefusal()
	{
		if ( _runtimeSecurityConfig?.Trusted == true )
			return null;

		// A plain call could leak data the owner meant to encrypt, so a refused config blocks all calls.
		if ( !string.IsNullOrEmpty( _runtimeSecurityConfig?.RefusalCode ) )
			return new SecurityConfigRefusal( _runtimeSecurityConfig.RefusalCode, _runtimeSecurityConfig.RefusalMessage );

		return SecuritySigningKeyPin.Length > 0
			? new SecurityConfigRefusal( "ENCRYPTION_KEY_UNVERIFIED", "no trusted security config has been loaded" )
			: null;
	}

	// Reads the signed `encryption` block. The fingerprint must be the SPKI hash of the key it accompanies, and the
	// keyId its prefix, so a config cannot pair a key with a fingerprint that does not match it.
	internal static bool TryReadEncryptionKey( JsonElement block, out RuntimeEncryptionKey key, out string error )
	{
		key = null;
		error = "";
		if ( block.ValueKind != JsonValueKind.Object )
		{
			error = "encryption block is not an object";
			return false;
		}

		if ( ReadString( block, "algorithm" ) != V2Algorithm )
		{
			error = "encryption algorithm is not supported";
			return false;
		}

		if ( !block.TryGetProperty( "publicKeyJwk", out var jwk ) || jwk.ValueKind != JsonValueKind.Object )
		{
			error = "encryption publicKeyJwk is missing";
			return false;
		}

		var modulus = NetworkStorageRsaKey.NormalizeModulus( NetworkStorageSecuritySignature.Base64UrlDecode( ReadString( jwk, "n" ) ) );
		var exponent = NetworkStorageSecuritySignature.Base64UrlDecode( ReadString( jwk, "e" ) );
		if ( modulus is null || exponent.Length == 0 )
		{
			error = "encryption publicKeyJwk is not an RSA-2048 key";
			return false;
		}

		var fingerprint = NormalizeSecurityKeyFingerprint( ReadString( block, "fingerprint" ) );
		if ( fingerprint != NetworkStorageRsaKey.Fingerprint( modulus, exponent ) )
		{
			error = "encryption fingerprint does not match publicKeyJwk";
			return false;
		}

		var keyId = NormalizeSecurityKeyFingerprint( ReadString( block, "keyId" ) );
		if ( keyId.Length != 32 || !fingerprint.StartsWith( keyId, StringComparison.Ordinal ) )
		{
			error = "encryption keyId is not the first 32 characters of its fingerprint";
			return false;
		}

		key = new RuntimeEncryptionKey( keyId, fingerprint, modulus, exponent );
		return true;
	}

	// Seals a payload under a fresh AES key wrapped to the trusted server key. There is no signature: GCM under the
	// wrapped key authenticates the envelope, and the AAD binds project, endpoint, identity, nonce, and keyId.
	private static Dictionary<string, object> SealEndpointRequestV2( RuntimeEncryptionKey key, string slug, Dictionary<string, object> payload,
		RuntimeAuthSession session, out EncryptedResponseContext responseContext )
	{
		var identityType = session is not null ? "session" : "steam";
		var identity = session is not null ? session.Id : Game.SteamId.ToString();
		return SealRequestV2( key, ProjectId, slug, identityType, identity, payload, out responseContext );
	}

	// Pure sealing core. Project and identity are inputs, so the envelope can be checked without a live game.
	internal static Dictionary<string, object> SealRequestV2( RuntimeEncryptionKey key, string projectId, string slug, string identityType,
		string identity, Dictionary<string, object> payload, out EncryptedResponseContext responseContext )
	{
		var aesKey = NetworkStorageSecureRandom.Bytes( 32 );
		var iv = NetworkStorageSecureRandom.Bytes( 12 );
		var seed = NetworkStorageSecureRandom.Bytes( 32 );
		var nonce = Base64UrlEncode( NetworkStorageSecureRandom.Bytes( 12 ) );

		var aad = EncryptedRequestAadV2( projectId, slug, identityType, identity, nonce, key.KeyId );
		var plaintext = Encoding.UTF8.GetBytes( JsonSerializer.Serialize( payload ) );
		var (ciphertext, tag) = Aes256GcmEncrypt( aesKey, iv, plaintext, aad );

		var envelope = new Dictionary<string, object>
		{
			["version"] = "2",
			["algorithm"] = V2Algorithm,
			["projectId"] = projectId,
			["keyId"] = key.KeyId,
			["nonce"] = nonce,
			["wrappedKey"] = Base64UrlEncode( NetworkStorageRsaKey.OaepSha256Encrypt( aesKey, key.Modulus, key.Exponent, seed ) ),
			["iv"] = Base64UrlEncode( iv ),
			["tag"] = Base64UrlEncode( tag ),
			["encryptedPayload"] = Base64UrlEncode( ciphertext )
		};
		if ( !string.IsNullOrEmpty( slug ) )
			envelope["endpointSlug"] = slug;
		envelope[identityType == "session" ? "sessionId" : "steamId"] = identity;

		responseContext = new EncryptedResponseContext( aesKey, nonce, key.KeyId );
		return envelope;
	}

	internal static byte[] EncryptedRequestAadV2( string projectId, string slug, string identityType, string identity, string nonce, string keyId )
	{
		var binding = new Dictionary<string, object>
		{
			["version"] = "2",
			["algorithm"] = V2Algorithm,
			["projectId"] = projectId,
			["identityType"] = identityType,
			["identity"] = identity,
			["nonce"] = nonce,
			["keyId"] = keyId
		};
		if ( !string.IsNullOrEmpty( slug ) )
			binding["endpointSlug"] = slug;
		return Encoding.UTF8.GetBytes( StableStringify( binding ) );
	}

	// A v2 request requires an authenticated response. Plain failures may report a pre-decryption error
	// (for example a stale key); they may trigger a verified config refresh but can never become a successful result.
	internal static string UnsealEndpointResponse( EncryptedResponseContext context, string slug, string raw )
	{
		try
		{
			using var doc = JsonDocument.Parse( raw ?? "" );
			var root = doc.RootElement;
			if ( !IsSealedV2Response( root ) )
			{
				if ( context is null || (root.ValueKind == JsonValueKind.Object
					&& root.TryGetProperty( "ok", out var ok ) && ok.ValueKind == JsonValueKind.False) )
					return raw;
			}
			else if ( context is not null )
			{
				var body = OpenSealedV2Response( context, root );
				if ( body is not null )
					return body;
			}
		}
		catch ( JsonException )
		{
			if ( context is null )
				return raw;
		}

		RecordEndpointError( slug, "ENCRYPTED_RESPONSE_INVALID", "Encrypted response could not be opened." );
		return null;
	}

	// Returns a v2 seal as-is. The host proxy uses this: it has no key, so it relays the ciphertext unparsed.
	internal static bool TryRelaySealedResponse( string raw, out JsonElement? relayed )
	{
		relayed = null;
		var trimmed = (raw ?? "").TrimStart();
		if ( trimmed.Length == 0 || trimmed[0] != '{' )
			return false;

		try
		{
			using var doc = JsonDocument.Parse( trimmed );
			if ( !IsSealedV2Response( doc.RootElement ) )
				return false;
			relayed = doc.RootElement.Clone();
			return true;
		}
		catch ( JsonException )
		{
			return false;
		}
	}

	private static bool IsSealedV2Response( JsonElement root )
		=> root.ValueKind == JsonValueKind.Object
			&& root.TryGetProperty( "encrypted", out var encrypted ) && encrypted.ValueKind == JsonValueKind.True
			&& root.TryGetProperty( "v", out var version ) && version.ValueKind == JsonValueKind.Number
			&& version.TryGetInt32( out var v ) && v == 2;

	private static string OpenSealedV2Response( EncryptedResponseContext context, JsonElement root )
	{
		var iv = NetworkStorageSecuritySignature.Base64UrlDecode( ReadString( root, "iv" ) );
		var tag = NetworkStorageSecuritySignature.Base64UrlDecode( ReadString( root, "tag" ) );
		var ciphertext = NetworkStorageSecuritySignature.Base64UrlDecode( ReadString( root, "payload" ) );
		if ( iv.Length != 12 || tag.Length != 16 || ciphertext.Length == 0 )
			return null;

		var aad = Encoding.UTF8.GetBytes( $"{V2ResponseAadLabel}\0{context.Nonce}\0{context.KeyId}" );
		var plaintext = Aes256GcmDecrypt( context.AesKey, iv, ciphertext, tag, aad );
		return plaintext is null ? null : Encoding.UTF8.GetString( plaintext );
	}

	// Pins and fingerprints compare as lowercase hex. Whitespace and ':' separators are ignored, so pasted values work.
	internal static string NormalizeSecurityKeyFingerprint( string value )
	{
		var sb = new StringBuilder();
		foreach ( var ch in value ?? "" )
		{
			if ( char.IsWhiteSpace( ch ) || ch == ':' )
				continue;
			sb.Append( char.ToLowerInvariant( ch ) );
		}
		return sb.ToString();
	}

	private static bool IsSha256Hex( string value )
	{
		if ( value.Length != 64 )
			return false;
		foreach ( var ch in value )
		{
			var isHex = (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f');
			if ( !isHex )
				return false;
		}
		return true;
	}

	// An empty value clears the pin. A value that is not a SHA-256 fingerprint is kept, so every config fails the pin
	// check and encrypted calls are refused until the owner fixes it.
	private static void SetSecuritySigningKeyPin( string fingerprint )
	{
		var normalized = NormalizeSecurityKeyFingerprint( fingerprint );
		if ( normalized.Length > 0 && !IsSha256Hex( normalized ) )
			Log.Warning( "[NetworkStorage] securityKeyFingerprint is not a 64-character SHA-256 hex fingerprint; encrypted calls will be refused" );
		SecuritySigningKeyPin = normalized;
	}
}