291 lines
9 KiB
GDScript
291 lines
9 KiB
GDScript
class_name LocalSigningIdentityStore
|
|
extends Node
|
|
|
|
const FORMAT_VERSION: int = 1
|
|
|
|
var fingerprint: String = ""
|
|
var public_pem: String = ""
|
|
var error_message: String = ""
|
|
var _private_key: CryptoKey
|
|
var _prefix: String = ""
|
|
var _allow_generation: bool = true
|
|
var _storage_directory: String = ""
|
|
|
|
|
|
func configure(
|
|
prefix: String,
|
|
allow_generation: bool = true,
|
|
storage_directory: String = "",
|
|
) -> void:
|
|
_prefix = prefix
|
|
_allow_generation = allow_generation
|
|
_storage_directory = storage_directory.strip_edges()
|
|
|
|
|
|
func load_or_create() -> bool:
|
|
if _prefix.is_empty():
|
|
error_message = "Identity storage is not configured."
|
|
return false
|
|
var key_path := _path(".key")
|
|
var public_path := _path(".pub")
|
|
var metadata_path := _path(".json")
|
|
var any_exists := (
|
|
FileAccess.file_exists(key_path)
|
|
or FileAccess.file_exists(public_path)
|
|
or FileAccess.file_exists(metadata_path)
|
|
)
|
|
if any_exists:
|
|
return _load_existing()
|
|
if not _allow_generation:
|
|
return false
|
|
return _generate_new()
|
|
|
|
|
|
func is_ready() -> bool:
|
|
return _private_key != null and NetworkIdentityCrypto.valid_fingerprint(fingerprint)
|
|
|
|
|
|
func sign(domain: String, fields: Array) -> PackedByteArray:
|
|
return NetworkIdentityCrypto.sign_fields(_private_key, domain, fields)
|
|
|
|
|
|
func verify(domain: String, fields: Array, signature: PackedByteArray) -> bool:
|
|
return NetworkIdentityCrypto.verify_fields(
|
|
NetworkIdentityCrypto.load_public_key(public_pem),
|
|
domain,
|
|
fields,
|
|
signature,
|
|
)
|
|
|
|
|
|
func export_identity_material() -> Dictionary:
|
|
if not is_ready():
|
|
return {}
|
|
return {
|
|
"private_pem": _private_key.save_to_string(),
|
|
"public_pem": public_pem,
|
|
"fingerprint": fingerprint,
|
|
}
|
|
|
|
|
|
func install_identity_material(
|
|
private_pem: String,
|
|
public_value: String,
|
|
expected_fingerprint: String,
|
|
) -> Dictionary:
|
|
var normalized_public := NetworkIdentityCrypto.normalize_public_pem(public_value)
|
|
var derived := NetworkIdentityCrypto.fingerprint_public_pem(normalized_public)
|
|
var key := CryptoKey.new()
|
|
if (
|
|
derived != expected_fingerprint
|
|
or key.load_from_string(private_pem) != OK
|
|
):
|
|
return {"ok": false}
|
|
var probe := NetworkIdentityCrypto.sign_fields(
|
|
key, "identity_import_self_test", [derived]
|
|
)
|
|
if not NetworkIdentityCrypto.verify_fields(
|
|
NetworkIdentityCrypto.load_public_key(normalized_public),
|
|
"identity_import_self_test",
|
|
[derived],
|
|
probe,
|
|
):
|
|
return {"ok": false}
|
|
if derived == fingerprint:
|
|
return {"ok": true, "same": true, "archive_path": ""}
|
|
var had_active_identity := is_ready()
|
|
var archive := _archive_current_identity() if had_active_identity else ""
|
|
if had_active_identity and archive.is_empty():
|
|
return {"ok": false}
|
|
var metadata := {
|
|
"format_version": FORMAT_VERSION,
|
|
"algorithm": NetworkIdentityCrypto.ALGORITHM,
|
|
"fingerprint": derived,
|
|
"created_at_unix": int(Time.get_unix_time_from_system()),
|
|
}
|
|
if (
|
|
not _write_atomic(_path(".key"), private_pem)
|
|
or not _write_atomic(_path(".pub"), normalized_public)
|
|
or not _write_atomic(_path(".json"), JSON.stringify(metadata, "\t"))
|
|
):
|
|
if not archive.is_empty():
|
|
_restore_archive(archive)
|
|
_load_existing()
|
|
return {"ok": false}
|
|
FileAccess.set_unix_permissions(_path(".key"), 384)
|
|
if not _load_existing() or fingerprint != derived:
|
|
if not archive.is_empty():
|
|
_restore_archive(archive)
|
|
_load_existing()
|
|
return {"ok": false}
|
|
return {"ok": true, "same": false, "archive_path": archive}
|
|
|
|
|
|
func _generate_new() -> bool:
|
|
var key := Crypto.new().generate_rsa(3072)
|
|
if key == null:
|
|
error_message = "A signing identity could not be generated."
|
|
return false
|
|
var private_text := key.save_to_string()
|
|
var public_text := NetworkIdentityCrypto.normalize_public_pem(
|
|
key.save_to_string(true)
|
|
)
|
|
var derived := NetworkIdentityCrypto.fingerprint_public_pem(public_text)
|
|
var probe := NetworkIdentityCrypto.sign_fields(key, "identity_self_test", [derived])
|
|
var public_key := NetworkIdentityCrypto.load_public_key(public_text)
|
|
if (
|
|
not NetworkIdentityCrypto.valid_fingerprint(derived)
|
|
or not NetworkIdentityCrypto.verify_fields(
|
|
public_key, "identity_self_test", [derived], probe
|
|
)
|
|
):
|
|
error_message = "The generated signing identity failed verification."
|
|
return false
|
|
var metadata := {
|
|
"format_version": FORMAT_VERSION,
|
|
"algorithm": NetworkIdentityCrypto.ALGORITHM,
|
|
"fingerprint": derived,
|
|
"created_at_unix": int(Time.get_unix_time_from_system()),
|
|
}
|
|
if (
|
|
not _write_atomic(_path(".key"), private_text)
|
|
or not _write_atomic(_path(".pub"), public_text)
|
|
or not _write_atomic(_path(".json"), JSON.stringify(metadata, "\t"))
|
|
):
|
|
error_message = "The signing identity could not be stored."
|
|
return false
|
|
FileAccess.set_unix_permissions(_path(".key"), 384)
|
|
return _load_existing()
|
|
|
|
|
|
func _load_existing() -> bool:
|
|
var required := [_path(".key"), _path(".pub"), _path(".json")]
|
|
for path: String in required:
|
|
if not FileAccess.file_exists(path):
|
|
error_message = "Identity recovery is required. An identity file is missing."
|
|
return false
|
|
var private_file := FileAccess.open(_path(".key"), FileAccess.READ)
|
|
var public_file := FileAccess.open(_path(".pub"), FileAccess.READ)
|
|
var metadata_file := FileAccess.open(_path(".json"), FileAccess.READ)
|
|
if private_file == null or public_file == null or metadata_file == null:
|
|
error_message = "Identity recovery is required. Identity files could not be read."
|
|
return false
|
|
var private_text := private_file.get_as_text()
|
|
var loaded_public := NetworkIdentityCrypto.normalize_public_pem(public_file.get_as_text())
|
|
var metadata_text := metadata_file.get_as_text()
|
|
var json := JSON.new()
|
|
if json.parse(metadata_text) != OK or typeof(json.data) != TYPE_DICTIONARY:
|
|
error_message = "Identity recovery is required. Identity metadata is damaged."
|
|
return false
|
|
var metadata: Dictionary = json.data
|
|
var derived := NetworkIdentityCrypto.fingerprint_public_pem(loaded_public)
|
|
if (
|
|
metadata.get("format_version") != FORMAT_VERSION
|
|
or metadata.get("algorithm") != NetworkIdentityCrypto.ALGORITHM
|
|
or metadata.get("fingerprint") != derived
|
|
):
|
|
error_message = "Identity recovery is required. Identity files do not match."
|
|
return false
|
|
var key := CryptoKey.new()
|
|
if key.load_from_string(private_text) != OK:
|
|
error_message = "Identity recovery is required. The private key is damaged."
|
|
return false
|
|
var probe := NetworkIdentityCrypto.sign_fields(key, "identity_self_test", [derived])
|
|
if not NetworkIdentityCrypto.verify_fields(
|
|
NetworkIdentityCrypto.load_public_key(loaded_public),
|
|
"identity_self_test",
|
|
[derived],
|
|
probe,
|
|
):
|
|
error_message = "Identity recovery is required. The key pair does not match."
|
|
return false
|
|
_private_key = key
|
|
public_pem = loaded_public
|
|
fingerprint = derived
|
|
error_message = ""
|
|
return true
|
|
|
|
|
|
func _write_atomic(path: String, content: String) -> bool:
|
|
var temporary := path + ".tmp"
|
|
var file := FileAccess.open(temporary, FileAccess.WRITE)
|
|
if file == null:
|
|
return false
|
|
file.store_string(content)
|
|
file.flush()
|
|
var write_error := file.get_error()
|
|
file.close()
|
|
if write_error != OK:
|
|
return false
|
|
if FileAccess.file_exists(path):
|
|
var backup := path + ".backup"
|
|
_remove_if_present(backup)
|
|
if not _rename(path, backup):
|
|
return false
|
|
if not _rename(temporary, path):
|
|
_rename(backup, path)
|
|
return false
|
|
_remove_if_present(backup)
|
|
return true
|
|
return _rename(temporary, path)
|
|
|
|
|
|
func _path(extension: String) -> String:
|
|
var filename: String = "%s%s" % [_prefix, extension]
|
|
return (
|
|
_storage_directory.path_join(filename)
|
|
if not _storage_directory.is_empty()
|
|
else "user://%s" % filename
|
|
)
|
|
|
|
|
|
func identity_type() -> String:
|
|
return "player" if _prefix == "player_identity" else "host"
|
|
|
|
|
|
func _archive_current_identity() -> String:
|
|
var timestamp := Time.get_datetime_string_from_system().replace(":", "-")
|
|
var archive := ProjectSettings.globalize_path(
|
|
"user://identity-recovery/%s-%s" % [_prefix, timestamp]
|
|
)
|
|
if DirAccess.make_dir_recursive_absolute(archive) != OK:
|
|
return ""
|
|
for extension: String in [".key", ".pub", ".json"]:
|
|
var source := _path(extension)
|
|
var bytes := PortableFileGuard.read_bytes(source, 1024 * 1024)
|
|
if bytes.is_empty():
|
|
return ""
|
|
var file := FileAccess.open(archive.path_join(_prefix + extension), FileAccess.WRITE)
|
|
if file == null:
|
|
return ""
|
|
file.store_buffer(bytes)
|
|
file.close()
|
|
FileAccess.set_unix_permissions(
|
|
archive.path_join(_prefix + ".key"), 384
|
|
)
|
|
return archive
|
|
|
|
|
|
func _restore_archive(archive: String) -> void:
|
|
for extension: String in [".key", ".pub", ".json"]:
|
|
var source := archive.path_join(_prefix + extension)
|
|
var bytes := PortableFileGuard.read_bytes(source, 1024 * 1024)
|
|
if bytes.is_empty():
|
|
continue
|
|
var file := FileAccess.open(_path(extension), FileAccess.WRITE)
|
|
if file != null:
|
|
file.store_buffer(bytes)
|
|
file.close()
|
|
FileAccess.set_unix_permissions(_path(".key"), 384)
|
|
|
|
|
|
func _rename(from_path: String, to_path: String) -> bool:
|
|
return DirAccess.rename_absolute(
|
|
ProjectSettings.globalize_path(from_path),
|
|
ProjectSettings.globalize_path(to_path),
|
|
) == OK
|
|
|
|
|
|
func _remove_if_present(path: String) -> void:
|
|
if FileAccess.file_exists(path):
|
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
|