Skip to content

Commit 495a6c3

Browse files
committed
lib/efi: Rework Certificate and EFIAuth
Separate a variable's owner key and non-owner certs. This allows creating Secure Boot variables with multiple certificates. Also make self-signed cert/key initialization explicit. Signed-off-by: Tu Dinh <[email protected]>
1 parent 41cd8af commit 495a6c3

File tree

3 files changed

+159
-87
lines changed

3 files changed

+159
-87
lines changed

lib/efi.py

Lines changed: 136 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,47 @@
77
import hashlib
88
import logging
99
import os
10+
from pathlib import Path
1011
import shutil
1112
import struct
1213
from datetime import datetime, timedelta
13-
from tempfile import TemporaryDirectory
14+
from tempfile import TemporaryDirectory, mkstemp
15+
from typing import Iterable, Literal, Optional, Union, cast
1416
from uuid import UUID
1517

1618
from cryptography import x509
1719
from cryptography.hazmat.primitives import hashes, serialization
1820
from cryptography.hazmat.primitives.serialization import Encoding, pkcs7
21+
from cryptography.hazmat.primitives.serialization.pkcs7 import PKCS7PrivateKeyTypes
1922

2023
import lib.commands as commands
2124

2225

26+
class _EfiGlobalTempdir:
27+
_instance = None
28+
29+
def _safe_cleanup(self):
30+
if self._instance is not None:
31+
try:
32+
self._instance.cleanup()
33+
except OSError:
34+
pass
35+
36+
def get(self):
37+
if self._instance is None:
38+
self._instance = TemporaryDirectory()
39+
atexit.register(self._safe_cleanup)
40+
return self._instance
41+
42+
def getfile(self, suffix=None, prefix=None):
43+
fd, path = mkstemp(suffix=suffix, prefix=prefix, dir=self.get().name)
44+
os.close(fd)
45+
return path
46+
47+
48+
_tempdir = _EfiGlobalTempdir()
49+
50+
2351
class GUID(UUID):
2452
def as_bytes(self):
2553
return self.bytes_le
@@ -128,8 +156,14 @@ def get_secure_boot_guid(variable: str) -> GUID:
128156
def cert_to_efi_sig_list(cert):
129157
"""Return an ESL from a PEM cert."""
130158
with open(cert, 'rb') as f:
131-
pem = f.read()
132-
cert = x509.load_pem_x509_certificate(pem)
159+
cert_raw = f.read()
160+
# Cert files can come in either PEM or DER form, and we can't assume
161+
# that they come in a specific form. Since `cryptography` doesn't have
162+
# a way to detect cert format, we have to detect it ourselves.
163+
try:
164+
cert = x509.load_pem_x509_certificate(cert_raw)
165+
except ValueError:
166+
cert = x509.load_der_x509_certificate(cert_raw)
133167
der = cert.public_bytes(Encoding.DER)
134168

135169
signature_type = EFI_CERT_X509_GUID
@@ -164,7 +198,13 @@ def certs_to_sig_db(certs) -> bytes:
164198
return db
165199

166200

167-
def sign_efi_sig_db(sig_db, var, key, cert, time=None, guid=None):
201+
def sign_efi_sig_db(
202+
sig_db: bytes,
203+
var: str,
204+
key: str,
205+
cert: str,
206+
time: Optional[datetime] = None,
207+
guid: Optional[GUID] = None):
168208
"""Return a pkcs7 SignedData from a UEFI signature database."""
169209
global p7_out
170210

@@ -214,10 +254,10 @@ def sign_efi_sig_db(sig_db, var, key, cert, time=None, guid=None):
214254
return create_auth2_header(p7, timestamp) + p7 + sig_db
215255

216256

217-
def sign(payload, key_file, cert_file):
257+
def sign(payload: bytes, key_file: str, cert_file: str):
218258
"""Returns a signed PKCS7 of payload signed by key and cert."""
219259
with open(key_file, 'rb') as f:
220-
priv_key = serialization.load_pem_private_key(f.read(), password=None)
260+
priv_key = cast(PKCS7PrivateKeyTypes, serialization.load_pem_private_key(f.read(), password=None))
221261

