NetworkStorage encryption helpers for v2. Implements sealing requests with RSA-OAEP-wrapped AES-256-GCM, building AAD, reading runtime public key config, and unsealing encrypted v2 responses using a provided AES context.
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;
}
}