Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[tools] Implement Python version of spake2p tool #23463

Merged
merged 1 commit into from
Nov 4, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()