222262
with open(cert_file, 'rb') as f:
223263
cert = x509.load_pem_x509_certificate(f.read())
@@ -231,12 +271,12 @@ def sign(payload, key_file, cert_file):
231271
return (
232272
pkcs7.PKCS7SignatureBuilder()
233273
.set_data(payload)
234-
.add_signer(cert, priv_key, hashes.SHA256()) # type: ignore
274+
.add_signer(cert, priv_key, hashes.SHA256())
235275
.sign(serialization.Encoding.DER, options)
236276
)
237277

238278

239-
def create_auth2_header(sig_db, timestamp):
279+
def create_auth2_header(sig_db: bytes, timestamp: bytes):
240280
"""Return an EFI_AUTHENTICATE_VARIABLE_2 from a signature database."""
241281
length = len(sig_db) + WIN_CERTIFICATE_UEFI_GUID_offset
242282
revision = 0x200
@@ -293,52 +333,67 @@ def pesign(key, cert, name, image):
293333

294334

295335
class Certificate:
296-
def __init__(self, common_name='XCP-ng Test Common Name', init_keys=True):
297-
self.common_name = common_name
298-
self.name = common_name.replace(' ', '_').lower()
299-
self.tempdir = TemporaryDirectory(prefix='cert_' + self.name)
300-
atexit.register(self.tempdir.cleanup)
301-
self.key = os.path.join(self.tempdir.name, '%s.key' % self.name)
302-
self.pub = os.path.join(self.tempdir.name, 'tmp.crt')
303-
304-
if init_keys:
305-
commands.local_cmd([
306-
'openssl', 'req', '-new', '-x509', '-newkey', 'rsa:2048',
307-
'-subj', '/CN=%s/' % self.common_name, '-nodes', '-keyout',
308-
self.key, '-sha256', '-days', '3650', '-out', self.pub
309-
])
336+
def __init__(self, pub: str, key: Optional[str]):
337+
self.pub = pub
338+
self.key = key
339+
340+
@classmethod
341+
def self_signed(cls, common_name='XCP-ng Test Common Name'):
342+
pub = _tempdir.getfile(suffix='.pem')
343+
key = _tempdir.getfile(suffix='.pem')
344+
345+
commands.local_cmd([
346+
'openssl', 'req', '-new', '-x509', '-newkey', 'rsa:2048',
347+
'-subj', '/CN=%s/' % common_name, '-nodes', '-keyout',
348+
key, '-sha256', '-days', '3650', '-out', pub
349+
])
350+
351+
return cls(pub, key)
310352

311-
def sign_data(self, var, data, guid):
353+
def sign_efi_sig_db(self, var: str, data: bytes, guid: Optional[GUID]):
354+
assert self.key is not None
312355
return sign_efi_sig_db(
313356
data, var, self.key, self.pub, time=timestamp(), guid=guid
314357
)
315358

316-
def _get_cert_path(self):
317-
return os.path.join(
318-
self.tempdir.name, '_'.join(self.common_name.split()) + '.crt'
319-
)
320-
321359
def copy(self):
322-
obj = Certificate(common_name=self.common_name, init_keys=False)
323-
shutil.copyfile(self.key, obj.key)
324-
shutil.copyfile(self.pub, obj.pub)
325-
return obj
360+
newpub = _tempdir.getfile(suffix='.pem')
361+
shutil.copyfile(self.pub, newpub)
362+
363+
newkey = None
364+
if self.key is not None:
365+
newkey = _tempdir.getfile(suffix='.pem')
366+
shutil.copyfile(self.key, newkey)
367+
368+
return Certificate(newpub, newkey)
326369

327370

