From 435299768de926f4b63a56c9a9e816eff5c3bf45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20Kr=C3=B3lik?= <66667989+Damian-Nordic@users.noreply.github.com> Date: Fri, 4 Nov 2022 09:51:09 +0100 Subject: [PATCH] [tools] Implement Python version of spake2p tool (#23463) Implement a Python script similar to spake2p tool for better portability and easier integration with build systems. The script allows one to generate SPAKE2+ verifier for a given passcode, salt and iteration count. Also, integrate the script with nRF Connect scripts for generating factory data and add a unit test. Signed-off-by: Damian Krolik Signed-off-by: Damian Krolik --- .../generate_nrfconnect_chip_factory_data.py | 30 ++---- .../tests/test_generate_factory_data.py | 35 ++++++ scripts/tools/spake2p/README.md | 47 ++++++++ scripts/tools/spake2p/spake2p.py | 100 ++++++++++++++++++ 4 files changed, 193 insertions(+), 19 deletions(-) create mode 100644 scripts/tools/spake2p/README.md create mode 100755 scripts/tools/spake2p/spake2p.py diff --git a/scripts/tools/nrfconnect/generate_nrfconnect_chip_factory_data.py b/scripts/tools/nrfconnect/generate_nrfconnect_chip_factory_data.py index 02ebc6de0a650f..0577e4ce9c4709 100644 --- a/scripts/tools/nrfconnect/generate_nrfconnect_chip_factory_data.py +++ b/scripts/tools/nrfconnect/generate_nrfconnect_chip_factory_data.py @@ -171,29 +171,25 @@ def gen_test_certs(chip_cert_exe: str, new_certificates["PAI_CERT"] + ".der") -def gen_spake2p_params(spake2p_path: str, passcode: int, it: int, salt: bytes) -> dict: - """ Generate Spake2+ params using external spake2p tool +def gen_spake2p_verifier(passcode: int, it: int, salt: bytes) -> str: + """ Generate Spake2+ verifier using SPAKE2+ Python Tool Args: - spake2p_path (str): path to spake2p executable passcode (int): Pairing passcode using in Spake2+ it (int): Iteration counter for Spake2+ verifier generation salt (str): Salt used to generate Spake2+ verifier Returns: - dict: dictionary containing passcode, it, salt, and generated Verifier + verifier encoded in Base64 """ cmd = [ - spake2p_path, 'gen-verifier', + os.path.join(MATTER_ROOT, 'scripts/tools/spake2p/spake2p.py'), 'gen-verifier', + '--passcode', str(passcode), + '--salt', base64.b64encode(salt).decode('ascii'), '--iteration-count', str(it), - '--salt', base64.b64encode(salt), - '--pin-code', str(passcode), - '--out', '-', ] - output = subprocess.check_output(cmd) - output = output.decode('utf-8').splitlines() - return dict(zip(output[0].split(','), output[1].split(','))) + return subprocess.check_output(cmd) class FactoryDataGenerator: @@ -223,8 +219,8 @@ def _validate_args(self): self._user_data = json.loads(self._args.user) except json.decoder.JSONDecodeError as e: raise AssertionError("Provided wrong user data, this is not a JSON format! {}".format(e)) - assert (self._args.spake2_verifier or (self._args.passcode and self._args.spake2p_path)), \ - "Cannot find Spake2+ verifier, to generate a new one please provide passcode (--passcode) and path to spake2p tool (--spake2p_path)" + assert self._args.spake2_verifier or self._args.passcode, \ + "Cannot find Spake2+ verifier, to generate a new one please provide passcode (--passcode)" assert (self._args.chip_cert_path or (self._args.dac_cert and self._args.pai_cert and self._args.dac_key)), \ "Cannot find paths to DAC or PAI certificates .der files. To generate a new ones please provide a path to chip-cert executable (--chip_cert_path)" assert self._args.output.endswith(".json"), \ @@ -347,9 +343,7 @@ def _add_entry(self, name: str, value: any): def _generate_spake2_verifier(self): """ If verifier has not been provided in arguments list it should be generated via external script """ - spake2_params = gen_spake2p_params(self._args.spake2p_path, self._args.passcode, - self._args.spake2_it, self._args.spake2_salt) - return base64.b64decode(spake2_params["Verifier"]) + return base64.b64decode(gen_spake2p_verifier(self._args.passcode, self._args.spake2_it, self._args.spake2_salt)) def _generate_rotating_device_uid(self): """ If rotating device unique ID has not been provided it should be generated """ @@ -446,7 +440,7 @@ def base64_str(s): return base64.b64decode(s) help="[string] provide human-readable product number") optional_arguments.add_argument("--chip_cert_path", type=str, help="Generate DAC and PAI certificates instead giving a path to .der files. This option requires a path to chip-cert executable." - "By default You can find spake2p in connectedhomeip/src/tools/chip-cert directory and build it there.") + "By default you can find chip-cert in connectedhomeip/src/tools/chip-cert directory and build it there.") optional_arguments.add_argument("--dac_cert", type=str, help="[.der] Provide the path to .der file containing DAC certificate.") optional_arguments.add_argument("--dac_key", type=str, @@ -461,8 +455,6 @@ def base64_str(s): return base64.b64decode(s) help="[hex string] [128-bit hex-encoded] Provide the rotating device unique ID. If this argument is not provided a new rotating device id unique id will be generated.") optional_arguments.add_argument("--passcode", type=allow_any_int, help="[int | hex] Default PASE session passcode. (This is mandatory to generate Spake2+ verifier).") - optional_arguments.add_argument("--spake2p_path", type=str, - help="[string] Provide a path to spake2p. By default You can find spake2p in connectedhomeip/src/tools/spake2p directory and build it there.") optional_arguments.add_argument("--spake2_verifier", type=base64_str, help="[base64 string] Provide Spake2+ verifier without generating it.") optional_arguments.add_argument("--enable_key", type=str, diff --git a/scripts/tools/nrfconnect/tests/test_generate_factory_data.py b/scripts/tools/nrfconnect/tests/test_generate_factory_data.py index 5d59a8d4d56538..af8b04ce3232a3 100755 --- a/scripts/tools/nrfconnect/tests/test_generate_factory_data.py +++ b/scripts/tools/nrfconnect/tests/test_generate_factory_data.py @@ -198,6 +198,41 @@ def test_generate_factory_data_all_specified(self): self.assertEqual(factory_data.get('rd_uid'), 'hex:91a9c12a7c80700a31ddcfa7fce63e44') self.assertEqual(factory_data.get('enable_key'), 'hex:00112233445566778899aabbccddeeff') + def test_generate_spake2p_verifier_default(self): + with tempfile.TemporaryDirectory() as outdir: + write_file(os.path.join(outdir, 'DAC_key.der'), DAC_DER_KEY) + write_file(os.path.join(outdir, 'DAC_cert.der'), DAC_DER_CERT) + write_file(os.path.join(outdir, 'PAI_cert.der'), PAI_DER_CERT) + + subprocess.check_call(['python3', os.path.join(TOOLS_DIR, 'generate_nrfconnect_chip_factory_data.py'), + '-s', os.path.join(TOOLS_DIR, 'nrfconnect_factory_data.schema'), + '--sn', 'SN:12345678', + '--vendor_id', '0x127F', + '--product_id', '0xABCD', + '--vendor_name', 'Nordic Semiconductor ASA', + '--product_name', 'Lock', + '--date', '2022-07-20', + '--hw_ver', '101', + '--hw_ver_str', 'v1.1', + '--dac_key', os.path.join(outdir, 'DAC_key.der'), + '--dac_cert', os.path.join(outdir, 'DAC_cert.der'), + '--pai_cert', os.path.join(outdir, 'PAI_cert.der'), + '--spake2_it', '1000', + '--spake2_salt', 'U1BBS0UyUCBLZXkgU2FsdA==', + '--passcode', '20202021', + '--discriminator', '0xFED', + '-o', os.path.join(outdir, 'fd.json') + ]) + + factory_data = read_json(os.path.join(outdir, 'fd.json')) + + self.assertEqual(factory_data.get('passcode'), None) + self.assertEqual(factory_data.get('spake2_salt'), + base64_to_json('U1BBS0UyUCBLZXkgU2FsdA==')) + self.assertEqual(factory_data.get('spake2_it'), 1000) + self.assertEqual(factory_data.get('spake2_verifier'), base64_to_json( + 'uWFwqugDNGiEck/po7KHwwMwwqZgN10XuyBajPGuyzUEV/iree4lOrao5GuwnlQ65CJzbeUB49s31EH+NEkg0JVI5MGCQGMMT/SRPFNRODm3wH/MBiehuFc6FJ/NH6Rmzw==')) + if __name__ == '__main__': unittest.main() diff --git a/scripts/tools/spake2p/README.md b/scripts/tools/spake2p/README.md new file mode 100644 index 00000000000000..cf760d4d649da2 --- /dev/null +++ b/scripts/tools/spake2p/README.md @@ -0,0 +1,47 @@ +# SPAKE2+ Python Tool + +SPAKE2+ Python Tool is a Python script for generating SPAKE2+ protocol +parameters (only Verifier as of today). SPAKE2+ protocol is used during Matter +commissioning to establish a secure session between the commissioner and the +commissionee. + +## Usage Examples + +To list all available subcommands: + +```console +$ ./spake2p.py --help +usage: spake2p.py [-h] subcommand ... + +SPAKE2+ Python Tool + +positional arguments: + subcommand + gen-verifier Generate SPAKE2+ Verifier + +options: + -h, --help show this help message and exit +``` + +To display parameters of the `gen-verifier` subcommand: + +```console +$ ./spake2p.py gen-verifier --help +usage: spake2p.py gen-verifier [-h] -p PASSCODE -s SALT -i count + +options: + -h, --help show this help message and exit + -p PASSCODE, --passcode PASSCODE + 8-digit passcode + -s SALT, --salt SALT Salt of length 16 to 32 octets encoded in Base64 + -i count, --iteration-count count + Iteration count between 1000 and 100000 +``` + +To generate SPAKE2+ verifier for "SPAKE2P Key Salt" salt and 20202021 passcode, +using 1000 PBKDF2 iterations: + +```console +./spake2p.py gen-verifier -p 20202021 -s U1BBS0UyUCBLZXkgU2FsdA== -i 1000 +uWFwqugDNGiEck/po7KHwwMwwqZgN10XuyBajPGuyzUEV/iree4lOrao5GuwnlQ65CJzbeUB49s31EH+NEkg0JVI5MGCQGMMT/SRPFNRODm3wH/MBiehuFc6FJ/NH6Rmzw== +``` diff --git a/scripts/tools/spake2p/spake2p.py b/scripts/tools/spake2p/spake2p.py new file mode 100755 index 00000000000000..2b654680824645 --- /dev/null +++ b/scripts/tools/spake2p/spake2p.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 + +# +# Copyright (c) 2022 Project CHIP Authors +# All rights reserved. +# +# 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. +# + +import argparse +import base64 +from ecdsa.curves import NIST256p +import hashlib +import struct + +# Forbidden passcodes as listed in the "5.1.7.1. Invalid Passcodes" section of the Matter spec +INVALID_PASSCODES = [00000000, + 11111111, + 22222222, + 33333333, + 44444444, + 55555555, + 66666666, + 77777777, + 88888888, + 99999999, + 12345678, + 87654321, ] + +# Length of `w0s` and `w1s` elements +WS_LENGTH = NIST256p.baselen + 8 + + +def generate_verifier(passcode: int, salt: bytes, iterations: int) -> bytes: + ws = hashlib.pbkdf2_hmac('sha256', struct.pack(' int: + passcode = int(arg) + + if not 0 <= passcode <= 99999999: + raise argparse.ArgumentTypeError('passcode out of range') + + if passcode in INVALID_PASSCODES: + raise argparse.ArgumentTypeError('invalid passcode') + + return passcode + + def salt_arg(arg: str) -> bytes: + salt = base64.b64decode(arg) + + if not 16 <= len(salt) <= 32: + raise argparse.ArgumentTypeError('invalid salt length') + + return salt + + def iterations_arg(arg: str) -> int: + iterations = int(arg) + + if not 1000 <= iterations <= 100000: + raise argparse.ArgumentTypeError('iteration count out of range') + + return iterations + + parser = argparse.ArgumentParser(description='SPAKE2+ Python Tool', fromfile_prefix_chars='@') + commands = parser.add_subparsers(dest='command', metavar='subcommand'.ljust(16), required=True) + + gen_verifier = commands.add_parser('gen-verifier', help='Generate SPAKE2+ Verifier') + gen_verifier.add_argument('-p', '--passcode', type=passcode_arg, + required=True, help='8-digit passcode') + gen_verifier.add_argument('-s', '--salt', type=salt_arg, + required=True, help='Salt of length 16 to 32 octets encoded in Base64') + gen_verifier.add_argument('-i', '--iteration-count', type=iterations_arg, + metavar='count', required=True, help='Iteration count between 1000 and 100000') + + args = parser.parse_args() + + if args.command == 'gen-verifier': + verifier = generate_verifier(args.passcode, args.salt, args.iteration_count) + print(base64.b64encode(verifier).decode('ascii')) + + +if __name__ == '__main__': + main()