85 lines
4.5 KiB
Python
85 lines
4.5 KiB
Python
"""Create a per-device NVS image and setup label; never print device secrets.
|
|||
|
|
|
||
|
|
Use with the *existing* physical_confirm inventory credential, not a newly
|
||
|
|
invented server key. Output is private production material, not source code.
|
||
|
|
"""
|
||
|
|
import argparse
|
||
|
|
import csv
|
||
|
|
import hashlib
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
from pathlib import Path
|
||
|
|
import re
|
||
|
|
import secrets
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
from urllib.parse import urlsplit
|
||
|
|
|
||
|
|
N = int(''.join([
|
||
|
|
'FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E',
|
||
|
|
'3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE',
|
||
|
|
'9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB9ED5290770',
|
||
|
|
'96966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3BE39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF69558171839',
|
||
|
|
'95497CEA956AE515D2261898FA051015728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6',
|
||
|
|
'E1E4C7ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6BF12FFA06D98A0864D87602733EC86A64521F2B18177B200CBBE117577A615D6C77',
|
||
|
|
'0988C0BAD946E208E24FA074E5AB3143DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF']), 16)
|
||
|
|
|
||
|
|
|
||
|
|
def security_material(username, password, salt):
|
||
|
|
"""Match esp_srp.c: hash raw salt and SHA512 bytes (preserve leading zeros)."""
|
||
|
|
inner = hashlib.sha512(f'{username}:{password}'.encode()).digest()
|
||
|
|
x = int.from_bytes(hashlib.sha512(salt + inner).digest(), 'big')
|
||
|
|
return pow(5, x, N).to_bytes(384, 'big')
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
p = argparse.ArgumentParser(description=__doc__)
|
||
|
|
p.add_argument('--device-id', required=True)
|
||
|
|
p.add_argument('--model', default='ECHOEAR-S3')
|
||
|
|
p.add_argument('--origin', required=True)
|
||
|
|
p.add_argument('--key-version', type=int, required=True)
|
||
|
|
p.add_argument('--device-key-file', type=Path, required=True, help='64 hex chars or controlled migration credential JSON')
|
||
|
|
p.add_argument('--output', type=Path, required=True, help='new private directory; refuses overwrite')
|
||
|
|
a = p.parse_args()
|
||
|
|
origin = urlsplit(a.origin)
|
||
|
|
if (not re.fullmatch(r'KP01-[0-9A-F]{12}', a.device_id) or
|
||
|
|
not re.fullmatch(r'[A-Za-z0-9_.-]{1,32}', a.model) or
|
||
|
|
not 1 <= a.key_version <= 0xffffffff or len(a.origin) > 150 or
|
||
|
|
origin.scheme != 'https' or not origin.hostname or origin.username or
|
||
|
|
origin.path or origin.query or origin.fragment):
|
||
|
|
p.error('invalid identity, model, key version or HTTPS origin')
|
||
|
|
key_hex = a.device_key_file.read_text(encoding='ascii').strip()
|
||
|
|
if key_hex.startswith('{'):
|
||
|
|
identity = json.loads(key_hex)
|
||
|
|
if identity.get('device_id') != a.device_id or identity.get('key_version') != a.key_version:
|
||
|
|
p.error('credential identity does not match the requested factory image')
|
||
|
|
key_hex = identity.get('device_key_hex', '')
|
||
|
|
if not re.fullmatch(r'[0-9a-fA-F]{64}', key_hex):
|
||
|
|
p.error('device key must be exactly 32 bytes in hexadecimal')
|
||
|
|
a.output.mkdir(parents=True, exist_ok=False, mode=0o700)
|
||
|
|
password = secrets.token_urlsafe(16)
|
||
|
|
salt = secrets.token_bytes(16)
|
||
|
|
verifier = security_material(a.device_id, password, salt)
|
||
|
|
values = [('identity', 'namespace', '', ''),
|
||
|
|
('factory_id', 'data', 'string', a.device_id),
|
||
|
|
('model', 'data', 'string', a.model),
|
||
|
|
('origin', 'data', 'string', a.origin),
|
||
|
|
('key_version', 'data', 'u32', a.key_version),
|
||
|
|
('device_key', 'data', 'hex2bin', key_hex),
|
||
|
|
('salt', 'data', 'hex2bin', salt.hex()),
|
||
|
|
('verifier', 'data', 'hex2bin', verifier.hex())]
|
||
|
|
csv_path = a.output / 'factory.csv'
|
||
|
|
with csv_path.open('x', encoding='utf-8', newline='') as f:
|
||
|
|
writer = csv.writer(f); writer.writerow(('key', 'type', 'encoding', 'value')); writer.writerows(values)
|
||
|
|
os.chmod(csv_path, 0o600)
|
||
|
|
subprocess.run([sys.executable, '-m', 'esp_idf_nvs_partition_gen', 'generate',
|
||
|
|
str(csv_path.resolve()), str((a.output / 'ble_factory.bin').resolve()), '0x6000'], check=True)
|
||
|
|
label = a.output / 'setup-label.json'
|
||
|
|
label.write_text(json.dumps({'device_id': a.device_id, 'model': a.model, 'setup_password': password}, ensure_ascii=False, indent=2), encoding='utf-8')
|
||
|
|
os.chmod(label, 0o600); os.chmod(a.output / 'ble_factory.bin', 0o600)
|
||
|
|
print(f'Created private NVS image and label in {a.output.resolve()}. No device key is present in the label.')
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == '__main__':
|
||
|
|
main()
|