328371
class EFIAuth:
329-
def __init__(self, name, is_null=False):
330-
if name not in SECURE_BOOT_VARIABLES:
331-
raise RuntimeError(f"{name} is not a secure boot variable")
372+
_auth_data: Optional[bytes]
373+
name: Literal["PK", "KEK", "db", "dbx"]
374+
375+
def __init__(
376+
self,
377+
name: Literal["PK", "KEK", "db", "dbx"],
378+
owner_cert: Optional[Certificate] = None,
379+
other_certs: Optional[Iterable[Union[Certificate, str]]] = None):
380+
assert name in SECURE_BOOT_VARIABLES
381+
# No point having an owner cert without a matching private key
382+
assert owner_cert is None or owner_cert.key is not None
332383
self.name = name
333-
self.is_null = is_null
334384
self.guid = get_secure_boot_guid(self.name)
335-
self.key = ''
336-
self.cert = Certificate()
337-
self.tempdir = TemporaryDirectory(prefix=name + '_')
338-
atexit.register(self.tempdir.cleanup)
339-
self.efi_signature_list = self._get_efi_signature_list()
340-
self.auth_data = None
341-
self.auth = os.path.join(self.tempdir.name, '%s.auth' % self.name)
385+
self._owner_cert = owner_cert
386+
self._other_certs = list(other_certs or [])
387+
self._efi_signature_list = self._get_efi_signature_list()
388+
self._auth_data = None
389+
self._auth = _tempdir.getfile(suffix='.auth')
390+
391+
@classmethod
392+
def self_signed(
393+
cls,
394+
name: Literal["PK", "KEK", "db", "dbx"],
395+
other_certs: Optional[Iterable[Union[Certificate, str]]] = None):
396+
return cls(name, owner_cert=Certificate.self_signed(name + " Owner"), other_certs=other_certs)
342397

343398
def is_signed(self):
344399
return self._auth_data is not None
@@ -351,19 +406,22 @@ def auth(self):
351406
assert self.is_signed()
352407
return self._auth
353408

