netfishing/network/identity_backup_service.gd

214 lines
6.5 KiB
GDScript3
Raw Normal View History

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