511 lines
17 KiB
Python
511 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import base64
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
SDK_SRC = PROJECT_ROOT / "packages" / "jwebot-sdk" / "src"
|
|
KERNEL_SRC = PROJECT_ROOT / "packages" / "jwebot-kernel" / "src"
|
|
sys.path[:0] = [str(SDK_SRC), str(KERNEL_SRC)]
|
|
|
|
from jwebot_kernel import ( # noqa: E402
|
|
ActivationError,
|
|
ReleaseError,
|
|
SignatureError,
|
|
build_release,
|
|
install_release,
|
|
parse_manifest,
|
|
rollback_release,
|
|
sign_release_record,
|
|
verify_installed_release_signature,
|
|
verify_release_signature,
|
|
)
|
|
|
|
|
|
SSH_KEYGEN = Path("/usr/bin/ssh-keygen")
|
|
PUBLISHER = "test-publisher"
|
|
|
|
|
|
def manifest():
|
|
return parse_manifest(
|
|
{
|
|
"id": "hello-command",
|
|
"author": "tester",
|
|
"description": "Say hello",
|
|
"help": ["/hello"],
|
|
"version": "1.0.0",
|
|
"api_version": "1",
|
|
"entrypoint": "hello_command:plugin",
|
|
"type": "command",
|
|
"capabilities": {
|
|
"commands": ["/hello"],
|
|
"storage": False,
|
|
"network_profiles": [],
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
def extension(root: Path, reply: str = "hello") -> Path:
|
|
root.mkdir()
|
|
(root / "src").mkdir()
|
|
(root / "plugin.toml").write_text(
|
|
'id = "hello-command"\n',
|
|
encoding="utf-8",
|
|
)
|
|
(root / "src" / "hello_command.py").write_text(
|
|
"\n".join(
|
|
(
|
|
"from jwebot_sdk import ExtensionResult, ReplyPlan",
|
|
"",
|
|
"def handle(event, arguments):",
|
|
f" return ExtensionResult(reply=ReplyPlan.text({reply!r}))",
|
|
)
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return root
|
|
|
|
|
|
def publisher_key(root: Path, publisher: str = PUBLISHER):
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
private_key = root / "publisher"
|
|
result = subprocess.run(
|
|
[
|
|
str(SSH_KEYGEN),
|
|
"-q",
|
|
"-t",
|
|
"ed25519",
|
|
"-N",
|
|
"",
|
|
"-f",
|
|
str(private_key),
|
|
],
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError("cannot generate test publisher key")
|
|
allowed_signers = root / "allowed_signers"
|
|
public_key = private_key.with_suffix(".pub").read_text(
|
|
encoding="utf-8"
|
|
).strip()
|
|
allowed_signers.write_text(
|
|
f"{publisher} {public_key}\n",
|
|
encoding="utf-8",
|
|
)
|
|
return private_key, allowed_signers
|
|
|
|
|
|
@unittest.skipUnless(
|
|
SSH_KEYGEN.is_file(),
|
|
"/usr/bin/ssh-keygen is unavailable",
|
|
)
|
|
class ReleaseSigningTests(unittest.TestCase):
|
|
def build(
|
|
self,
|
|
base: Path,
|
|
name: str = "extension",
|
|
reply: str = "hello",
|
|
):
|
|
return build_release(
|
|
extension(base / name, reply),
|
|
base / f"{name}-dist",
|
|
manifest=manifest(),
|
|
)
|
|
|
|
def test_valid_signature_is_deterministic_and_verifies(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
base = Path(directory)
|
|
_, record_path, _ = self.build(base)
|
|
private_key, allowed_signers = publisher_key(base)
|
|
|
|
first_path, first = sign_release_record(
|
|
record_path,
|
|
private_key,
|
|
PUBLISHER,
|
|
)
|
|
first_bytes = first_path.read_bytes()
|
|
second_path, second = sign_release_record(
|
|
record_path,
|
|
private_key,
|
|
PUBLISHER,
|
|
)
|
|
|
|
self.assertEqual(first, second)
|
|
self.assertEqual(first_bytes, second_path.read_bytes())
|
|
verified = verify_release_signature(
|
|
record_path,
|
|
first_path,
|
|
allowed_signers,
|
|
expected_publisher=PUBLISHER,
|
|
)
|
|
self.assertEqual(verified.publisher, PUBLISHER)
|
|
|
|
def test_signature_rejects_release_record_tampering(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
base = Path(directory)
|
|
_, record_path, _ = self.build(base)
|
|
private_key, allowed_signers = publisher_key(base)
|
|
signature_path, _ = sign_release_record(
|
|
record_path,
|
|
private_key,
|
|
PUBLISHER,
|
|
)
|
|
value = json.loads(record_path.read_text(encoding="utf-8"))
|
|
value["artifact_size"] += 1
|
|
record_path.write_text(
|
|
json.dumps(
|
|
value,
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
with self.assertRaisesRegex(
|
|
SignatureError,
|
|
"signed release digest mismatch",
|
|
):
|
|
verify_release_signature(
|
|
record_path,
|
|
signature_path,
|
|
allowed_signers,
|
|
)
|
|
|
|
def test_signing_rejects_tampered_artifact(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
base = Path(directory)
|
|
artifact, record_path, _ = self.build(base)
|
|
private_key, _ = publisher_key(base)
|
|
artifact.write_bytes(artifact.read_bytes() + b"tampered")
|
|
|
|
with self.assertRaisesRegex(ReleaseError, "size mismatch"):
|
|
sign_release_record(
|
|
record_path,
|
|
private_key,
|
|
PUBLISHER,
|
|
)
|
|
|
|
def test_signature_rejects_cryptographic_tampering(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
base = Path(directory)
|
|
_, record_path, _ = self.build(base)
|
|
private_key, allowed_signers = publisher_key(base)
|
|
signature_path, _ = sign_release_record(
|
|
record_path,
|
|
private_key,
|
|
PUBLISHER,
|
|
)
|
|
value = json.loads(signature_path.read_text(encoding="utf-8"))
|
|
raw = bytearray(base64.b64decode(value["signature_base64"]))
|
|
raw[len(raw) // 2] ^= 0x01
|
|
value["signature_base64"] = base64.b64encode(raw).decode("ascii")
|
|
signature_path.write_text(
|
|
json.dumps(
|
|
value,
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
with self.assertRaisesRegex(
|
|
SignatureError,
|
|
"signature verification failed",
|
|
):
|
|
verify_release_signature(
|
|
record_path,
|
|
signature_path,
|
|
allowed_signers,
|
|
)
|
|
|
|
def test_signature_rejects_untrusted_publisher_key(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
base = Path(directory)
|
|
_, record_path, _ = self.build(base)
|
|
private_key, _ = publisher_key(base / "trusted")
|
|
untrusted_root = base / "untrusted"
|
|
untrusted_root.mkdir()
|
|
_, wrong_allowed_signers = publisher_key(untrusted_root)
|
|
signature_path, _ = sign_release_record(
|
|
record_path,
|
|
private_key,
|
|
PUBLISHER,
|
|
)
|
|
|
|
with self.assertRaisesRegex(
|
|
SignatureError,
|
|
"signature verification failed",
|
|
):
|
|
verify_release_signature(
|
|
record_path,
|
|
signature_path,
|
|
wrong_allowed_signers,
|
|
)
|
|
|
|
def test_trust_file_rejects_non_ed25519_entries(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
base = Path(directory)
|
|
_, record_path, _ = self.build(base)
|
|
private_key, allowed_signers = publisher_key(base)
|
|
signature_path, _ = sign_release_record(
|
|
record_path,
|
|
private_key,
|
|
PUBLISHER,
|
|
)
|
|
allowed_signers.write_text(
|
|
allowed_signers.read_text(encoding="utf-8").replace(
|
|
"ssh-ed25519",
|
|
"ssh-rsa",
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
with self.assertRaisesRegex(SignatureError, "simple Ed25519"):
|
|
verify_release_signature(
|
|
record_path,
|
|
signature_path,
|
|
allowed_signers,
|
|
)
|
|
|
|
def test_signature_binds_publisher_even_when_key_has_two_principals(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
base = Path(directory)
|
|
_, record_path, _ = self.build(base)
|
|
private_key, allowed_signers = publisher_key(base)
|
|
public_key = private_key.with_suffix(".pub").read_text(
|
|
encoding="utf-8"
|
|
).strip()
|
|
with allowed_signers.open("a", encoding="utf-8") as stream:
|
|
stream.write(f"other-publisher {public_key}\n")
|
|
signature_path, _ = sign_release_record(
|
|
record_path,
|
|
private_key,
|
|
PUBLISHER,
|
|
)
|
|
value = json.loads(signature_path.read_text(encoding="utf-8"))
|
|
value["publisher"] = "other-publisher"
|
|
signature_path.write_text(
|
|
json.dumps(
|
|
value,
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
with self.assertRaisesRegex(
|
|
SignatureError,
|
|
"signature verification failed",
|
|
):
|
|
verify_release_signature(
|
|
record_path,
|
|
signature_path,
|
|
allowed_signers,
|
|
)
|
|
|
|
def test_signing_rejects_noncanonical_release_record(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
base = Path(directory)
|
|
_, record_path, _ = self.build(base)
|
|
private_key, _ = publisher_key(base)
|
|
record_path.write_bytes(b" " + record_path.read_bytes())
|
|
|
|
with self.assertRaisesRegex(SignatureError, "not canonical"):
|
|
sign_release_record(
|
|
record_path,
|
|
private_key,
|
|
PUBLISHER,
|
|
)
|
|
|
|
def test_signed_activation_persists_and_reverifies_signature(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
base = Path(directory)
|
|
artifact, record_path, _ = self.build(base)
|
|
private_key, allowed_signers = publisher_key(base)
|
|
signature_path, _ = sign_release_record(
|
|
record_path,
|
|
private_key,
|
|
PUBLISHER,
|
|
)
|
|
|
|
release_dir = install_release(
|
|
artifact,
|
|
record_path,
|
|
base / "state",
|
|
signature=signature_path,
|
|
allowed_signers=allowed_signers,
|
|
expected_publisher=PUBLISHER,
|
|
health_check=lambda release: None,
|
|
)
|
|
|
|
self.assertTrue((release_dir / ".release.sig.json").is_file())
|
|
record, signature = verify_installed_release_signature(
|
|
release_dir,
|
|
allowed_signers,
|
|
expected_publisher=PUBLISHER,
|
|
)
|
|
self.assertEqual(record.plugin_id, "hello-command")
|
|
self.assertEqual(signature.publisher, PUBLISHER)
|
|
|
|
def test_signed_activation_requires_passing_health_check(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
base = Path(directory)
|
|
artifact, record_path, _ = self.build(base)
|
|
private_key, allowed_signers = publisher_key(base)
|
|
signature_path, _ = sign_release_record(
|
|
record_path,
|
|
private_key,
|
|
PUBLISHER,
|
|
)
|
|
state = base / "state"
|
|
|
|
with self.assertRaisesRegex(
|
|
ActivationError,
|
|
"health check is required",
|
|
):
|
|
install_release(
|
|
artifact,
|
|
record_path,
|
|
state,
|
|
signature=signature_path,
|
|
allowed_signers=allowed_signers,
|
|
expected_publisher=PUBLISHER,
|
|
)
|
|
self.assertFalse(state.exists())
|
|
|
|
def test_failed_health_check_does_not_switch_current(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
base = Path(directory)
|
|
artifact, record_path, record = self.build(base)
|
|
private_key, allowed_signers = publisher_key(base)
|
|
signature_path, _ = sign_release_record(
|
|
record_path,
|
|
private_key,
|
|
PUBLISHER,
|
|
)
|
|
state = base / "state"
|
|
|
|
with self.assertRaisesRegex(
|
|
ActivationError,
|
|
"health check failed",
|
|
):
|
|
install_release(
|
|
artifact,
|
|
record_path,
|
|
state,
|
|
signature=signature_path,
|
|
allowed_signers=allowed_signers,
|
|
expected_publisher=PUBLISHER,
|
|
health_check=lambda release: (_ for _ in ()).throw(
|
|
RuntimeError("unhealthy")
|
|
),
|
|
)
|
|
self.assertFalse((state / "current").exists())
|
|
self.assertFalse(
|
|
(state / "releases" / record.source_digest).exists()
|
|
)
|
|
|
|
def test_signed_activation_rejects_wrong_publisher_policy(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
base = Path(directory)
|
|
artifact, record_path, _ = self.build(base)
|
|
private_key, allowed_signers = publisher_key(base)
|
|
signature_path, _ = sign_release_record(
|
|
record_path,
|
|
private_key,
|
|
PUBLISHER,
|
|
)
|
|
state = base / "state"
|
|
|
|
with self.assertRaisesRegex(
|
|
SignatureError,
|
|
"publisher does not match policy",
|
|
):
|
|
install_release(
|
|
artifact,
|
|
record_path,
|
|
state,
|
|
signature=signature_path,
|
|
allowed_signers=allowed_signers,
|
|
expected_publisher="other-publisher",
|
|
)
|
|
self.assertFalse(state.exists())
|
|
|
|
def test_signed_rollback_reverifies_previous_release(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
base = Path(directory)
|
|
private_key, allowed_signers = publisher_key(base)
|
|
first_artifact, first_record_path, first_record = self.build(
|
|
base,
|
|
"first",
|
|
"one",
|
|
)
|
|
first_signature, _ = sign_release_record(
|
|
first_record_path,
|
|
private_key,
|
|
PUBLISHER,
|
|
)
|
|
second_artifact, second_record_path, second_record = self.build(
|
|
base,
|
|
"second",
|
|
"two",
|
|
)
|
|
second_signature, _ = sign_release_record(
|
|
second_record_path,
|
|
private_key,
|
|
PUBLISHER,
|
|
)
|
|
state = base / "state"
|
|
install_release(
|
|
first_artifact,
|
|
first_record_path,
|
|
state,
|
|
signature=first_signature,
|
|
allowed_signers=allowed_signers,
|
|
expected_publisher=PUBLISHER,
|
|
health_check=lambda release: None,
|
|
)
|
|
install_release(
|
|
second_artifact,
|
|
second_record_path,
|
|
state,
|
|
signature=second_signature,
|
|
allowed_signers=allowed_signers,
|
|
expected_publisher=PUBLISHER,
|
|
health_check=lambda release: None,
|
|
)
|
|
|
|
destination = rollback_release(
|
|
state,
|
|
allowed_signers=allowed_signers,
|
|
expected_publisher=PUBLISHER,
|
|
)
|
|
|
|
self.assertEqual(destination.name, first_record.source_digest)
|
|
self.assertEqual(
|
|
(state / "current").resolve().name,
|
|
first_record.source_digest,
|
|
)
|
|
self.assertEqual(
|
|
(state / "previous").resolve().name,
|
|
second_record.source_digest,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|