class_name IdentityBackupService extends Node signal operation_finished(success: bool, message: String) const MAGIC := "NETFISHING_IDENTITY_BACKUP" const FORMAT_VERSION := 1 const MAX_BACKUP_BYTES := 256 * 1024 const MIN_PASSPHRASE_LENGTH := 12 const MAX_PASSPHRASE_LENGTH := 256 var _data_root: PlayerDataRoot var _player_identity: PlayerIdentityStore var _host_identity: HostIdentityStore func setup( data_root: PlayerDataRoot, player_identity: PlayerIdentityStore, host_identity: HostIdentityStore, ) -> void: _data_root = data_root _player_identity = player_identity _host_identity = host_identity func default_export_path(identity_type: String) -> String: var store: LocalSigningIdentityStore = _store(identity_type) if store == null: return "" if not store.is_ready() and not store.load_or_create(): return "" var timestamp: String = Time.get_datetime_string_from_system().replace(":", "-") return _data_root.identity_backup_directory().path_join( "%s-%s-%s.nfidentity" % [ identity_type, NetworkIdentityCrypto.compact_suffix(store.fingerprint), timestamp, ] ) func export_backup( identity_type: String, path: String, passphrase: String, confirmation: String, ) -> bool: if not _valid_passphrase(passphrase) or passphrase != confirmation: return _finish(false, "Passphrases must match and contain at least 12 characters.") if FileAccess.file_exists(path): return _finish(false, "Choose a new backup filename.") var store: LocalSigningIdentityStore = _store(identity_type) if store == null or (not store.is_ready() and not store.load_or_create()): return _finish(false, "The active identity is unavailable.") var material: Dictionary = store.export_identity_material() var proof: PackedByteArray = store.sign("identity_backup_self_test", [ identity_type, material["fingerprint"], ]) var envelope: Dictionary = { "magic": MAGIC, "format_version": FORMAT_VERSION, "identity_type": identity_type, "algorithm": NetworkIdentityCrypto.ALGORITHM, "private_pem": material["private_pem"], "public_pem": material["public_pem"], "fingerprint": material["fingerprint"], "created_at_unix": int(Time.get_unix_time_from_system()), "source_device_id": _data_root.device_id, "self_signature": Marshalls.raw_to_base64(proof), } DirAccess.make_dir_recursive_absolute(path.get_base_dir()) var file: FileAccess = FileAccess.open_encrypted_with_pass( path, FileAccess.WRITE, passphrase ) if file == null: return _finish(false, "Could not write this identity backup.") file.store_string(JSON.stringify(envelope)) file.flush() var ok: bool = file.get_error() == OK file.close() if not ok: return _finish(false, "Could not write this identity backup.") var verified: Dictionary = inspect_backup(path, passphrase, identity_type) if not bool(verified.get("ok", false)): DirAccess.remove_absolute(path) return _finish(false, "Could not verify this identity backup.") return _finish(true, "Encrypted identity backup created.") func inspect_backup( path: String, passphrase: String, expected_type: String, ) -> Dictionary: if ( not _valid_passphrase(passphrase) or not FileAccess.file_exists(path) or FileAccess.get_size(path) > MAX_BACKUP_BYTES ): return {"ok": false} var file: FileAccess = FileAccess.open_encrypted_with_pass( path, FileAccess.READ, passphrase ) if file == null: return {"ok": false} var text: String = file.get_as_text() file.close() var json: JSON = JSON.new() if json.parse(text) != OK or typeof(json.data) != TYPE_DICTIONARY: return {"ok": false} var data: Dictionary = json.data if ( data.get("magic") != MAGIC or data.get("format_version") != FORMAT_VERSION or data.get("identity_type") != expected_type or data.get("algorithm") != NetworkIdentityCrypto.ALGORITHM ): return {"ok": false} var private_pem: String = str(data.get("private_pem", "")) var public_pem: String = NetworkIdentityCrypto.normalize_public_pem( str(data.get("public_pem", "")) ) var fingerprint: String = str(data.get("fingerprint", "")) if ( private_pem.length() > 128 * 1024 or public_pem.length() > 32 * 1024 or NetworkIdentityCrypto.fingerprint_public_pem(public_pem) != fingerprint ): return {"ok": false} var key: CryptoKey = CryptoKey.new() if key.load_from_string(private_pem) != OK: return {"ok": false} var signature: PackedByteArray = Marshalls.base64_to_raw( str(data.get("self_signature", "")) ) if not NetworkIdentityCrypto.verify_fields( NetworkIdentityCrypto.load_public_key(public_pem), "identity_backup_self_test", [expected_type, fingerprint], signature, ): return {"ok": false} var fresh: PackedByteArray = NetworkIdentityCrypto.sign_fields( key, "identity_import_self_test", [fingerprint] ) if not NetworkIdentityCrypto.verify_fields( NetworkIdentityCrypto.load_public_key(public_pem), "identity_import_self_test", [fingerprint], fresh, ): return {"ok": false} return { "ok": true, "identity_type": expected_type, "fingerprint": fingerprint, "private_pem": private_pem, "public_pem": public_pem, } func import_backup( identity_type: String, path: String, passphrase: String, confirmed_replacement: bool, ) -> Dictionary: var inspected: Dictionary = inspect_backup(path, passphrase, identity_type) if not bool(inspected.get("ok", false)): _finish(false, "Could not open this identity backup.") return {"ok": false} var store: LocalSigningIdentityStore = _store(identity_type) var incoming: String = str(inspected["fingerprint"]) if store.fingerprint == incoming: _finish(true, "This identity is already active.") return {"ok": true, "same": true} if not confirmed_replacement: return { "ok": false, "requires_confirmation": true, "current_fingerprint": store.fingerprint, "incoming_fingerprint": incoming, } var result: Dictionary = store.install_identity_material( str(inspected["private_pem"]), str(inspected["public_pem"]), incoming, ) _finish( bool(result.get("ok", false)), "Identity imported. Restart NETfishing before multiplayer." if bool(result.get("ok", false)) else "Could not install this identity backup.", ) return result func _store(identity_type: String) -> LocalSigningIdentityStore: if identity_type == "player": return _player_identity if identity_type == "host": return _host_identity return null func _valid_passphrase(value: String) -> bool: return value.length() >= MIN_PASSPHRASE_LENGTH and value.length() <= MAX_PASSPHRASE_LENGTH func _finish(success: bool, message: String) -> bool: operation_finished.emit(success, message) return success