#!/usr/bin/env python
#
# Copyright 2018 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
#
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for at_auth_unlock."""
import argparse
import filecmp
import os
import shutil
import subprocess
import unittest
from at_auth_unlock import *
from Crypto.PublicKey import RSA
from unittest.mock import patch
def dataPath(file):
return os.path.join(os.path.dirname(__file__), 'data', file)
DATA_FILE_PIK_CERTIFICATE = dataPath('atx_pik_certificate.bin')
DATA_FILE_PUK_CERTIFICATE = dataPath('atx_puk_certificate.bin')
DATA_FILE_PUK_KEY = dataPath('testkey_atx_puk.pem')
DATA_FILE_UNLOCK_CHALLENGE = dataPath('atx_unlock_challenge.bin')
DATA_FILE_UNLOCK_CREDENTIAL = dataPath('atx_unlock_credential.bin')
def createTempZip(contents):
tempzip = tempfile.NamedTemporaryFile()
with zipfile.ZipFile(tempzip, 'w') as zip:
for arcname in contents:
zip.write(contents[arcname], arcname)
return tempzip
def validUnlockCredsZip():
return createTempZip({
'pik_certificate_v1.bin': DATA_FILE_PIK_CERTIFICATE,
'puk_certificate_v1.bin': DATA_FILE_PUK_CERTIFICATE,
'puk_v1.pem': DATA_FILE_PUK_KEY
})
class UnlockCredentialsTest(unittest.TestCase):
def testFromValidZipArchive(self):
with validUnlockCredsZip() as zip:
creds = UnlockCredentials.from_credential_archive(zip)
self.assertIsNotNone(creds.intermediate_cert)
self.assertIsNotNone(creds.unlock_cert)
self.assertIsNotNone(creds.unlock_key)
def testFromInvalidZipArchive(self):
with self.assertRaises(zipfile.BadZipfile):
UnlockCredentials.from_credential_archive(DATA_FILE_PUK_KEY)
def testFromArchiveMissingPikCertificate(self):
with createTempZip({
'puk_certificate_v1.bin': DATA_FILE_PUK_CERTIFICATE,
'puk_v1.pem': DATA_FILE_PUK_KEY
}) as zip:
with self.assertRaises(ValueError):
UnlockCredentials.from_credential_archive(zip)
def testFromArchiveMissingPukCertificate(self):
with createTempZip({
'pik_certificate_v1.bin': DATA_FILE_PIK_CERTIFICATE,
'puk_v1.pem': DATA_FILE_PUK_KEY
}) as zip:
with self.assertRaises(ValueError):
UnlockCredentials.from_credential_archive(zip)
def testFromArchiveMissingPuk(self):
with createTempZip({
'pik_certificate_v1.bin': DATA_FILE_PIK_CERTIFICATE,
'puk_certificate_v1.bin': DATA_FILE_PUK_CERTIFICATE,
}) as zip:
with self.assertRaises(ValueError):
UnlockCredentials.from_credential_archive(zip)
def testFromArchiveMultiplePikCertificates(self):
with createTempZip({
'pik_certificate_v1.bin': DATA_FILE_PIK_CERTIFICATE,
'pik_certificate_v2.bin': DATA_FILE_PIK_CERTIFICATE,
'puk_certificate_v1.bin': DATA_FILE_PUK_CERTIFICATE,
'puk_v1.pem': DATA_FILE_PUK_KEY
}) as zip:
with self.assertRaises(ValueError):
UnlockCredentials.from_credential_archive(zip)
def testFromArchiveMultiplePukCertificates(self):
with createTempZip({
'pik_certificate_v1.bin': DATA_FILE_PIK_CERTIFICATE,
'puk_certificate_v1.bin': DATA_FILE_PUK_CERTIFICATE,
'puk_certificate_v2.bin': DATA_FILE_PUK_CERTIFICATE,
'puk_v1.pem': DATA_FILE_PUK_KEY
}) as zip:
with self.assertRaises(ValueError):
UnlockCredentials.from_credential_archive(zip)
def testFromArchiveMultiplePuks(self):
with createTempZip({
'pik_certificate_v1.bin': DATA_FILE_PIK_CERTIFICATE,
'puk_certificate_v1.bin': DATA_FILE_PUK_CERTIFICATE,
'puk_v1.pem': DATA_FILE_PUK_KEY,
'puk_v2.pem': DATA_FILE_PUK_KEY
}) as zip:
with self.assertRaises(ValueError):
UnlockCredentials.from_credential_archive(zip)
def testFromFiles(self):
creds = UnlockCredentials(
intermediate_cert_file=DATA_FILE_PIK_CERTIFICATE,
unlock_cert_file=DATA_FILE_PUK_CERTIFICATE,
unlock_key_file=DATA_FILE_PUK_KEY)
self.assertIsNotNone(creds.intermediate_cert)
self.assertIsNotNone(creds.unlock_cert)
self.assertIsNotNone(creds.unlock_key)
def testInvalidPuk(self):
with self.assertRaises(ValueError):
UnlockCredentials(
intermediate_cert_file=DATA_FILE_PIK_CERTIFICATE,
unlock_cert_file=DATA_FILE_PUK_CERTIFICATE,
unlock_key_file=DATA_FILE_PUK_CERTIFICATE)
def testPukNotPrivateKey(self):
tempdir = tempfile.mkdtemp()
try:
with open(DATA_FILE_PUK_KEY, 'rb') as f:
key = RSA.importKey(f.read())
pubkey = os.path.join(tempdir, 'pubkey.pub')
with open(pubkey, 'wb') as f:
f.write(key.publickey().exportKey())
with self.assertRaises(ValueError):
UnlockCredentials(
intermediate_cert_file=DATA_FILE_PIK_CERTIFICATE,
unlock_cert_file=DATA_FILE_PUK_CERTIFICATE,
unlock_key_file=pubkey)
finally:
shutil.rmtree(tempdir)
def testWrongSizeCerts(self):
pik_cert = DATA_FILE_PIK_CERTIFICATE
tempdir = tempfile.mkdtemp()
try:
# Copy a valid cert and truncate a single byte from the end to create a
# too-short cert.
shortfile = os.path.join(tempdir, 'shortfile.bin')
shutil.copy2(pik_cert, shortfile)
with open(shortfile, 'ab') as f:
f.seek(-1, os.SEEK_END)
f.truncate()
with self.assertRaises(ValueError):
creds = UnlockCredentials(
intermediate_cert_file=shortfile,
unlock_cert_file=DATA_FILE_PUK_CERTIFICATE,
unlock_key_file=DATA_FILE_PUK_KEY)
with self.assertRaises(ValueError):
creds = UnlockCredentials(
intermediate_cert_file=DATA_FILE_PIK_CERTIFICATE,
unlock_cert_file=shortfile,
unlock_key_file=DATA_FILE_PUK_KEY)
# Copy a valid cert and append an arbitrary byte on the end to create a
# too-long cert.
longfile = os.path.join(tempdir, 'longfile.bin')
shutil.copy2(pik_cert, longfile)
with open(longfile, 'ab') as f:
f.write(b'\0')
with self.assertRaises(ValueError):
creds = UnlockCredentials(
intermediate_cert_file=longfile,
unlock_cert_file=DATA_FILE_PUK_CERTIFICATE,
unlock_key_file=DATA_FILE_PUK_KEY)
with self.assertRaises(ValueError):
creds = UnlockCredentials(
intermediate_cert_file=DATA_FILE_PIK_CERTIFICATE,
unlock_cert_file=longfile,
unlock_key_file=DATA_FILE_PUK_KEY)
finally:
shutil.rmtree(tempdir)
def writeFullUnlockChallenge(out_file, product_id_hash=None):
"""Helper function to create a file with a full AvbAtxUnlockChallenge struct.
Arguments:
product_id_hash: [optional] 32 byte value to include in the challenge as the
SHA256 hash of the product ID. If not provided, will default to the
product ID hash from the subject of DATA_FILE_PUK_CERTIFICATE.
"""
if product_id_hash is None:
with open(DATA_FILE_PUK_CERTIFICATE, 'rb') as f:
product_id_hash = GetAtxCertificateSubject(f.read())
assert len(product_id_hash) == 32
with open(out_file, 'wb') as out:
out.write(struct.pack('<I', 1))
out.write(product_id_hash)
with open(DATA_FILE_UNLOCK_CHALLENGE, 'rb') as f:
out.write(f.read())
class MakeAtxUnlockCredentialTest(unittest.TestCase):
def testCredentialIsCorrect(self):
with validUnlockCredsZip() as zip:
creds = UnlockCredentials.from_credential_archive(zip)
tempdir = tempfile.mkdtemp()
try:
challenge_file = os.path.join(tempdir, 'challenge')
writeFullUnlockChallenge(challenge_file)
challenge = UnlockChallenge(challenge_file)
out_cred = os.path.join(tempdir, 'credential')
# Compare unlock credential generated by function with one generated
# using 'avbtool make_atx_unlock_credential', to check correctness.
MakeAtxUnlockCredential(creds, challenge, out_cred)
self.assertTrue(filecmp.cmp(out_cred, DATA_FILE_UNLOCK_CREDENTIAL))
finally:
shutil.rmtree(tempdir)
def testWrongChallengeSize(self):
with validUnlockCredsZip() as zip:
creds = UnlockCredentials.from_credential_archive(zip)
tempdir = tempfile.mkdtemp()
try:
out_cred = os.path.join(tempdir, 'credential')
# The bundled unlock challenge is just the 16 byte challenge, not the
# full AvbAtxUnlockChallenge like this expects.
with self.assertRaises(ValueError):
challenge = UnlockChallenge(DATA_FILE_UNLOCK_CHALLENGE)
MakeAtxUnlockCredential(creds, challenge, out_cred)
finally:
shutil.rmtree(tempdir)
def makeFastbootCommandFake(testcase,
expect_serial=None,
error_on_command_number=None,
product_id_hash=None,
stay_locked=False):
"""Construct a fake fastboot command handler, to be used with unitttest.mock.Mock.side_effect.
This can be used to create a callable that acts as a fake for a real device
responding to the fastboot commands involved in an authenticated unlock. The
returned callback is intended to be used with unittest.mock.Mock.side_effect.
There are a number of optional arguments here that can be used to customize
the behavior of the fake for a specific test.
Arguments:
testcase: unittest.TestCase object for the associated test
expect_serial: [optional] Expect (and assert) that the fastboot command
specifies a specific device serial to communicate with.
error_on_command_number: [optional] Return a fastboot error (non-zero exit
code) on the nth (0-based) command handled.
stay_locked: [optional] Make the fake report that the device is still locked
after an otherwise successful unlock attempt.
"""
def handler(args, *extraArgs, **kwargs):
if error_on_command_number is not None:
handler.command_counter += 1
if handler.command_counter - 1 == error_on_command_number:
raise subprocess.CalledProcessError(
returncode=1, cmd=args, output=b'Fake: ERROR')
testcase.assertEqual(args.pop(0), 'fastboot')
if expect_serial is not None:
# This is a bit fragile in that, in reality, fastboot allows '-s SERIAL'
# to not just be the first arguments, but it works for this use case.
testcase.assertEqual(args.pop(0), '-s')
testcase.assertEqual(args.pop(0), expect_serial)
if args[0:2] == ['oem', 'at-get-vboot-unlock-challenge']:
handler.challenge_staged = True
elif args[0] == 'get_staged':
if not handler.challenge_staged:
raise subprocess.CalledProcessError(
returncode=1, cmd=args, output=b'Fake: No data staged')
writeFullUnlockChallenge(args[1], product_id_hash=product_id_hash)
handler.challenge_staged = False
elif args[0] == 'stage':
handler.staged_file = args[1]
elif args[0:2] == ['oem', 'at-unlock-vboot']:
if handler.staged_file is None:
raise subprocess.CalledProcessError(
returncode=1, cmd=args, output=b'Fake: No unlock credential staged')
# Validate the unlock credential as if this were a test key locked device,
# which implies tests that want a successful unlock need to be set up to
# use DATA_FILE_PUK_KEY to sign the challenge. Credentials generated using
# other keys will be properly rejected.
if not filecmp.cmp(handler.staged_file, DATA_FILE_UNLOCK_CREDENTIAL):
raise subprocess.CalledProcessError(
returncode=1, cmd=args, output=b'Fake: Incorrect unlock credential')
handler.locked = True if stay_locked else False
elif args[0:2] == ['getvar', 'at-vboot-state']:
return b'avb-locked: ' + (b'1' if handler.locked else b'0')
return b'Fake: OK'
handler.command_counter = 0
handler.challenge_staged = False
handler.staged_file = None
handler.locked = True
return handler
class AuthenticatedUnlockTest(unittest.TestCase):
@patch('subprocess.check_output')
def testUnlockWithZipArchive(self, mock_subp_check_output):
with validUnlockCredsZip() as zip:
mock_subp_check_output.side_effect = makeFastbootCommandFake(self)
self.assertEqual(main([zip.name]), 0)
self.assertNotEqual(mock_subp_check_output.call_count, 0)
@patch('subprocess.check_output')
def testUnlockDeviceBySerial(self, mock_subp_check_output):
with validUnlockCredsZip() as zip:
SERIAL = 'abcde12345'
mock_subp_check_output.side_effect = makeFastbootCommandFake(
self, expect_serial=SERIAL)
self.assertEqual(main([zip.name, '-s', SERIAL]), 0)
self.assertNotEqual(mock_subp_check_output.call_count, 0)
@patch('subprocess.check_output')
def testUnlockWithIndividualFiles(self, mock_subp_check_output):
mock_subp_check_output.side_effect = makeFastbootCommandFake(self)
self.assertEqual(
main([
'--pik_cert', DATA_FILE_PIK_CERTIFICATE, '--puk_cert',
DATA_FILE_PUK_CERTIFICATE, '--puk', DATA_FILE_PUK_KEY
]), 0)
self.assertNotEqual(mock_subp_check_output.call_count, 0)
@patch('subprocess.check_output')
def testFastbootError(self, mock_subp_check_output):
"""Verify that errors are handled properly if fastboot commands error out."""
with validUnlockCredsZip() as zip:
for n in range(5):
mock_subp_check_output.reset_mock()
mock_subp_check_output.side_effect = makeFastbootCommandFake(
self, error_on_command_number=n)
self.assertNotEqual(main([zip.name]), 0)
self.assertNotEqual(mock_subp_check_output.call_count, 0)
@patch('subprocess.check_output')
def testDoesntActuallyUnlock(self, mock_subp_check_output):
"""Verify fails if fake set to not actually unlock."""
with validUnlockCredsZip() as zip:
mock_subp_check_output.side_effect = makeFastbootCommandFake(
self, stay_locked=True)
self.assertNotEqual(main([zip.name]), 0)
self.assertNotEqual(mock_subp_check_output.call_count, 0)
@patch('subprocess.check_output')
def testNoCredentialsMatchDeviceProductID(self, mock_subp_check_output):
"""Test two cases where fake responds with a challenge that has a product ID hash which doesn't match the credentials used."""
# Case 1: Change the product ID hash that the fake responds with.
with validUnlockCredsZip() as zip:
mock_subp_check_output.side_effect = makeFastbootCommandFake(
self, product_id_hash=b'\x00' * 32)
self.assertNotEqual(main([zip.name]), 0)
self.assertNotEqual(mock_subp_check_output.call_count, 0)
# Case 2: Use credentials with a different product ID.
with createTempZip({
'pik_certificate_v1.bin': DATA_FILE_PIK_CERTIFICATE,
# Note: PIK cert used as PUK cert so subject (i.e. product ID hash) is
# different
'puk_certificate_v1.bin': DATA_FILE_PIK_CERTIFICATE,
'puk_v1.pem': DATA_FILE_PUK_KEY
}) as zip:
mock_subp_check_output.side_effect = makeFastbootCommandFake(self)
self.assertNotEqual(main([zip.name]), 0)
self.assertNotEqual(mock_subp_check_output.call_count, 0)
@patch('subprocess.check_output')
def testMatchingCredentialSelectedFromZipArchives(self,
mock_subp_check_output):
"""Test correct credential based on product ID hash used if multiple provided directly through arguments."""
with validUnlockCredsZip() as correctCreds, createTempZip({
'pik_certificate_v1.bin': DATA_FILE_PIK_CERTIFICATE,
# Note: PIK cert used as PUK cert so subject (i.e. product ID hash)
# doesn't match
'puk_certificate_v1.bin': DATA_FILE_PIK_CERTIFICATE,
'puk_v1.pem': DATA_FILE_PUK_KEY
}) as wrongCreds:
mock_subp_check_output.side_effect = makeFastbootCommandFake(self)
self.assertEqual(main([wrongCreds.name, correctCreds.name]), 0)
self.assertNotEqual(mock_subp_check_output.call_count, 0)
@patch('subprocess.check_output')
def testMatchingCredentialSelectedFromDirectory(self, mock_subp_check_output):
"""Test correct credential based on product ID hash used if multiple provided indirectly through a directory argument."""
with validUnlockCredsZip() as correctCreds, createTempZip({
'pik_certificate_v1.bin': DATA_FILE_PIK_CERTIFICATE,
# Note: PIK cert used as PUK cert so subject (i.e. product ID hash)
# doesn't match
'puk_certificate_v1.bin': DATA_FILE_PIK_CERTIFICATE,
'puk_v1.pem': DATA_FILE_PUK_KEY
}) as wrongCreds:
tempdir = tempfile.mkdtemp()
try:
shutil.copy2(correctCreds.name, tempdir)
shutil.copy2(wrongCreds.name, tempdir)
mock_subp_check_output.side_effect = makeFastbootCommandFake(self)
self.assertEqual(main([tempdir]), 0)
self.assertNotEqual(mock_subp_check_output.call_count, 0)
finally:
shutil.rmtree(tempdir)
@patch('subprocess.check_output')
def testMatchingCredentialSelectedFromEither(self, mock_subp_check_output):
"""Test correct credential based on product ID hash used if arguments give some combination of file and directory arguments."""
with validUnlockCredsZip() as correctCreds, createTempZip({
'pik_certificate_v1.bin': DATA_FILE_PIK_CERTIFICATE,
# Note: PIK cert used as PUK cert so subject (i.e. product ID hash)
# doesn't match
'puk_certificate_v1.bin': DATA_FILE_PIK_CERTIFICATE,
'puk_v1.pem': DATA_FILE_PUK_KEY
}) as wrongCreds:
# Case 1: Correct creds in directory, wrong in file arg
tempdir = tempfile.mkdtemp()
try:
shutil.copy2(correctCreds.name, tempdir)
mock_subp_check_output.side_effect = makeFastbootCommandFake(self)
self.assertEqual(main([wrongCreds.name, tempdir]), 0)
self.assertNotEqual(mock_subp_check_output.call_count, 0)
finally:
shutil.rmtree(tempdir)
# Case 2: Correct creds in file arg, wrong in directory
tempdir = tempfile.mkdtemp()
try:
shutil.copy2(wrongCreds.name, tempdir)
mock_subp_check_output.side_effect = makeFastbootCommandFake(self)
self.assertEqual(main([tempdir, correctCreds.name]), 0)
self.assertNotEqual(mock_subp_check_output.call_count, 0)
# Case 2: Correct creds in file arg, wrong in directory
finally:
shutil.rmtree(tempdir)
@patch('argparse.ArgumentParser.error')
def testArgparseDirectoryWithNoCredentials(self, mock_parser_error):
"""Test """
tempdir = tempfile.mkdtemp()
try:
# Make sure random files are ignored.
with open(os.path.join(tempdir, 'so_random'), 'w') as f:
f.write("I'm a random file")
mock_parser_error.side_effect = ValueError('ArgumentParser.error')
with self.assertRaises(ValueError):
main([tempdir])
self.assertEqual(mock_parser_error.call_count, 1)
finally:
shutil.rmtree(tempdir)
@patch('argparse.ArgumentParser.error')
def testArgparseMutualExclusionArchiveAndFiles(self, mock_parser_error):
mock_parser_error.side_effect = ValueError('ArgumentParser.error')
with self.assertRaises(ValueError):
main(['dummy.zip', '--pik_cert', DATA_FILE_PIK_CERTIFICATE])
self.assertEqual(mock_parser_error.call_count, 1)
@patch('argparse.ArgumentParser.error')
def testArgparseMutualInclusionOfFileArgs(self, mock_parser_error):
mock_parser_error.side_effect = ValueError('ArgumentParser.error')
with self.assertRaises(ValueError):
main(['--pik_cert', 'pik_cert.bin', '--puk_cert', 'puk_cert.bin'])
self.assertEqual(mock_parser_error.call_count, 1)
mock_parser_error.reset_mock()
with self.assertRaises(ValueError):
main(['--pik_cert', 'pik_cert.bin', '--puk', 'puk.pem'])
self.assertEqual(mock_parser_error.call_count, 1)
mock_parser_error.reset_mock()
with self.assertRaises(ValueError):
main(['--puk_cert', 'puk_cert.bin', '--puk', 'puk.pem'])
self.assertEqual(mock_parser_error.call_count, 1)
@patch('argparse.ArgumentParser.error')
def testArgparseMissingBundleAndFiles(self, mock_parser_error):
mock_parser_error.side_effect = ValueError('ArgumentParser.error')
with self.assertRaises(ValueError):
main(['-s', '1234abcd'])
self.assertEqual(mock_parser_error.call_count, 1)
if __name__ == '__main__':
unittest.main(verbosity=3)