Skip to content

Commit

Permalink
[tools] Implement Python version of spake2p tool (#23463)
Browse files Browse the repository at this point in the history
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 <damian.krolik@nordicsemi.no>

Signed-off-by: Damian Krolik <damian.krolik@nordicsemi.no>
  • Loading branch information
Damian-Nordic authored and pull[bot] committed Sep 13, 2023
1 parent 78ae9a4 commit a8b5eca
Show file tree
Hide file tree
Showing 4 changed files with 193 additions and 19 deletions.
30 changes: 11 additions & 19 deletions scripts/tools/nrfconnect/generate_nrfconnect_chip_factory_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"), \
Expand Down Expand Up @@ -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 """
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions scripts/tools/nrfconnect/tests/test_generate_factory_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
47 changes: 47 additions & 0 deletions scripts/tools/spake2p/README.md
Original file line number Diff line number Diff line change
@@ -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==
```
100 changes: 100 additions & 0 deletions scripts/tools/spake2p/spake2p.py
Original file line number Diff line number Diff line change
@@ -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('<I', passcode), salt, iterations, WS_LENGTH * 2)
w0 = int.from_bytes(ws[:WS_LENGTH], byteorder='big') % NIST256p.order
w1 = int.from_bytes(ws[WS_LENGTH:], byteorder='big') % NIST256p.order
L = NIST256p.generator * w1

return w0.to_bytes(NIST256p.baselen, byteorder='big') + L.to_bytes('uncompressed')


def main():
def passcode_arg(arg: str) -> 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()

0 comments on commit a8b5eca

Please sign in to comment.