354-
def sign_auth(self, other: 'EFIAuth'):
409+
def sign_auth(self, to_be_signed: 'EFIAuth'):
355410
"""
356411
Sign another EFIAuth object.
357412
358413
The other EFIAuth's member `auth` will be set to
359414
the path of the .auth file.
360415
"""
361-
other.auth_data = self.cert.sign_data(
362-
other.name, other.efi_signature_list, other.guid
416+
assert self._owner_cert is not None
417+
418+
auth_data = self._owner_cert.sign_efi_sig_db(
419+
to_be_signed.name, to_be_signed._efi_signature_list, to_be_signed.guid
363420
)
421+
to_be_signed._auth_data = auth_data
364422

365-
with open(other.auth, 'wb') as f:
366-
f.write(other.auth_data)
423+
with open(to_be_signed._auth, 'wb') as f:
424+
f.write(auth_data)
367425

368426
def sign_image(self, image: str) -> str:
369427
"""
@@ -376,19 +434,19 @@ def sign_image(self, image: str) -> str:
376434
377435
Returns path to signed image.
378436
"""
437+
assert self._owner_cert is not None
379438
if shutil.which('sbsign'):
380439
signed = get_signed_name(image)
381440
commands.local_cmd([
382-
'sbsign', '--key', self.cert.key, '--cert', self.cert.pub,
441+
'sbsign', '--key', self._owner_cert.key, '--cert', self._owner_cert.pub,
383442
image, '--output', signed
384443
])
385444
else:
386-
signed = pesign(self.cert.key, self.cert.pub, self.name, image)
445+
signed = pesign(self._owner_cert.key, self._owner_cert.pub, self.name, image)
387446

388447
return signed
389448

390-
@classmethod
391-
def copy(cls, other, name=None):
449+
def copy(self, name: Optional[Literal["PK", "KEK", "db", "dbx"]] = None):
392450
"""
393451
Make a copy of an existing EFIAuth object.
394452
@@ -408,24 +466,35 @@ def copy(cls, other, name=None):
408466
409467
This is ONLY useful for creating a new handle.
410468
"""
411-
if name is None:
412-
name = other.name
469+
assert self._owner_cert is not None
470+
471+
newname = name or self.name
413472

414-
obj = cls(name=name, is_null=other.is_null)
415-
obj.cert = other.cert.copy()
416-
obj.efi_signature_list = other.efi_signature_list
473+
copied = EFIAuth(
474+
name=newname,
475+
owner_cert=self._owner_cert.copy(),
476+
other_certs=self._other_certs.copy())
477+
copied._efi_signature_list = self._efi_signature_list
417478

418-
if other.is_signed():
419-
obj.auth_data = copy.copy(other.auth_data)
420-
shutil.copyfile(other.auth, obj.auth)
479+
if self.is_signed():
480+
copied._auth_data = copy.copy(self._auth_data)
481+
shutil.copyfile(self._auth, copied._auth)
421482

422-
return obj
483+
return copied
423484

424485
def _get_efi_signature_list(self) -> bytes:
425-
if self.is_null:
426-
return b''
486+
certs = []
487+
if self._owner_cert is not None:
488+
certs.append(self._owner_cert.pub)
489+
for other_cert in self._other_certs:
490+
if isinstance(other_cert, str):
491+
certs.append(other_cert)
492+
elif isinstance(other_cert, Certificate):
493+
certs.append(other_cert.pub)
494+
else:
495+
raise TypeError('other_cert is not Certificate or str')
427496

428-
return certs_to_sig_db(self.cert.pub)
497+
return certs_to_sig_db(certs)
429498

430499

431500
def esl_from_auth_file(auth: str) -> bytes:
@@ -442,16 +511,16 @@ def esl_from_auth_file(auth: str) -> bytes:
442511
return esl_from_auth_bytes(data)
443512

444513

445-
def esl_from_auth_bytes(auth: bytes) -> bytes:
514+
def esl_from_auth_bytes(auth_data: bytes) -> bytes:
446515
"""
447516
Return the ESL contained inside the AUTH2 structure.
448517
449518
Warning: This will break if used on any ESL containing certs of non-X509 GUID type.
450519
All of the certs used in Secure Boot are X509 GUID type.
451520
"""
452-
return auth[auth.index(EFI_CERT_X509_GUID):]
521+
return auth_data[auth_data.index(EFI_CERT_X509_GUID):]
453522

454-
def get_md5sum_from_auth(auth):
523+
def get_md5sum_from_auth(auth: str):
455524
return hashlib.md5(esl_from_auth_file(auth)).hexdigest()
456525

457526
if __name__ == '__main__':

tests/uefi_sb/test_auth_var.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def set_and_assert_var(vm, cert, new, should_pass):
2222

2323
old = vm.get_efi_var(var, global_variable_guid)
2424

25-
signed = cert.sign_data(var, new, global_variable_guid)
25+
signed = cert.sign_efi_sig_db(var, new, global_variable_guid)
2626

2727
ok = True
2828
try:
@@ -48,7 +48,7 @@ def test_auth_variable(uefi_vm):
4848
try:
4949
vm.wait_for_vm_running_and_ssh_up()
5050

51-
cert = Certificate()
51+
cert = Certificate.self_signed()
5252

5353
# Set the variable
5454
set_and_assert_var(vm, cert, b'I am old news', should_pass=True)
@@ -63,7 +63,7 @@ def test_auth_variable(uefi_vm):
6363
set_and_assert_var(vm, cert, b'new data', should_pass=True)
6464

6565
# Set the variable with new data, signed by a different cert
66-
set_and_assert_var(vm, Certificate(), b'this should fail', should_pass=False)
66+
set_and_assert_var(vm, Certificate.self_signed(), b'this should fail', should_pass=False)
6767
finally:
6868
vm.shutdown(verify=True)
6969

@@ -74,7 +74,10 @@ def test_db_append(uefi_vm):
7474
"""Pass if appending the DB succeeds. Otherwise, fail."""
7575
vm = uefi_vm
7676

77-
PK, KEK, db, db2 = EFIAuth("PK"), EFIAuth("KEK"), EFIAuth("db"), Certificate("db")
77+
PK = EFIAuth.self_signed("PK")
78+
KEK = EFIAuth.self_signed("KEK")
79+
db = EFIAuth.self_signed("db")
80+
db2 = Certificate.self_signed("db")
7881
PK.sign_auth(PK)
7982
PK.sign_auth(KEK)
8083
KEK.sign_auth(db)

0 commit comments

Comments
 (0)