268 lines
13 KiB
Python
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()
|