Files
Privatae e3074f50b0
ci / test (push) Successful in 1m1s
ci / personal-data (push) Successful in 3s
ci / reproducible (push) Successful in 4s
bastion agent 0.1.16
2026-10-25 23:16:50 +00:00

268 lines
13 KiB
Python

"""Tests for the release verifier. Run: python3 -m unittest discover ."""
import base64
import contextlib
import hashlib
import io
import json
import os
import shutil
import subprocess
import tempfile
import unittest
from unittest import mock
from . import build as bd
from . import verify_release as vr
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
def _sign(seed, msg):
"""RFC 8032 signing, for the tests only: the verifier never signs."""
h = hashlib.sha512(seed).digest()
a = int.from_bytes(h[:32], "little")
a &= (1 << 254) - 8
a |= 1 << 254
pub = vr._compress(vr._mul(a, vr._G))
r = int.from_bytes(hashlib.sha512(h[32:] + msg).digest(), "little") % vr._Q
big_r = vr._compress(vr._mul(r, vr._G))
k = int.from_bytes(hashlib.sha512(big_r + pub + msg).digest(), "little") % vr._Q
return big_r + ((r + k * a) % vr._Q).to_bytes(32, "little")
class Ed25519Tests(unittest.TestCase):
# RFC 8032, section 7.1, tests 1 and 2.
RFC = (
("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
"d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
"",
"e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"),
("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb",
"3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c",
"72",
"92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00"),
)
def test_rfc8032_vectors(self):
for seed, pub, msg, sig in self.RFC:
seed, pub, msg, sig = (bytes.fromhex(x) for x in (seed, pub, msg, sig))
self.assertEqual(vr.ed25519_public_key(seed), pub)
self.assertTrue(vr.ed25519_verify(pub, msg, sig))
self.assertEqual(_sign(seed, msg), sig)
def test_any_changed_bit_is_refused(self):
seed, pub, msg, sig = (bytes.fromhex(x) for x in self.RFC[1])
self.assertFalse(vr.ed25519_verify(pub, msg + b"\x00", sig))
for i in (0, 31, 32, 63):
bad = bytearray(sig)
bad[i] ^= 1
self.assertFalse(vr.ed25519_verify(pub, msg, bytes(bad)))
other = vr.ed25519_public_key(b"\x02" * 32)
self.assertFalse(vr.ed25519_verify(other, msg, sig))
def test_malformed_input_is_false_not_an_error(self):
_, pub, msg, sig = (bytes.fromhex(x) for x in self.RFC[0])
self.assertFalse(vr.ed25519_verify(pub[:31], msg, sig))
self.assertFalse(vr.ed25519_verify(pub, msg, sig[:63]))
# S at or above the group order is refused (signature malleability).
s = int.from_bytes(sig[32:], "little") + vr._Q
if s < 1 << 256:
self.assertFalse(vr.ed25519_verify(pub, msg, sig[:32] + s.to_bytes(32, "little")))
# A y coordinate that is on no point of the curve.
self.assertFalse(vr.ed25519_verify(b"\x02" + b"\x00" * 31, msg, sig))
class StatementTests(unittest.TestCase):
def test_statement_bytes(self):
self.assertEqual(vr.statement("bastion-agent", "0.1.13", "ab" * 32),
b"privatae-release-signature-v1\nbastion-agent\n0.1.13\n" + b"ab" * 32 + b"\n")
def test_the_signing_tool_and_this_check_agree(self):
# The vector the bastion's release-signing tool and the bastion share:
# seed of 32 bytes of 1, this manifest, kind "bastion", this signature.
# If the statement format drifted here, this would fail.
pub = base64.b64encode(vr.ed25519_public_key(b"\x01" * 32)).decode()
body = b'{"version":"1.2.3","binaries":{}}'
sig = "tr5h3oXjBDmwvwQiZSriW4T6Y6oBFQDymfofLEGXMdTJSXfoQAKN+ajhCs89FO9vdCEZH3Y8mYzYXPZ7RzZVDw=="
m, i = vr.check_signature(body, sig, keys=(pub,), kind="bastion")
self.assertEqual((m["version"], i), ("1.2.3", 0))
# Bound to its kind: a bastion manifest's signature is not an agent's.
with self.assertRaises(vr.VerifyError):
vr.check_signature(body, sig, keys=(pub,), kind="bastion-agent")
def test_signature_file_name(self):
body = b'{"version":"0.1.13"}'
self.assertEqual(vr.sig_name(body),
"version.json.%s.sig" % hashlib.sha256(body).hexdigest()[:16])
def test_pinned_keys_are_two_valid_points(self):
self.assertEqual(len(vr.RELEASE_KEYS), 2)
for k in vr.RELEASE_KEYS:
raw = base64.b64decode(k, validate=True)
self.assertEqual(len(raw), 32)
self.assertIsNotNone(vr._decompress(raw))
def test_refusals(self):
seed = b"\x03" * 32
pub = base64.b64encode(vr.ed25519_public_key(seed)).decode()
def signed(body):
msg = vr.statement("bastion-agent", json.loads(body)["version"], hashlib.sha256(body).hexdigest())
return base64.b64encode(_sign(seed, msg)).decode()
body = b'{"version":"0.2.0"}'
self.assertEqual(vr.check_signature(body, signed(body), keys=(pub,))[1], 0)
# The key that verified is reported by position: the spare is second.
self.assertEqual(vr.check_signature(body, signed(body), keys=(vr.RELEASE_KEYS[0], pub))[1], 1)
cases = (
(b"not json", "AAAA"),
(b'{"version":""}', signed(body)),
(b'{"version":"0.2.0\\n"}', signed(body)),
(body, "not base64!"),
(body, base64.b64encode(b"short").decode()),
(body + b" ", signed(body)), # the manifest changed after signing
(body, signed(body)), # the production keys did not sign it
)
for i, (b, s) in enumerate(cases):
keys = None if i == len(cases) - 1 else (pub,)
with self.subTest(i=i), self.assertRaises(vr.VerifyError):
vr.check_signature(b, s, keys=keys)
@unittest.skipUnless(shutil.which("git"), "needs git")
class EndToEndTests(unittest.TestCase):
"""The whole check against a throwaway repository holding this tree."""
SEED = b"\x04" * 32
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.mkdtemp(prefix="agent-verify-test-")
cls.repo = os.path.join(cls.tmp, "repo")
ignore = shutil.ignore_patterns("__pycache__", "*.pyc")
shutil.copytree(HERE, os.path.join(cls.repo, "bastion_agent"), ignore=ignore)
shutil.copytree(os.path.join(REPO, "pii"), os.path.join(cls.repo, "pii"), ignore=ignore)
shutil.copy(os.path.join(REPO, "LICENSE"), cls.repo)
git = ("git", "-C", cls.repo, "-c", "user.name=test", "-c", "user.email=test@example.invalid",
"-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false")
run = lambda *a: subprocess.run(git + a, check=True, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, universal_newlines=True).stdout.strip()
subprocess.run(("git", "init", "-q", cls.repo), check=True)
run("add", "-A")
run("commit", "-q", "-m", "release")
cls.commit = run("rev-parse", "HEAD")
cls.run_git = staticmethod(run)
cls.version = bd.version()
# The same files as this tree, so the same bytes as building this tree.
cls.digest = hashlib.sha256(bd.build_bytes()).hexdigest()
cls.pub = base64.b64encode(vr.ed25519_public_key(cls.SEED)).decode()
@classmethod
def tearDownClass(cls):
shutil.rmtree(cls.tmp, ignore_errors=True)
def manifest(self, built_from, digest=None):
name = "bastion-agent-%s.pyz" % self.version
return json.dumps({"version": self.version, "built_from": built_from,
"files": {name: {"sha256": digest or self.digest},
name + ".sha256": {"sha256": "0" * 64}}}).encode()
def verify(self, body, seed=None, args=()):
msg = vr.statement("bastion-agent", self.version, hashlib.sha256(body).hexdigest())
sig = base64.b64encode(_sign(seed or self.SEED, msg)) + b"\n"
served = {"/version.json": body, "/" + vr.sig_name(body): sig}
self.served = served
out, err = io.StringIO(), io.StringIO()
with mock.patch.object(vr, "fetch", lambda url: served[url[len("https://test"):]]), \
contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
code = vr.main(["--base", "https://test"] + list(args), repo=self.repo, keys=(self.pub,))
return code, out.getvalue() + err.getvalue()
def release_tag(self, built_from, annotated=True):
tag = "v" + self.version
if annotated:
self.run_git("tag", "-a", tag, "-m", "bastion agent %s\n\nbuilt from %s" % (self.version, built_from))
else:
self.run_git("tag", tag)
self.addCleanup(subprocess.run, ("git", "-C", self.repo, "tag", "-d", tag),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def test_tag_first_then_built_from(self):
code, out = self.verify(self.manifest("0000000"))
self.assertEqual(code, 1, out)
self.assertIn("neither the tag v%s" % self.version, out)
code, out = self.verify(self.manifest(self.commit[:7]))
self.assertEqual(code, 0, out)
self.assertIn("source: built_from", out)
self.assertIn("matches %s" % self.digest, out)
self.release_tag("0000000")
code, out = self.verify(self.manifest("0000000"))
self.assertEqual(code, 0, out)
self.assertIn("source: tag v%s" % self.version, out)
def test_a_tag_without_the_release_record_is_refused(self):
# A lightweight tag, or one recording another build, says nothing
# about which release it is: refused, not passed over for built_from.
self.release_tag("0000000", annotated=False)
code, out = self.verify(self.manifest(self.commit[:7]))
self.assertEqual(code, 1, out)
self.assertIn("does not carry the release record", out)
self.assertNotIn("rebuild:", out)
self.run_git("tag", "-d", "v" + self.version)
self.release_tag("1111111")
code, out = self.verify(self.manifest(self.commit[:7]))
self.assertEqual(code, 1, out)
self.assertIn("built from %s" % self.commit[:7], out)
def test_another_version_than_expected_is_refused_before_any_checkout(self):
code, out = self.verify(self.manifest(self.commit[:7]), args=["--expect-version", "v9.9.9"])
self.assertEqual(code, 1, out)
self.assertIn("names v%s, not v9.9.9" % self.version, out)
self.assertNotIn("source:", out)
code, out = self.verify(self.manifest(self.commit[:7]), args=["--expect-version", "v" + self.version])
self.assertEqual(code, 0, out)
def test_out_writes_what_a_release_carries(self):
name = "bastion-agent-%s.pyz" % self.version
sums = ("%s %s\n" % (self.digest, name)).encode()
out_dir = os.path.join(self.tmp, "release-out")
self.addCleanup(shutil.rmtree, out_dir, True)
body = json.dumps({"version": self.version, "built_from": self.commit[:7],
"files": {name: {"sha256": self.digest},
name + ".sha256": {"sha256": hashlib.sha256(sums).hexdigest()}}}).encode()
code, out = self.verify(body, args=["--out", out_dir])
self.assertEqual(code, 0, out)
sig = vr.sig_name(body)
self.assertEqual(sorted(os.listdir(out_dir)), sorted([name, name + ".sha256", "version.json", sig]))
with open(os.path.join(out_dir, "version.json"), "rb") as fh:
self.assertEqual(fh.read(), body)
with open(os.path.join(out_dir, sig), "rb") as fh:
self.assertEqual(fh.read(), self.served["/" + sig])
with open(os.path.join(out_dir, name), "rb") as fh:
self.assertEqual(hashlib.sha256(fh.read()).hexdigest(), self.digest)
def test_out_writes_nothing_when_the_rebuild_differs(self):
out_dir = os.path.join(self.tmp, "release-out-bad")
code, out = self.verify(self.manifest(self.commit[:7], digest="1" * 64), args=["--out", out_dir])
self.assertEqual(code, 1, out)
self.assertFalse(os.path.exists(out_dir))
def test_a_different_build_is_reported(self):
code, out = self.verify(self.manifest(self.commit, digest="1" * 64))
self.assertEqual(code, 1, out)
self.assertIn("DIFFERS", out)
def test_a_foreign_signature_stops_before_any_checkout(self):
code, out = self.verify(self.manifest(self.commit), seed=b"\x05" * 32)
self.assertEqual(code, 1, out)
self.assertIn("does not verify", out)
self.assertNotIn("source:", out)
if __name__ == "__main__":
unittest.main()