877 lines
32 KiB
GDScript
877 lines
32 KiB
GDScript
@tool
|
|
class_name McpReleaseVerifier
|
|
extends RefCounted
|
|
|
|
## Pure, static verification of a signed v4 release: the canonical manifest,
|
|
## its RSA signature, the archive, and the tree hash the installer compares
|
|
## after extraction and again after restart.
|
|
##
|
|
## Every rule here mirrors `src/godot_ai/release_verify.py`. The two must stay
|
|
## byte-for-byte compatible: the release pipeline produces the manifest with
|
|
## Python's `_canonical()` and the installed-tree hash with Python's
|
|
## `hash_tree()`, and this file re-derives both from raw bytes. Every function
|
|
## fails closed with a specific error string and never raises.
|
|
|
|
const REPOSITORY := "hi-godot/godot-ai"
|
|
const CHANNEL := "stable"
|
|
const ASSET_NAME := "godot-ai-v4-plugin.zip"
|
|
const PLUGIN_PREFIX := "addons/godot_ai/"
|
|
const PLUGIN_CONFIG := "addons/godot_ai/plugin.cfg"
|
|
const SCHEMA_VERSION := 1
|
|
const MAX_FILES := 4096
|
|
const MAX_FILE_SIZE := 64 * 1024 * 1024
|
|
const MAX_ARCHIVE_SIZE := 64 * 1024 * 1024
|
|
const MAX_TREE_SIZE := MAX_ARCHIVE_SIZE
|
|
const MAX_MANIFEST_SIZE := 1024 * 1024
|
|
## Signature length for the production RSA-4096 key. The check itself is
|
|
## derived from the supplied key's modulus so a smaller injected test key
|
|
## still verifies its own (shorter) signatures.
|
|
const SIGNATURE_SIZE := 512
|
|
const MIN_RSA_MODULUS_BYTES := 256
|
|
## Largest integer JSON can round-trip through Godot's double-based parser.
|
|
const MAX_SAFE_INTEGER := 9007199254740992
|
|
const READ_CHUNK := 1024 * 1024
|
|
|
|
const ROOT_KEYS := [
|
|
"asset", "channel", "inventory", "repository", "schema_version", "source_commit", "tag", "version",
|
|
]
|
|
const ASSET_KEYS := ["name", "sha256", "size"]
|
|
const ROW_KEYS := ["path", "sha256", "size"]
|
|
const RESERVED_NAMES := [
|
|
"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$",
|
|
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
|
"COM¹", "COM²", "COM³",
|
|
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
|
"LPT¹", "LPT²", "LPT³",
|
|
]
|
|
const _UNSAFE_PATH_CHARS := "<>:\"|?*"
|
|
## DER-encoded AlgorithmIdentifier for rsaEncryption with NULL parameters.
|
|
const _RSA_ALGORITHM := [0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00]
|
|
|
|
|
|
# ----- public API -------------------------------------------------------
|
|
|
|
|
|
## Authenticate raw manifest bytes against a detached signature and the
|
|
## caller's explicit expectations. `expected` carries `repository` and
|
|
## `channel`, optionally `version` (must equal the manifest's), and optionally
|
|
## `current_version` (the manifest must be strictly newer). Returns
|
|
## `{"ok": bool, "error": String, "manifest": Dictionary}`; the returned
|
|
## manifest has integer sizes normalised to `int`.
|
|
static func verify_manifest(
|
|
manifest_bytes: PackedByteArray,
|
|
signature: PackedByteArray,
|
|
expected: Dictionary,
|
|
public_key_pem: String,
|
|
) -> Dictionary:
|
|
var result := {"ok": false, "error": "", "manifest": {}}
|
|
if manifest_bytes.is_empty() or manifest_bytes.size() > MAX_MANIFEST_SIZE:
|
|
result.error = "manifest: expected 1..%d bytes, got %d" % [MAX_MANIFEST_SIZE, manifest_bytes.size()]
|
|
return result
|
|
if not is_valid_utf8(manifest_bytes):
|
|
result.error = "manifest: not valid UTF-8"
|
|
return result
|
|
var json := JSON.new()
|
|
var parse_error := json.parse(manifest_bytes.get_string_from_utf8())
|
|
if parse_error != OK:
|
|
result.error = "manifest: invalid JSON: %s (line %d)" % [json.get_error_message(), json.get_error_line()]
|
|
return result
|
|
var canonical := canonical_json(json.data)
|
|
if not bool(canonical.ok):
|
|
result.error = "manifest: encoding is not canonical JSON: %s" % str(canonical.error)
|
|
return result
|
|
if canonical.bytes != manifest_bytes:
|
|
result.error = "manifest: encoding is not canonical JSON"
|
|
return result
|
|
if not json.data is Dictionary:
|
|
result.error = "manifest: expected a JSON object"
|
|
return result
|
|
var validated := validate_manifest_dict(json.data)
|
|
if not bool(validated.ok):
|
|
result.error = str(validated.error)
|
|
return result
|
|
var manifest: Dictionary = validated.manifest
|
|
var identity_error := _check_identity(manifest, expected)
|
|
if not identity_error.is_empty():
|
|
result.error = identity_error
|
|
return result
|
|
var signature_error := verify_signature(manifest_bytes, signature, public_key_pem)
|
|
if not signature_error.is_empty():
|
|
result.error = signature_error
|
|
return result
|
|
result.ok = true
|
|
result.manifest = manifest
|
|
return result
|
|
|
|
|
|
## Check a zip on disk against an already-verified manifest: whole-file size
|
|
## and SHA-256 against `manifest.asset`, then the entry list against the
|
|
## inventory (exact names, exact order, no directory entries), then every
|
|
## entry's size and SHA-256 against its row. Returns `{"ok", "error"}`.
|
|
static func verify_archive(zip_path: String, manifest: Dictionary) -> Dictionary:
|
|
var result := {"ok": false, "error": ""}
|
|
var validated := validate_manifest_dict(manifest)
|
|
if not bool(validated.ok):
|
|
result.error = str(validated.error)
|
|
return result
|
|
var normalised: Dictionary = validated.manifest
|
|
var asset: Dictionary = normalised["asset"]
|
|
var hashed := sha256_file(zip_path)
|
|
if not bool(hashed.ok):
|
|
result.error = "archive: %s" % str(hashed.error)
|
|
return result
|
|
if int(hashed["size"]) != int(asset["size"]):
|
|
result.error = "archive: size %d differs from signed manifest size %d" % [int(hashed["size"]), int(asset["size"])]
|
|
return result
|
|
if str(hashed.sha256) != str(asset["sha256"]):
|
|
result.error = "archive: SHA-256 differs from signed manifest"
|
|
return result
|
|
var reader := ZIPReader.new()
|
|
var open_error := reader.open(_absolute(zip_path))
|
|
if open_error != OK:
|
|
result.error = "archive: cannot open zip: %s" % error_string(open_error)
|
|
return result
|
|
var entries := reader.get_files()
|
|
var rows: Array = normalised["inventory"]
|
|
var expected_paths: Array[String] = []
|
|
var expected_rows := {}
|
|
for row in rows:
|
|
expected_paths.append(str(row["path"]))
|
|
expected_rows[str(row["path"])] = row
|
|
var listing_error := _check_entry_names(entries, expected_paths, expected_rows)
|
|
if not listing_error.is_empty():
|
|
reader.close()
|
|
result.error = listing_error
|
|
return result
|
|
var total := 0
|
|
var config := PackedByteArray()
|
|
for entry in entries:
|
|
var row: Dictionary = expected_rows[entry]
|
|
var data := reader.read_file(entry)
|
|
if data.size() != int(row["size"]):
|
|
reader.close()
|
|
result.error = "archive entry %s: size %d differs from inventory size %d" % [entry, data.size(), int(row["size"])]
|
|
return result
|
|
if sha256_bytes(data) != str(row["sha256"]):
|
|
reader.close()
|
|
result.error = "archive entry %s: content differs from inventory" % entry
|
|
return result
|
|
total += data.size()
|
|
if total > MAX_TREE_SIZE:
|
|
reader.close()
|
|
result.error = "archive: expanded tree exceeds %d-byte bound" % MAX_TREE_SIZE
|
|
return result
|
|
if entry == PLUGIN_CONFIG:
|
|
config = data
|
|
reader.close()
|
|
var config_version := plugin_cfg_version(config)
|
|
if config_version != str(normalised["version"]):
|
|
result.error = "archive: plugin.cfg version %s differs from signed manifest version %s" % [
|
|
config_version if not config_version.is_empty() else "(unreadable)", str(normalised["version"]),
|
|
]
|
|
return result
|
|
result.ok = true
|
|
return result
|
|
|
|
|
|
## Hash every regular file under `root` recursively. Returns
|
|
## `{"ok", "error", "files": {relative_path: {"size", "sha256"}}, "tree_sha256"}`.
|
|
##
|
|
## Tree-hash algorithm (shared verbatim with Python `release_verify.hash_tree`):
|
|
## relative POSIX paths sorted by UTF-8 byte order; for each file one line
|
|
## `sha256 + " " + str(size) + " " + path + "\n"`; `tree_sha256` is the hex
|
|
## SHA-256 of those lines concatenated. Symlinks anywhere in the tree fail.
|
|
static func hash_tree(root: String) -> Dictionary:
|
|
var result := {"ok": false, "error": "", "files": {}, "tree_sha256": ""}
|
|
var root_abs := _absolute(root)
|
|
if not DirAccess.dir_exists_absolute(root_abs):
|
|
result.error = "tree: directory does not exist: %s" % root_abs
|
|
return result
|
|
var files := {}
|
|
var pending: Array[String] = [""]
|
|
var total := 0
|
|
while not pending.is_empty():
|
|
var relative: String = pending.pop_back()
|
|
var directory_abs := root_abs if relative.is_empty() else root_abs.path_join(relative)
|
|
var directory := DirAccess.open(directory_abs)
|
|
if directory == null:
|
|
result.error = "tree: cannot open %s: %s" % [directory_abs, error_string(DirAccess.get_open_error())]
|
|
return result
|
|
directory.include_hidden = true
|
|
directory.include_navigational = false
|
|
for name in directory.get_directories():
|
|
var child_abs := directory_abs.path_join(name)
|
|
if directory.is_link(child_abs):
|
|
result.error = "tree: link is not allowed: %s" % child_abs
|
|
return result
|
|
pending.append(name if relative.is_empty() else relative + "/" + name)
|
|
for name in directory.get_files():
|
|
var child_abs := directory_abs.path_join(name)
|
|
if directory.is_link(child_abs):
|
|
result.error = "tree: link is not allowed: %s" % child_abs
|
|
return result
|
|
var hashed := sha256_file(child_abs)
|
|
if not bool(hashed.ok):
|
|
result.error = "tree: %s" % str(hashed.error)
|
|
return result
|
|
total += int(hashed["size"])
|
|
if files.size() >= MAX_FILES or total > MAX_TREE_SIZE:
|
|
result.error = "tree: exceeds %d files or %d bytes" % [MAX_FILES, MAX_TREE_SIZE]
|
|
return result
|
|
var relative_path := name if relative.is_empty() else relative + "/" + name
|
|
files[relative_path] = {"size": int(hashed["size"]), "sha256": str(hashed.sha256)}
|
|
var sorted_paths: Array[String] = []
|
|
for path in files:
|
|
sorted_paths.append(str(path))
|
|
sorted_paths.sort()
|
|
var ordered := {}
|
|
for path in sorted_paths:
|
|
ordered[path] = files[path]
|
|
result.files = ordered
|
|
result.tree_sha256 = tree_hash_from_files(ordered)
|
|
result.ok = true
|
|
return result
|
|
|
|
|
|
## The tree hash a correct extraction of `manifest.inventory` must produce:
|
|
## the same line algorithm as `hash_tree` over the inventory rows with the
|
|
## `addons/godot_ai/` prefix stripped. Only `inventory` is read, and row
|
|
## order does not matter. Returns "" for a malformed inventory or a row
|
|
## outside `addons/godot_ai/`, which can never equal a real hash.
|
|
static func inventory_tree_hash(manifest: Dictionary) -> String:
|
|
if not manifest.has("inventory") or not manifest["inventory"] is Array:
|
|
return ""
|
|
var files := {}
|
|
for value in manifest["inventory"]:
|
|
if not value is Dictionary:
|
|
return ""
|
|
var row: Dictionary = value
|
|
if not _check_keys(row, ROW_KEYS, "row").is_empty():
|
|
return ""
|
|
var size := _integer(row["size"], "row.size", MAX_FILE_SIZE)
|
|
if not row["path"] is String or not bool(size.ok) or not row["sha256"] is String:
|
|
return ""
|
|
var path := str(row["path"])
|
|
var digest := str(row["sha256"])
|
|
if not path.begins_with(PLUGIN_PREFIX) or path.length() == PLUGIN_PREFIX.length() or not _is_hex(digest, 64):
|
|
return ""
|
|
var relative := path.substr(PLUGIN_PREFIX.length())
|
|
if files.has(relative):
|
|
return ""
|
|
files[relative] = {"size": int(size.value), "sha256": digest}
|
|
if files.is_empty():
|
|
return ""
|
|
return tree_hash_from_files(files)
|
|
|
|
|
|
## Apply the tree-hash line algorithm to a `{relative_path: {size, sha256}}`
|
|
## dictionary (the `files` shape `hash_tree` returns).
|
|
static func tree_hash_from_files(files: Dictionary) -> String:
|
|
var paths: Array[String] = []
|
|
for path in files:
|
|
paths.append(str(path))
|
|
paths.sort()
|
|
var context := HashingContext.new()
|
|
context.start(HashingContext.HASH_SHA256)
|
|
for path in paths:
|
|
var entry: Dictionary = files[path]
|
|
var line := "%s %d %s\n" % [str(entry["sha256"]), int(entry["size"]), path]
|
|
context.update(line.to_utf8_buffer())
|
|
return context.finish().hex_encode()
|
|
|
|
|
|
## Hex SHA-256 of a byte buffer.
|
|
static func sha256_bytes(bytes: PackedByteArray) -> String:
|
|
var context := HashingContext.new()
|
|
context.start(HashingContext.HASH_SHA256)
|
|
if not bytes.is_empty():
|
|
context.update(bytes)
|
|
return context.finish().hex_encode()
|
|
|
|
|
|
## Chunked SHA-256 of a file. Returns `{"ok", "error", "size", "sha256"}`;
|
|
## files beyond MAX_FILE_SIZE fail without being read to the end.
|
|
static func sha256_file(path: String) -> Dictionary:
|
|
var result := {"ok": false, "error": "", "size": 0, "sha256": ""}
|
|
var absolute := _absolute(path)
|
|
var file := FileAccess.open(absolute, FileAccess.READ)
|
|
if file == null:
|
|
result.error = "cannot read %s: %s" % [absolute, error_string(FileAccess.get_open_error())]
|
|
return result
|
|
var context := HashingContext.new()
|
|
context.start(HashingContext.HASH_SHA256)
|
|
var size := 0
|
|
while true:
|
|
var chunk := file.get_buffer(READ_CHUNK)
|
|
if chunk.is_empty():
|
|
break
|
|
size += chunk.size()
|
|
if size > MAX_FILE_SIZE:
|
|
file.close()
|
|
result.error = "%s exceeds %d-byte bound" % [absolute, MAX_FILE_SIZE]
|
|
return result
|
|
context.update(chunk)
|
|
var read_error := file.get_error()
|
|
file.close()
|
|
if read_error != OK and read_error != ERR_FILE_EOF:
|
|
result.error = "cannot read %s: %s" % [absolute, error_string(read_error)]
|
|
return result
|
|
result.size = size
|
|
result.sha256 = context.finish().hex_encode()
|
|
result.ok = true
|
|
return result
|
|
|
|
|
|
## Serialise `value` exactly as Python's
|
|
## `json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n"`
|
|
## encodes it: sorted keys, no whitespace, integers (including integral
|
|
## floats, which is what Godot's JSON parser yields for them) as integers,
|
|
## non-ASCII unescaped. Returns `{"ok", "error", "bytes"}`. Non-integral
|
|
## numbers fail: the manifest schema has none and Godot's float formatting
|
|
## is not guaranteed to match Python's.
|
|
static func canonical_json(value: Variant) -> Dictionary:
|
|
var result := {"ok": false, "error": "", "bytes": PackedByteArray()}
|
|
var text := _canonical_value(value, result)
|
|
if not str(result.error).is_empty():
|
|
return result
|
|
result.bytes = (text + "\n").to_utf8_buffer()
|
|
result.ok = true
|
|
return result
|
|
|
|
|
|
## Strict UTF-8 validation: rejects overlong forms, surrogates, and code
|
|
## points beyond U+10FFFF, the same input Python's `bytes.decode` rejects.
|
|
static func is_valid_utf8(bytes: PackedByteArray) -> bool:
|
|
var index := 0
|
|
var size := bytes.size()
|
|
while index < size:
|
|
var lead := bytes[index]
|
|
if lead < 0x80:
|
|
index += 1
|
|
continue
|
|
var need := 0
|
|
var code := 0
|
|
if lead >= 0xC2 and lead <= 0xDF:
|
|
need = 1
|
|
code = lead & 0x1F
|
|
elif lead >= 0xE0 and lead <= 0xEF:
|
|
need = 2
|
|
code = lead & 0x0F
|
|
elif lead >= 0xF0 and lead <= 0xF4:
|
|
need = 3
|
|
code = lead & 0x07
|
|
else:
|
|
return false
|
|
if index + need >= size:
|
|
return false
|
|
for offset in range(1, need + 1):
|
|
var continuation := bytes[index + offset]
|
|
if (continuation & 0xC0) != 0x80:
|
|
return false
|
|
code = (code << 6) | (continuation & 0x3F)
|
|
if need == 2 and code < 0x800:
|
|
return false
|
|
if need == 3 and (code < 0x10000 or code > 0x10FFFF):
|
|
return false
|
|
if code >= 0xD800 and code <= 0xDFFF:
|
|
return false
|
|
index += need + 1
|
|
return true
|
|
|
|
|
|
## Validate a parsed manifest's schema, bounds, and inventory path safety
|
|
## without checking identity or the signature. Returns
|
|
## `{"ok", "error", "manifest"}` where `manifest` is a normalised copy with
|
|
## integer fields as `int`.
|
|
static func validate_manifest_dict(root: Dictionary) -> Dictionary:
|
|
var result := {"ok": false, "error": "", "manifest": {}}
|
|
var root_error := _check_keys(root, ROOT_KEYS, "manifest")
|
|
if not root_error.is_empty():
|
|
result.error = root_error
|
|
return result
|
|
var schema := _integer(root["schema_version"], "manifest.schema_version", SCHEMA_VERSION)
|
|
if not bool(schema.ok):
|
|
result.error = str(schema.error)
|
|
return result
|
|
if int(schema.value) != SCHEMA_VERSION:
|
|
result.error = "manifest.schema_version: expected %d" % SCHEMA_VERSION
|
|
return result
|
|
var manifest := {"schema_version": SCHEMA_VERSION}
|
|
for key in ["repository", "channel", "tag", "version", "source_commit"]:
|
|
var text := _string(root[key], "manifest.%s" % key)
|
|
if not bool(text.ok):
|
|
result.error = str(text.error)
|
|
return result
|
|
manifest[key] = str(text.value)
|
|
if not root["asset"] is Dictionary:
|
|
result.error = "manifest.asset: expected object"
|
|
return result
|
|
var asset: Dictionary = root["asset"]
|
|
var asset_error := _check_keys(asset, ASSET_KEYS, "manifest.asset")
|
|
if not asset_error.is_empty():
|
|
result.error = asset_error
|
|
return result
|
|
if not asset["name"] is String or str(asset["name"]) != ASSET_NAME:
|
|
result.error = "manifest.asset.name: expected %s" % ASSET_NAME
|
|
return result
|
|
var asset_size := _integer(asset["size"], "manifest.asset.size", MAX_ARCHIVE_SIZE)
|
|
if not bool(asset_size.ok):
|
|
result.error = str(asset_size.error)
|
|
return result
|
|
if not asset["sha256"] is String or not _is_hex(str(asset["sha256"]), 64):
|
|
result.error = "manifest.asset.sha256: expected 64 lowercase hex characters"
|
|
return result
|
|
manifest["asset"] = {"name": ASSET_NAME, "size": int(asset_size.value), "sha256": str(asset["sha256"])}
|
|
if not root["inventory"] is Array:
|
|
result.error = "manifest.inventory: expected array"
|
|
return result
|
|
var rows: Array = root["inventory"]
|
|
var inventory: Array = []
|
|
var paths: Array[String] = []
|
|
var total := 0
|
|
for index in rows.size():
|
|
var context := "manifest.inventory[%d]" % index
|
|
if not rows[index] is Dictionary:
|
|
result.error = "%s: expected object" % context
|
|
return result
|
|
var row: Dictionary = rows[index]
|
|
var row_error := _check_keys(row, ROW_KEYS, context)
|
|
if not row_error.is_empty():
|
|
result.error = row_error
|
|
return result
|
|
var path := _string(row["path"], context + ".path")
|
|
if not bool(path.ok):
|
|
result.error = str(path.error)
|
|
return result
|
|
var size := _integer(row["size"], context + ".size", MAX_FILE_SIZE)
|
|
if not bool(size.ok):
|
|
result.error = str(size.error)
|
|
return result
|
|
if not row["sha256"] is String or not _is_hex(str(row["sha256"]), 64):
|
|
result.error = "%s.sha256: expected 64 lowercase hex characters" % context
|
|
return result
|
|
total += int(size.value)
|
|
paths.append(str(path.value))
|
|
inventory.append({"path": str(path.value), "size": int(size.value), "sha256": str(row["sha256"])})
|
|
var paths_error := _validate_paths(paths, "manifest.inventory")
|
|
if not paths_error.is_empty():
|
|
result.error = paths_error
|
|
return result
|
|
if total > MAX_TREE_SIZE:
|
|
result.error = "manifest.inventory: expanded tree exceeds %d-byte bound" % MAX_TREE_SIZE
|
|
return result
|
|
if not paths.has(PLUGIN_CONFIG):
|
|
result.error = "manifest.inventory: missing %s" % PLUGIN_CONFIG
|
|
return result
|
|
manifest["inventory"] = inventory
|
|
result.manifest = manifest
|
|
result.ok = true
|
|
return result
|
|
|
|
|
|
## Verify a PKCS#1 v1.5 SHA-256 signature over `message` with a PEM
|
|
## SubjectPublicKeyInfo. Returns "" on success, otherwise the reason.
|
|
static func verify_signature(message: PackedByteArray, signature: PackedByteArray, public_key_pem: String) -> String:
|
|
var modulus_size := rsa_modulus_size(public_key_pem)
|
|
if modulus_size < MIN_RSA_MODULUS_BYTES:
|
|
return "public key: expected a PEM RSA SubjectPublicKeyInfo of at least %d bits" % (MIN_RSA_MODULUS_BYTES * 8)
|
|
if signature.size() != modulus_size:
|
|
return "manifest signature: expected exactly %d bytes, got %d" % [modulus_size, signature.size()]
|
|
var key := CryptoKey.new()
|
|
if key.load_from_string(public_key_pem, true) != OK:
|
|
return "public key: could not be loaded"
|
|
var crypto := Crypto.new()
|
|
var digest := PackedByteArray()
|
|
var context := HashingContext.new()
|
|
context.start(HashingContext.HASH_SHA256)
|
|
context.update(message)
|
|
digest = context.finish()
|
|
if not crypto.verify(HashingContext.HASH_SHA256, digest, signature, key):
|
|
return "manifest signature: RSA PKCS#1 v1.5 SHA-256 verification failed"
|
|
return ""
|
|
|
|
|
|
## Byte length of the RSA modulus inside a PEM SubjectPublicKeyInfo, or 0
|
|
## when the PEM is not a well-formed rsaEncryption public key.
|
|
static func rsa_modulus_size(public_key_pem: String) -> int:
|
|
var lines := public_key_pem.replace("\r\n", "\n").strip_edges().split("\n")
|
|
if (
|
|
lines.size() < 3
|
|
or lines[0].strip_edges() != "-----BEGIN PUBLIC KEY-----"
|
|
or lines[lines.size() - 1].strip_edges() != "-----END PUBLIC KEY-----"
|
|
):
|
|
return 0
|
|
var encoded := ""
|
|
for index in range(1, lines.size() - 1):
|
|
encoded += lines[index].strip_edges()
|
|
if encoded.is_empty() or not _is_base64(encoded):
|
|
return 0
|
|
var der := Marshalls.base64_to_raw(encoded)
|
|
if der.is_empty():
|
|
return 0
|
|
var outer := _der_item(der, 0, 0x30)
|
|
if not bool(outer.ok) or int(outer.end) != der.size():
|
|
return 0
|
|
var body: PackedByteArray = outer.value
|
|
var algorithm := _der_item(body, 0, 0x30)
|
|
if not bool(algorithm.ok) or algorithm.value != PackedByteArray(_RSA_ALGORITHM):
|
|
return 0
|
|
var bits := _der_item(body, int(algorithm.end), 0x03)
|
|
if not bool(bits.ok) or int(bits.end) != body.size():
|
|
return 0
|
|
var bit_string: PackedByteArray = bits.value
|
|
if bit_string.is_empty() or bit_string[0] != 0:
|
|
return 0
|
|
var key := _der_item(bit_string, 1, 0x30)
|
|
if not bool(key.ok) or int(key.end) != bit_string.size():
|
|
return 0
|
|
var key_body: PackedByteArray = key.value
|
|
var modulus := _der_item(key_body, 0, 0x02)
|
|
if not bool(modulus.ok):
|
|
return 0
|
|
var raw: PackedByteArray = modulus.value
|
|
if raw.is_empty() or raw[0] & 0x80:
|
|
return 0
|
|
if raw.size() > 1 and raw[0] == 0:
|
|
if not (raw[1] & 0x80):
|
|
return 0
|
|
raw = raw.slice(1)
|
|
var exponent := _der_item(key_body, int(modulus.end), 0x02)
|
|
if not bool(exponent.ok) or int(exponent.end) != key_body.size() or exponent.value.is_empty():
|
|
return 0
|
|
return raw.size()
|
|
|
|
|
|
## The single quoted `version="..."` line of a plugin.cfg, or "" when the
|
|
## file does not carry exactly one.
|
|
static func plugin_cfg_version(data: PackedByteArray) -> String:
|
|
if not is_valid_utf8(data):
|
|
return ""
|
|
var expression := RegEx.new()
|
|
if expression.compile("(?m)^\\s*version\\s*=\\s*\"([^\"]+)\"\\s*$") != OK:
|
|
return ""
|
|
var matches := expression.search_all(data.get_string_from_utf8())
|
|
if matches.size() != 1:
|
|
return ""
|
|
return matches[0].get_string(1)
|
|
|
|
|
|
## Parse a dotted `major.minor.patch` version into three ints, or [] when
|
|
## it is not exactly three unsigned decimal components.
|
|
static func version_parts(version: String) -> Array[int]:
|
|
var parts := version.split(".")
|
|
if parts.size() != 3:
|
|
return []
|
|
var numbers: Array[int] = []
|
|
for part in parts:
|
|
if not _all_digits(part):
|
|
return []
|
|
numbers.append(int(part))
|
|
return numbers
|
|
|
|
|
|
## True when `candidate` is strictly newer than `current` by tuple order.
|
|
static func is_newer_version(candidate: String, current: String) -> bool:
|
|
var left := version_parts(candidate)
|
|
var right := version_parts(current)
|
|
if left.is_empty() or right.is_empty():
|
|
return false
|
|
for index in 3:
|
|
if left[index] != right[index]:
|
|
return left[index] > right[index]
|
|
return false
|
|
|
|
|
|
# ----- identity ---------------------------------------------------------
|
|
|
|
|
|
static func _check_identity(manifest: Dictionary, expected: Dictionary) -> String:
|
|
var repository := str(manifest["repository"])
|
|
if repository != REPOSITORY or repository != str(expected.get("repository", REPOSITORY)):
|
|
return "manifest.repository: expected %s" % REPOSITORY
|
|
var channel := str(manifest["channel"])
|
|
if channel != CHANNEL or channel != str(expected.get("channel", CHANNEL)):
|
|
return "manifest.channel: v4 releases are stable-only"
|
|
var version := str(manifest["version"])
|
|
var parts := version_parts(version)
|
|
if parts.is_empty() or parts[0] != 4:
|
|
return "manifest.version: expected a 4.x.y version"
|
|
if str(manifest["tag"]) != "v" + version:
|
|
return "manifest.tag: expected v%s" % version
|
|
if not _is_hex(str(manifest["source_commit"]), 40):
|
|
return "manifest.source_commit: expected 40 lowercase hex characters"
|
|
var expected_version := str(expected.get("version", ""))
|
|
if not expected_version.is_empty() and expected_version != version:
|
|
return "manifest.version: %s does not match the expected release %s" % [version, expected_version]
|
|
var expected_tag := str(expected.get("tag", ""))
|
|
if not expected_tag.is_empty() and expected_tag != str(manifest["tag"]):
|
|
return "manifest.tag: %s does not match the expected tag %s" % [str(manifest["tag"]), expected_tag]
|
|
var expected_commit := str(expected.get("source_commit", ""))
|
|
if not expected_commit.is_empty() and expected_commit != str(manifest["source_commit"]):
|
|
return "manifest.source_commit: does not match the expected commit"
|
|
var current_version := str(expected.get("current_version", ""))
|
|
if not current_version.is_empty():
|
|
if version_parts(current_version).is_empty():
|
|
return "current_version: %s is not a dotted major.minor.patch version" % current_version
|
|
if not is_newer_version(version, current_version):
|
|
return "manifest.version: %s is not newer than the installed %s" % [version, current_version]
|
|
return ""
|
|
|
|
|
|
# ----- archive listing --------------------------------------------------
|
|
|
|
|
|
static func _check_entry_names(
|
|
entries: PackedStringArray, expected_paths: Array[String], expected_rows: Dictionary
|
|
) -> String:
|
|
var seen := {}
|
|
for entry in entries:
|
|
if entry.ends_with("/"):
|
|
return "archive: directory entry is not allowed: %s" % entry
|
|
if not expected_rows.has(entry):
|
|
return "archive: entry is not in the signed inventory: %s" % entry
|
|
if seen.has(entry):
|
|
return "archive: duplicate entry: %s" % entry
|
|
seen[entry] = true
|
|
for path in expected_paths:
|
|
if not seen.has(path):
|
|
return "archive: inventory file is missing from the zip: %s" % path
|
|
if Array(entries) != Array(expected_paths):
|
|
return "archive: entries are not in the sorted inventory order"
|
|
return ""
|
|
|
|
|
|
# ----- path safety ------------------------------------------------------
|
|
|
|
|
|
static func _validate_paths(paths: Array[String], context: String) -> String:
|
|
if paths.is_empty() or paths.size() > MAX_FILES:
|
|
return "%s: expected 1..%d paths" % [context, MAX_FILES]
|
|
var sorted := paths.duplicate()
|
|
sorted.sort()
|
|
var exact := {}
|
|
var identities := {}
|
|
for index in paths.size():
|
|
var path := paths[index]
|
|
var path_error := _check_path(path, "%s[%d]" % [context, index])
|
|
if not path_error.is_empty():
|
|
return path_error
|
|
if exact.has(path):
|
|
return "%s: duplicate path %s" % [context, path]
|
|
exact[path] = true
|
|
var identity := path.to_lower()
|
|
if identities.has(identity):
|
|
return "%s: case-colliding paths %s and %s" % [context, str(identities[identity]), path]
|
|
identities[identity] = path
|
|
if paths != sorted:
|
|
return "%s: paths are not in bytewise order" % context
|
|
for path in paths:
|
|
var parent := path.get_base_dir()
|
|
while parent.contains("/"):
|
|
if exact.has(parent):
|
|
return "%s: file/ancestor collision at %s" % [context, parent]
|
|
parent = parent.get_base_dir()
|
|
return ""
|
|
|
|
|
|
static func _check_path(path: String, context: String) -> String:
|
|
if not path.begins_with(PLUGIN_PREFIX) or path.ends_with("/") or path.contains("\\"):
|
|
return "%s: path must be a file beneath %s: %s" % [context, PLUGIN_PREFIX, path]
|
|
for part in path.split("/"):
|
|
if part.is_empty() or part == "." or part == ".." or part.ends_with(" ") or part.ends_with("."):
|
|
return "%s: unsafe path component in %s" % [context, path]
|
|
for index in part.length():
|
|
var code := part.unicode_at(index)
|
|
if code < 32 or _UNSAFE_PATH_CHARS.contains(char(code)):
|
|
return "%s: unsafe path character in %s" % [context, path]
|
|
var stem := part.split(".", true, 1)[0]
|
|
if RESERVED_NAMES.has(stem.to_upper()):
|
|
return "%s: reserved path component in %s" % [context, path]
|
|
return ""
|
|
|
|
|
|
# ----- schema helpers ---------------------------------------------------
|
|
|
|
|
|
static func _check_keys(value: Dictionary, expected_keys: Array, context: String) -> String:
|
|
var keys := value.keys()
|
|
for key in keys:
|
|
if not key is String:
|
|
return "%s: expected object with exactly keys %s" % [context, str(expected_keys)]
|
|
keys.sort()
|
|
if keys != expected_keys:
|
|
return "%s: expected object with exactly keys %s" % [context, str(expected_keys)]
|
|
return ""
|
|
|
|
|
|
static func _string(value: Variant, context: String) -> Dictionary:
|
|
if not value is String or str(value).is_empty():
|
|
return {"ok": false, "error": "%s: expected non-empty string" % context, "value": ""}
|
|
return {"ok": true, "error": "", "value": str(value)}
|
|
|
|
|
|
## Accept `int`, or the integral `float` Godot's JSON parser yields, within
|
|
## `[0, maximum]`. Booleans are rejected.
|
|
static func _integer(value: Variant, context: String, maximum: int) -> Dictionary:
|
|
var failure := {"ok": false, "error": "%s: expected integer in [0, %d]" % [context, maximum], "value": 0}
|
|
var number := 0
|
|
if value is int:
|
|
number = value
|
|
elif value is float:
|
|
var real: float = value
|
|
if real != floor(real) or absf(real) > MAX_SAFE_INTEGER:
|
|
return failure
|
|
number = int(real)
|
|
else:
|
|
return failure
|
|
if number < 0 or number > maximum:
|
|
return failure
|
|
return {"ok": true, "error": "", "value": number}
|
|
|
|
|
|
static func _is_hex(text: String, length: int) -> bool:
|
|
if text.length() != length:
|
|
return false
|
|
for index in text.length():
|
|
var code := text.unicode_at(index)
|
|
if not ((code >= 48 and code <= 57) or (code >= 97 and code <= 102)):
|
|
return false
|
|
return true
|
|
|
|
|
|
static func _all_digits(text: String) -> bool:
|
|
if text.is_empty():
|
|
return false
|
|
for index in text.length():
|
|
var code := text.unicode_at(index)
|
|
if code < 48 or code > 57:
|
|
return false
|
|
return true
|
|
|
|
|
|
static func _is_base64(text: String) -> bool:
|
|
for index in text.length():
|
|
var code := text.unicode_at(index)
|
|
var ok := (
|
|
(code >= 65 and code <= 90)
|
|
or (code >= 97 and code <= 122)
|
|
or (code >= 48 and code <= 57)
|
|
or code == 43 or code == 47 or code == 61
|
|
)
|
|
if not ok:
|
|
return false
|
|
return true
|
|
|
|
|
|
# ----- canonical JSON ---------------------------------------------------
|
|
|
|
|
|
static func _canonical_value(value: Variant, result: Dictionary) -> String:
|
|
match typeof(value):
|
|
TYPE_NIL:
|
|
return "null"
|
|
TYPE_BOOL:
|
|
return "true" if value else "false"
|
|
TYPE_INT:
|
|
return String.num_int64(value)
|
|
TYPE_FLOAT:
|
|
var real: float = value
|
|
if real != floor(real) or absf(real) > MAX_SAFE_INTEGER:
|
|
result.error = "non-integer number %s" % str(real)
|
|
return ""
|
|
return String.num_int64(int(real))
|
|
TYPE_STRING, TYPE_STRING_NAME:
|
|
return _escape_json_string(str(value))
|
|
TYPE_ARRAY:
|
|
var items: Array[String] = []
|
|
for item in value:
|
|
items.append(_canonical_value(item, result))
|
|
if not str(result.error).is_empty():
|
|
return ""
|
|
return "[" + ",".join(items) + "]"
|
|
TYPE_DICTIONARY:
|
|
var dictionary: Dictionary = value
|
|
var keys: Array = dictionary.keys()
|
|
for key in keys:
|
|
if not key is String:
|
|
result.error = "non-string object key"
|
|
return ""
|
|
keys.sort()
|
|
var members: Array[String] = []
|
|
for key in keys:
|
|
var member := _canonical_value(dictionary[key], result)
|
|
if not str(result.error).is_empty():
|
|
return ""
|
|
members.append(_escape_json_string(str(key)) + ":" + member)
|
|
return "{" + ",".join(members) + "}"
|
|
_:
|
|
result.error = "unsupported JSON value type %s" % type_string(typeof(value))
|
|
return ""
|
|
|
|
|
|
static func _escape_json_string(text: String) -> String:
|
|
var out := "\""
|
|
for index in text.length():
|
|
var code := text.unicode_at(index)
|
|
match code:
|
|
0x22:
|
|
out += "\\\""
|
|
0x5C:
|
|
out += "\\\\"
|
|
0x0A:
|
|
out += "\\n"
|
|
0x0D:
|
|
out += "\\r"
|
|
0x09:
|
|
out += "\\t"
|
|
0x08:
|
|
out += "\\b"
|
|
0x0C:
|
|
out += "\\f"
|
|
_:
|
|
if code < 0x20:
|
|
out += "\\u%04x" % code
|
|
else:
|
|
out += char(code)
|
|
return out + "\""
|
|
|
|
|
|
# ----- DER --------------------------------------------------------------
|
|
|
|
|
|
static func _der_item(data: PackedByteArray, offset: int, tag: int) -> Dictionary:
|
|
var failure := {"ok": false, "value": PackedByteArray(), "end": 0}
|
|
var size := data.size()
|
|
if offset >= size or data[offset] != tag:
|
|
return failure
|
|
offset += 1
|
|
if offset >= size:
|
|
return failure
|
|
var first := data[offset]
|
|
offset += 1
|
|
var length := 0
|
|
if first < 0x80:
|
|
length = first
|
|
else:
|
|
var count := first & 0x7F
|
|
if count < 1 or count > 4 or offset + count > size or data[offset] == 0:
|
|
return failure
|
|
for index in count:
|
|
length = (length << 8) | data[offset + index]
|
|
offset += count
|
|
if length < 0x80:
|
|
return failure
|
|
var end := offset + length
|
|
if end > size:
|
|
return failure
|
|
return {"ok": true, "value": data.slice(offset, end), "end": end}
|
|
|
|
|
|
# ----- paths ------------------------------------------------------------
|
|
|
|
|
|
static func _absolute(path: String) -> String:
|
|
if path.begins_with("res://") or path.begins_with("user://"):
|
|
return ProjectSettings.globalize_path(path)
|
|
return path
|