Identity IoT: initial release — boot manager, provisioning, sensor/relay
This commit is contained in:
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
# Runtime files generated on device — never commit
|
||||
config.json
|
||||
identity_seed.bin
|
||||
identity_pk.b64
|
||||
identity_jwt.txt
|
||||
identity_node_uid.txt
|
||||
|
||||
# Duplicates / backups
|
||||
*.copy
|
||||
*copy*
|
||||
|
||||
# Dev junk
|
||||
blink.py
|
||||
oled_test.py
|
||||
wifitest.py
|
||||
test.py
|
||||
test_*.py
|
||||
|
||||
# VSCode
|
||||
.vscode/
|
||||
*.code-workspace
|
||||
.micropico
|
||||
118
README.md
Normal file
118
README.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# identity-micropython
|
||||
|
||||
**Identity IoT — MicroPython module for Pico W / ESP32**
|
||||
|
||||
(c) WIDE di D. Papa — Naples, Italy
|
||||
(c) æ Aeonian Engineering Limited — Hong Kong
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Ed25519 challenge/response authentication for IoT nodes.
|
||||
Compatible with `identity_layer/identity_iot` PHP backend.
|
||||
|
||||
Private key never leaves the device — stored on flash, never transmitted.
|
||||
Node UID is SHA-256 hashed before sending — raw UID never reaches the server.
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
| File | Description |
|
||||
|---|---|
|
||||
| `identity_iot.py` | Main service — register, challenge, verify |
|
||||
| `identity_iot_ed25519.py` | Pure-Python Ed25519 (PoC — replace with natmod in production) |
|
||||
| `identity_iot_aes.py` | AES-CBC ITEMS envelope (uses `ucryptolib`) |
|
||||
| `example_main.py` | Pico W example |
|
||||
| `package.json` | `mip` install manifest |
|
||||
|
||||
---
|
||||
|
||||
## Install via mip (on-board, requires WiFi)
|
||||
|
||||
```python
|
||||
#Pico W / Pico 2 W
|
||||
import network, utime
|
||||
wlan = network.WLAN(network.STA_IF)
|
||||
wlan.active(True)
|
||||
wlan.connect('YOUR_SSD', 'YOUR_WIFI_PASSWORD')
|
||||
while not wlan.isconnected(): utime.sleep(1)
|
||||
|
||||
import mip
|
||||
mip.install("https://git.aeonianengineering.net/wide/identity-micropython/raw/branch/main/package.json")
|
||||
|
||||
# Then install main.py to root
|
||||
mip.install("https://git.aeonianengineering.net/wide/identity-micropython/raw/branch/main/main.py", target="/")
|
||||
|
||||
```
|
||||
|
||||
## Install via mpremote (from host)
|
||||
|
||||
```bash
|
||||
mpremote cp identity_iot.py :identity_iot.py
|
||||
mpremote cp identity_iot_ed25519.py :identity_iot_ed25519.py
|
||||
mpremote cp identity_iot_aes.py :identity_iot_aes.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
from identity_iot import IdentityIoT
|
||||
|
||||
identity = IdentityIoT(
|
||||
base_url = 'https://ap.parta.app/API/V1/identity_layer/identity_iot/index.php',
|
||||
node_type = 'sensor',
|
||||
node_label = 'pico-01',
|
||||
tenant_id = 1,
|
||||
comm_key = b'your-32-char-bootstrap-key......',
|
||||
comm_iv = b'your-16-char-iv.',
|
||||
)
|
||||
|
||||
# Register (idempotent — safe every boot)
|
||||
identity.ensure_registered()
|
||||
|
||||
# Authenticate — returns JWT
|
||||
token = identity.authenticate()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture notes
|
||||
|
||||
### PoC vs Production
|
||||
|
||||
| Component | PoC (now) | Production |
|
||||
|---|---|---|
|
||||
| Ed25519 | Pure Python (~2-3s/sign) | natmod C (~10ms) |
|
||||
| SHA-512 | Double SHA-256 (approximation) | natmod C (proper) |
|
||||
| AES-CBC | `ucryptolib` built-in | same |
|
||||
| Transport | `urequests` plain/encrypted | same |
|
||||
|
||||
**Important:** the pure-Python Ed25519 uses double-SHA256 as SHA-512 approximation.
|
||||
This is NOT cryptographically correct for production — it works for PoC flow testing only.
|
||||
Replace `identity_iot_ed25519.py` with the natmod version before production deployment.
|
||||
|
||||
### Key storage
|
||||
|
||||
```
|
||||
/identity_seed.bin ← 32-byte Ed25519 seed (never transmitted)
|
||||
/identity_pk.b64 ← public key base64
|
||||
/identity_jwt.txt ← current JWT (refreshed on expiry)
|
||||
/identity_node_uid.txt ← SHA-256(machine.unique_id()) hex
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
Requires [identity_layer](https://git.aeonianengineering.net/wide/identity_layer) PHP backend with `identity_iot` module.
|
||||
|
||||
---
|
||||
|
||||
## IP Notice
|
||||
|
||||
- Rights holder: WIDE di D. Papa — P.IVA IT03744531215
|
||||
- Exclusive worldwide (RoW) licensee: æ Aeonian Engineering Limited — Hong Kong
|
||||
353
boot_manager.py
Normal file
353
boot_manager.py
Normal file
@@ -0,0 +1,353 @@
|
||||
# ============================================================+
|
||||
# Identity IoT — Boot Manager
|
||||
# (c) Copyright : ae Aeonian Engineering Limited - Hong Kong
|
||||
# (c) Copyright : WIDE di D. Papa - Naples - Italy
|
||||
# ============================================================+
|
||||
# State machine: BOOT → CONFIG_CHECK → PROVISIONING | WIFI_CONNECT
|
||||
# → CHECKIN → OPERATIONAL
|
||||
# ============================================================+
|
||||
|
||||
import ujson
|
||||
import utime
|
||||
import network
|
||||
import machine
|
||||
import ubinascii
|
||||
from machine import Pin, I2C
|
||||
|
||||
# Hardware instances — initialised in detect_capabilities() after config load
|
||||
_oled = None
|
||||
_nfc = None
|
||||
_dht = None
|
||||
_relay = None
|
||||
HAS_OLED = False
|
||||
HAS_NFC = False
|
||||
HAS_SENSOR = False
|
||||
HAS_RELAY = False
|
||||
|
||||
CONFIG_PATH = 'config.json'
|
||||
|
||||
STATES = [
|
||||
'BOOT',
|
||||
'CAPABILITY_DETECT',
|
||||
'CONFIG_CHECK',
|
||||
'PROVISIONING',
|
||||
'WIFI_CONNECT',
|
||||
'CHECKIN',
|
||||
'OPERATIONAL',
|
||||
'ERROR',
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Display helpers
|
||||
# =============================================================================
|
||||
|
||||
def _show(lines):
|
||||
if not HAS_OLED or _oled is None:
|
||||
print(' | '.join(str(l) for l in lines))
|
||||
return
|
||||
_oled.fill(0)
|
||||
for i, line in enumerate(lines[:5]):
|
||||
_oled.text(str(line)[:16], 0, i * 12)
|
||||
_oled.show()
|
||||
|
||||
|
||||
def _show_state(state, detail=''):
|
||||
_show(['Identity IoT', 'WIDE / AEL', '', state, detail])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Capability detection
|
||||
# =============================================================================
|
||||
|
||||
def detect_capabilities(cfg=None):
|
||||
global _oled, _nfc, _dht, _relay
|
||||
global HAS_OLED, HAS_NFC, HAS_SENSOR, HAS_RELAY
|
||||
|
||||
pinout = cfg.get('pinout', {}) if cfg else {}
|
||||
i2c0_sda = pinout.get('i2c0_sda', 4)
|
||||
i2c0_scl = pinout.get('i2c0_scl', 5)
|
||||
i2c1_sda = pinout.get('i2c1_sda', 6)
|
||||
i2c1_scl = pinout.get('i2c1_scl', 7)
|
||||
dht_pin = pinout.get('dht_pin', 15)
|
||||
relay_pin = pinout.get('relay_pin', 16)
|
||||
|
||||
# OLED
|
||||
try:
|
||||
from ssd1306 import SSD1306_I2C
|
||||
_i2c0 = I2C(0, sda=Pin(i2c0_sda), scl=Pin(i2c0_scl))
|
||||
_oled = SSD1306_I2C(128, 64, _i2c0)
|
||||
HAS_OLED = True
|
||||
except Exception:
|
||||
_oled = None
|
||||
HAS_OLED = False
|
||||
|
||||
# NFC (PN532 as provisioning dongle only — not present in normal operation)
|
||||
try:
|
||||
from pn532 import PN532_I2C
|
||||
_i2c1 = I2C(1, sda=Pin(i2c1_sda), scl=Pin(i2c1_scl))
|
||||
_nfc = PN532_I2C(_i2c1)
|
||||
HAS_NFC = True
|
||||
except Exception as e:
|
||||
print('[nfc] not detected:', e)
|
||||
_nfc = None
|
||||
HAS_NFC = False
|
||||
|
||||
# Sensor (DHT22)
|
||||
try:
|
||||
import dht
|
||||
_dht = dht.DHT22(Pin(dht_pin))
|
||||
HAS_SENSOR = True
|
||||
except Exception:
|
||||
_dht = None
|
||||
HAS_SENSOR = False
|
||||
|
||||
# Relay
|
||||
try:
|
||||
_relay = Pin(relay_pin, Pin.OUT)
|
||||
HAS_RELAY = True
|
||||
except Exception:
|
||||
_relay = None
|
||||
HAS_RELAY = False
|
||||
|
||||
caps = {
|
||||
'has_oled': HAS_OLED,
|
||||
'has_nfc': HAS_NFC,
|
||||
'has_sensor': HAS_SENSOR,
|
||||
'has_relay': HAS_RELAY,
|
||||
}
|
||||
print('[caps]', caps)
|
||||
return caps
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Config
|
||||
# =============================================================================
|
||||
|
||||
def load_config():
|
||||
try:
|
||||
with open(CONFIG_PATH) as f:
|
||||
return ujson.load(f)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def save_config(cfg):
|
||||
with open(CONFIG_PATH, 'w') as f:
|
||||
ujson.dump(cfg, f)
|
||||
|
||||
|
||||
def validate_config(cfg):
|
||||
required = ['wifi_ssid', 'wifi_pass', 'comm_key', 'comm_iv',
|
||||
'tenant_id', 'node_type', 'node_id', 'identity_iot_url']
|
||||
for k in required:
|
||||
if k not in cfg:
|
||||
print('[config] missing key:', k)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# WiFi
|
||||
# =============================================================================
|
||||
|
||||
def connect_wifi(cfg, timeout=30):
|
||||
_show_state('WIFI_CONNECT', cfg['wifi_ssid'])
|
||||
wlan = network.WLAN(network.STA_IF)
|
||||
wlan.active(True)
|
||||
utime.sleep(2)
|
||||
|
||||
if not wlan.isconnected():
|
||||
wlan.connect(cfg['wifi_ssid'], cfg['wifi_pass'])
|
||||
t = timeout
|
||||
while t > 0:
|
||||
if wlan.isconnected() and wlan.ifconfig()[0] != '0.0.0.0':
|
||||
break
|
||||
utime.sleep(1)
|
||||
t -= 1
|
||||
|
||||
if not wlan.isconnected() or wlan.ifconfig()[0] == '0.0.0.0':
|
||||
_show_state('ERROR', 'WiFi failed')
|
||||
raise Exception('WiFi connect failed')
|
||||
|
||||
if cfg.get('wifi_static'):
|
||||
wlan.ifconfig((
|
||||
cfg['wifi_ip'],
|
||||
cfg['wifi_mask'],
|
||||
cfg['wifi_gw'],
|
||||
cfg['wifi_dns'],
|
||||
))
|
||||
|
||||
ip = wlan.ifconfig()[0]
|
||||
print('[wifi] connected:', ip)
|
||||
_show_state('WIFI_OK', ip)
|
||||
utime.sleep(3)
|
||||
return wlan
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Checkin
|
||||
# =============================================================================
|
||||
|
||||
def do_checkin(cfg):
|
||||
_show_state('CHECKIN', '')
|
||||
from identity_iot import IdentityIoT
|
||||
|
||||
key = cfg['comm_key'].encode() if isinstance(cfg['comm_key'], str) else cfg['comm_key']
|
||||
iv = cfg['comm_iv'].encode() if isinstance(cfg['comm_iv'], str) else cfg['comm_iv']
|
||||
|
||||
identity = IdentityIoT(
|
||||
base_url = cfg['identity_iot_url'],
|
||||
node_type = cfg.get('node_type', 'sensor'),
|
||||
node_label = cfg.get('node_label', ''),
|
||||
tenant_id = cfg['tenant_id'],
|
||||
node_id = cfg['node_id'],
|
||||
comm_key = key,
|
||||
comm_iv = iv,
|
||||
wifi_ssid = cfg['wifi_ssid'],
|
||||
wifi_pass = cfg['wifi_pass'],
|
||||
)
|
||||
|
||||
# Register — retry loop
|
||||
for attempt in range(3):
|
||||
try:
|
||||
_show(['CHECKIN', 'Registering...', 'attempt ' + str(attempt+1) + '/3', '', ''])
|
||||
reg = identity.ensure_registered()
|
||||
break
|
||||
except Exception as e:
|
||||
print('[checkin] register attempt', attempt + 1, ':', e)
|
||||
if attempt == 2:
|
||||
_show_state('ERROR', 'Check network')
|
||||
raise
|
||||
utime.sleep(3)
|
||||
|
||||
uid_short = reg.get('node_uid', '')[:12] + '...'
|
||||
_show(['CHECKIN', 'Node ' + reg.get('result', ''), uid_short, '', ''])
|
||||
utime.sleep(1)
|
||||
|
||||
# Authenticate → JWT
|
||||
_show(['CHECKIN', 'Challenge...', '', '', ''])
|
||||
utime.sleep(0.5)
|
||||
_show(['CHECKIN', 'Signing...', '', 'Please wait', ''])
|
||||
token = identity.authenticate()
|
||||
|
||||
if not token:
|
||||
_show_state('ERROR', 'Auth failed')
|
||||
raise Exception('authenticate returned no token')
|
||||
|
||||
print('[checkin] JWT issued')
|
||||
_show_state('CHECKIN OK', cfg.get('node_label', ''))
|
||||
utime.sleep(1)
|
||||
|
||||
return {'status': 'ok', 'token': token, 'identity': identity}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Boot Manager — main entry point
|
||||
# =============================================================================
|
||||
|
||||
def run():
|
||||
state = 'BOOT'
|
||||
cfg = None
|
||||
caps = {'has_oled': False, 'has_nfc': False, 'has_sensor': False, 'has_relay': False}
|
||||
|
||||
while True:
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
if state == 'BOOT':
|
||||
_show(['Identity IoT', 'WIDE / AEL', '', 'Booting...', ''])
|
||||
utime.sleep(1)
|
||||
state = 'CONFIG_CHECK'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
elif state == 'CONFIG_CHECK':
|
||||
cfg = load_config()
|
||||
if cfg and validate_config(cfg):
|
||||
print('[config] found and valid')
|
||||
state = 'CAPABILITY_DETECT'
|
||||
else:
|
||||
print('[config] missing or invalid — provisioning mode')
|
||||
caps = detect_capabilities()
|
||||
state = 'PROVISIONING'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
elif state == 'CAPABILITY_DETECT':
|
||||
caps = detect_capabilities(cfg)
|
||||
_show([
|
||||
'Identity IoT',
|
||||
'NFC:' + ('Y' if caps['has_nfc'] else 'N') +
|
||||
' SNS:' + ('Y' if caps['has_sensor'] else 'N') +
|
||||
' RLY:' + ('Y' if caps['has_relay'] else 'N'),
|
||||
'',
|
||||
'READY',
|
||||
'',
|
||||
])
|
||||
utime.sleep(1)
|
||||
state = 'WIFI_CONNECT'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
elif state == 'PROVISIONING':
|
||||
if caps.get('has_nfc'):
|
||||
from provisioning_nfc import run_provisioning_nfc
|
||||
_show(['PROVISIONING', 'NFC mode', '', 'Tap writer badge', ''])
|
||||
cfg = run_provisioning_nfc(_nfc)
|
||||
else:
|
||||
from provisioning_server import run_provisioning
|
||||
wlan_sta = network.WLAN(network.STA_IF)
|
||||
_ip = wlan_sta.ifconfig()[0] if wlan_sta.isconnected() else '0.0.0.0'
|
||||
_show(['PROVISIONING', 'IP: ' + _ip, 'HTTP:8080', 'UDP :5000', 'Waiting app...'])
|
||||
cfg = run_provisioning()
|
||||
save_config(cfg)
|
||||
print('[provisioning] config saved — rebooting')
|
||||
_show_state('CONFIG SAVED', 'Rebooting...')
|
||||
utime.sleep(2)
|
||||
machine.reset()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
elif state == 'WIFI_CONNECT':
|
||||
try:
|
||||
connect_wifi(cfg)
|
||||
state = 'CHECKIN'
|
||||
except Exception as e:
|
||||
print('[wifi] error:', e)
|
||||
_show_state('ERROR', 'WiFi')
|
||||
utime.sleep(10)
|
||||
state = 'WIFI_CONNECT'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
elif state == 'CHECKIN':
|
||||
try:
|
||||
import gc
|
||||
gc.collect()
|
||||
checkin_result = do_checkin(cfg)
|
||||
identity = checkin_result.get('identity')
|
||||
state = 'OPERATIONAL'
|
||||
except Exception as e:
|
||||
print('[checkin] error:', e)
|
||||
_show_state('ERROR', 'Checkin')
|
||||
utime.sleep(15)
|
||||
state = 'CHECKIN'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
elif state == 'OPERATIONAL':
|
||||
node_type = cfg.get('node_type', 'sensor')
|
||||
_show_state('OPERATIONAL', node_type)
|
||||
|
||||
if node_type == 'sensor':
|
||||
from node_sensor import run_sensor
|
||||
run_sensor(cfg, caps)
|
||||
|
||||
elif node_type == 'relay':
|
||||
from node_relay import run_relay
|
||||
run_relay(cfg, caps)
|
||||
|
||||
else:
|
||||
print('[operational] unknown node_type:', node_type)
|
||||
_show_state('ERROR', 'node_type?')
|
||||
utime.sleep(30)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
elif state == 'ERROR':
|
||||
_show_state('ERROR', 'halted')
|
||||
utime.sleep(60)
|
||||
machine.reset()
|
||||
35
config_example.json
Normal file
35
config_example.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"wifi_ssid": "YOUR_SSID",
|
||||
"wifi_pass": "YOUR_PASSWORD",
|
||||
"wifi_static": false,
|
||||
"wifi_ip": "192.168.1.50",
|
||||
"wifi_mask": "255.255.255.0",
|
||||
"wifi_gw": "192.168.1.1",
|
||||
"wifi_dns": "8.8.8.8",
|
||||
"comm_key": "YOUR_32_CHAR_KEY________________",
|
||||
"comm_iv": "YOUR_16_CHAR_IV_",
|
||||
"tenant_id": "1",
|
||||
"node_id": "0",
|
||||
"node_type": "sensor",
|
||||
"node_label": "node-01",
|
||||
"identity_iot_url": "https://api.yourhost.com/API/V1/identity_layer/identity_iot/",
|
||||
"data_destination_url": "",
|
||||
"pinout": {
|
||||
"i2c0_sda": 4,
|
||||
"i2c0_scl": 5,
|
||||
"i2c1_sda": 6,
|
||||
"i2c1_scl": 7,
|
||||
"dht_pin": 15,
|
||||
"relay_pin": 16,
|
||||
"rain_pin": 26,
|
||||
"soil_pin": 27,
|
||||
"pot_pin": 28,
|
||||
"switch_pins": [17, 18],
|
||||
"rotary_a": 19,
|
||||
"rotary_b": 20,
|
||||
"rotary_sw": 21,
|
||||
"gps_uart": 1,
|
||||
"gps_tx": 8,
|
||||
"gps_rx": 9
|
||||
}
|
||||
}
|
||||
305
identity_iot.py
Normal file
305
identity_iot.py
Normal file
@@ -0,0 +1,305 @@
|
||||
# ============================================================+
|
||||
# Identity IoT — MicroPython Module
|
||||
# (c) Copyright : ae Aeonian Engineering Limited - Hong Kong
|
||||
# (c) Copyright : WIDE di D. Papa - Naples - Italy
|
||||
# ============================================================+
|
||||
# Ed25519 challenge/response authentication for IoT nodes.
|
||||
# Compatible with identity_layer/identity_iot PHP backend.
|
||||
#
|
||||
# Install via mip:
|
||||
# import mip
|
||||
# mip.install("https://git.aeonianengineering.net/wide/identity-micropython/raw/branch/main/package.json")
|
||||
#
|
||||
# Usage:
|
||||
# from identity_iot import IdentityIoT
|
||||
# identity = IdentityIoT(base_url="https://api.parta.app/API/V1/identity_layer/identity_iot/")
|
||||
# identity.ensure_registered()
|
||||
# token = identity.authenticate()
|
||||
# ============================================================+
|
||||
|
||||
import ujson
|
||||
import ubinascii
|
||||
import uhashlib
|
||||
import urandom
|
||||
import machine
|
||||
import network
|
||||
import utime
|
||||
|
||||
try:
|
||||
import uurequests as requests
|
||||
except ImportError:
|
||||
import urequests as requests
|
||||
|
||||
from identity_iot_ed25519 import ed25519_sign, ed25519_keypair_from_seed
|
||||
|
||||
|
||||
class IdentityIoT:
|
||||
"""
|
||||
Identity IoT authentication service for MicroPython.
|
||||
Device-bound Ed25519 keypair — private key never leaves the device.
|
||||
"""
|
||||
|
||||
_F_SEED = '/identity_seed.bin'
|
||||
_F_PK = '/identity_pk.b64'
|
||||
_F_JWT = '/identity_jwt.txt'
|
||||
_F_NODEID = '/identity_node_uid.txt'
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
app_id: str = 'identity_iot',
|
||||
node_type: str = 'sensor',
|
||||
node_label: str = None,
|
||||
tenant_id = 0,
|
||||
node_id: str = None,
|
||||
comm_key: bytes = None,
|
||||
comm_iv: bytes = None,
|
||||
wifi_ssid: str = None,
|
||||
wifi_pass: str = None,
|
||||
):
|
||||
self.base_url = base_url
|
||||
self.app_id = app_id
|
||||
self.node_type = node_type
|
||||
self.node_label = node_label
|
||||
self.tenant_id = tenant_id
|
||||
self.node_id = node_id
|
||||
self._comm_key = comm_key
|
||||
self._comm_iv = comm_iv
|
||||
self._wifi_ssid = wifi_ssid
|
||||
self._wifi_pass = wifi_pass
|
||||
|
||||
# =========================================================================
|
||||
# Public API
|
||||
# =========================================================================
|
||||
|
||||
def ensure_registered(self):
|
||||
"""Register node if not already registered. Idempotent."""
|
||||
pk = self._load_pk()
|
||||
if pk is None:
|
||||
pk = self._generate_keypair()
|
||||
node_uid = self._get_node_uid()
|
||||
resp = self._post({
|
||||
'request': 'register_node',
|
||||
'public_key_b64': pk,
|
||||
'node_uid': node_uid,
|
||||
'node_type': self.node_type,
|
||||
'node_label': self.node_label or '',
|
||||
'tenant_id': self.tenant_id,
|
||||
})
|
||||
if resp.get('status') != 'ok':
|
||||
raise Exception('register_node failed: ' + str(resp))
|
||||
return resp
|
||||
|
||||
def authenticate(self):
|
||||
"""Full challenge/response flow. Returns JWT string."""
|
||||
|
||||
#Not using cashing
|
||||
#jwt = self._load_jwt()
|
||||
#if jwt and not self._is_jwt_expired(jwt):
|
||||
# return jwt
|
||||
|
||||
node_uid = self._get_node_uid()
|
||||
|
||||
resp = self._post({'request': 'challenge', 'node_uid': node_uid, 'tenant_id': self.tenant_id})
|
||||
if resp.get('status') != 'ok':
|
||||
raise Exception('challenge failed: ' + str(resp))
|
||||
|
||||
challenge_id = resp['challenge_id']
|
||||
nonce_b64 = resp['nonce_b64']
|
||||
|
||||
nonce_bytes = ubinascii.a2b_base64(_normalize_b64(nonce_b64))
|
||||
seed = self._load_seed()
|
||||
signature = ed25519_sign(seed, nonce_bytes)
|
||||
sig_b64 = ubinascii.b2a_base64(signature).decode().strip()
|
||||
|
||||
resp = self._post({
|
||||
'request': 'verify_signature',
|
||||
'challenge_id': challenge_id,
|
||||
'signature_b64': sig_b64,
|
||||
'node_uid': node_uid,
|
||||
'tenant_id': self.tenant_id,
|
||||
})
|
||||
if resp.get('status') != 'ok':
|
||||
raise Exception('verify_signature failed: ' + str(resp))
|
||||
|
||||
token = resp.get('token', '')
|
||||
# JWT not cached — fresh challenge/verify every time
|
||||
# if token:
|
||||
# self._save_jwt(token)
|
||||
return token
|
||||
|
||||
def get_node_uid(self):
|
||||
return self._get_node_uid()
|
||||
|
||||
# =========================================================================
|
||||
# Keypair management
|
||||
# =========================================================================
|
||||
|
||||
def _generate_keypair(self):
|
||||
seed = bytes(urandom.getrandbits(8) for _ in range(32))
|
||||
pk, _ = ed25519_keypair_from_seed(seed)
|
||||
pk_b64 = ubinascii.b2a_base64(pk).decode().strip()
|
||||
with open(self._F_SEED, 'wb') as f:
|
||||
f.write(seed)
|
||||
with open(self._F_PK, 'w') as f:
|
||||
f.write(pk_b64)
|
||||
return pk_b64
|
||||
|
||||
def _load_seed(self):
|
||||
try:
|
||||
with open(self._F_SEED, 'rb') as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
raise StateError('No seed found. Call ensure_registered() first.')
|
||||
|
||||
def _load_pk(self):
|
||||
try:
|
||||
with open(self._F_PK, 'r') as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
# =========================================================================
|
||||
# Node UID
|
||||
# =========================================================================
|
||||
|
||||
def _get_node_uid(self):
|
||||
try:
|
||||
with open(self._F_NODEID, 'r') as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
pass
|
||||
# Fresh UID — hardware ID + random entropy so each new registration is unique
|
||||
raw_uid = machine.unique_id()
|
||||
entropy = bytes([urandom.getrandbits(8) for _ in range(16)])
|
||||
h = uhashlib.sha256(raw_uid + entropy)
|
||||
uid_hex = ubinascii.hexlify(h.digest()).decode()
|
||||
with open(self._F_NODEID, 'w') as f:
|
||||
f.write(uid_hex)
|
||||
return uid_hex
|
||||
|
||||
# =========================================================================
|
||||
# JWT management
|
||||
# =========================================================================
|
||||
|
||||
def _load_jwt(self):
|
||||
try:
|
||||
with open(self._F_JWT, 'r') as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
def _save_jwt(self, token):
|
||||
with open(self._F_JWT, 'w') as f:
|
||||
f.write(token)
|
||||
|
||||
def _is_jwt_expired(self, jwt):
|
||||
try:
|
||||
parts = jwt.split('.')
|
||||
if len(parts) != 3:
|
||||
return True
|
||||
payload_b64 = _normalize_b64(parts[1])
|
||||
payload_json = ubinascii.a2b_base64(payload_b64).decode()
|
||||
payload = ujson.loads(payload_json)
|
||||
exp = payload.get('exp', 0)
|
||||
return utime.time() >= exp
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
# =========================================================================
|
||||
# WiFi reconnect
|
||||
# =========================================================================
|
||||
|
||||
def _ensure_wifi(self):
|
||||
wlan = network.WLAN(network.STA_IF)
|
||||
if wlan.isconnected():
|
||||
return True
|
||||
if not self._wifi_ssid:
|
||||
return False
|
||||
print('WiFi lost, reconnecting...')
|
||||
wlan.active(True)
|
||||
wlan.connect(self._wifi_ssid, self._wifi_pass)
|
||||
timeout = 15
|
||||
while not wlan.isconnected() and timeout > 0:
|
||||
utime.sleep(1)
|
||||
timeout -= 1
|
||||
return wlan.isconnected()
|
||||
|
||||
# =========================================================================
|
||||
# HTTP transport
|
||||
# =========================================================================
|
||||
|
||||
def _post(self, inner: dict) -> dict:
|
||||
if self._comm_key and self._comm_iv:
|
||||
return self._post_encrypted(inner)
|
||||
return self._post_plain(inner)
|
||||
|
||||
def _post_plain(self, inner: dict) -> dict:
|
||||
for attempt in range(3):
|
||||
try:
|
||||
if not self._ensure_wifi():
|
||||
raise Exception('WiFi not connected')
|
||||
body = ujson.dumps({'data': ujson.dumps(inner)})
|
||||
resp = requests.post(
|
||||
self.base_url,
|
||||
headers={'Content-Type': 'application/json'},
|
||||
data=body,
|
||||
timeout=20
|
||||
)
|
||||
outer = ujson.loads(resp.text)
|
||||
resp.close()
|
||||
if 'data' in outer:
|
||||
try:
|
||||
return ujson.loads(outer['data'])
|
||||
except Exception:
|
||||
return outer
|
||||
return outer
|
||||
except Exception as e:
|
||||
print('plain retry', attempt + 1, ':', e)
|
||||
utime.sleep(3)
|
||||
raise Exception('POST failed after 3 attempts')
|
||||
|
||||
def _post_encrypted(self, inner: dict) -> dict:
|
||||
from identity_iot_aes import aes_cbc_encrypt, aes_cbc_decrypt
|
||||
for attempt in range(3):
|
||||
try:
|
||||
if not self._ensure_wifi():
|
||||
raise Exception('WiFi not connected')
|
||||
inner_json = ujson.dumps(inner)
|
||||
cipher_b64 = aes_cbc_encrypt(inner_json, self._comm_key, self._comm_iv)
|
||||
body = ujson.dumps({'data': cipher_b64})
|
||||
resp = requests.post(
|
||||
self.base_url,
|
||||
headers={'Content-Type': 'application/json'},
|
||||
data=body,
|
||||
timeout=20
|
||||
)
|
||||
print('status:', resp.status_code)
|
||||
print('raw:', resp.text[:100])
|
||||
outer = ujson.loads(resp.text)
|
||||
resp.close()
|
||||
if 'data' not in outer:
|
||||
raise Exception('Missing outer.data')
|
||||
clear = aes_cbc_decrypt(outer['data'], self._comm_key, self._comm_iv)
|
||||
return ujson.loads(clear)
|
||||
except Exception as e:
|
||||
print('enc retry', attempt + 1, ':', e)
|
||||
utime.sleep(3)
|
||||
raise Exception('POST failed after 3 attempts')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Utility
|
||||
# =============================================================================
|
||||
|
||||
def _normalize_b64(s: str) -> str:
|
||||
s = s.strip().replace('\n', '').replace('\r', '').replace(' ', '')
|
||||
s = s.replace('-', '+').replace('_', '/')
|
||||
pad = len(s) % 4
|
||||
if pad:
|
||||
s += '=' * (4 - pad)
|
||||
return s
|
||||
|
||||
|
||||
class StateError(Exception):
|
||||
pass
|
||||
63
identity_iot_aes.py
Normal file
63
identity_iot_aes.py
Normal file
@@ -0,0 +1,63 @@
|
||||
# ============================================================+
|
||||
# Identity IoT — AES-CBC ITEMS Envelope
|
||||
# (c) Copyright : ae Aeonian Engineering Limited - Hong Kong
|
||||
# (c) Copyright : WIDE di D. Papa - Naples - Italy
|
||||
# ============================================================+
|
||||
# AES-CBC encryption/decryption compatible with PHP ITEMSEncrypter.
|
||||
# Uses ucryptolib (built-in on Pico W / ESP32).
|
||||
# ============================================================+
|
||||
|
||||
import ucryptolib
|
||||
import ubinascii
|
||||
|
||||
def _pkcs7_pad(data: bytes, block_size: int = 16) -> bytes:
|
||||
pad_len = block_size - (len(data) % block_size)
|
||||
return data + bytes([pad_len] * pad_len)
|
||||
|
||||
|
||||
def _pkcs7_unpad(data: bytes) -> bytes:
|
||||
pad_len = data[-1]
|
||||
return data[:-pad_len]
|
||||
|
||||
|
||||
def aes_cbc_encrypt(plaintext: str, key: bytes, iv: bytes) -> str:
|
||||
"""
|
||||
Encrypt plaintext string with AES-CBC.
|
||||
Returns base64-encoded ciphertext.
|
||||
Compatible with PHP ITEMSEncrypter::encryptAES().
|
||||
"""
|
||||
if isinstance(key, str):
|
||||
key = key.encode()
|
||||
if isinstance(iv, str):
|
||||
iv = iv.encode()
|
||||
if isinstance(plaintext, str):
|
||||
plaintext = plaintext.encode('utf-8')
|
||||
|
||||
padded = _pkcs7_pad(plaintext)
|
||||
cipher = ucryptolib.aes(key, 2, iv) # mode 2 = CBC
|
||||
encrypted = cipher.encrypt(padded)
|
||||
return ubinascii.b2a_base64(encrypted).decode().strip()
|
||||
|
||||
|
||||
def aes_cbc_decrypt(ciphertext_b64: str, key: bytes, iv: bytes) -> str:
|
||||
"""
|
||||
Decrypt base64-encoded AES-CBC ciphertext.
|
||||
Returns plaintext string.
|
||||
Compatible with PHP ITEMSEncrypter::decryptAES().
|
||||
"""
|
||||
if isinstance(key, str):
|
||||
key = key.encode()
|
||||
if isinstance(iv, str):
|
||||
iv = iv.encode()
|
||||
|
||||
# Normalize base64
|
||||
s = ciphertext_b64.strip().replace('\n', '').replace('\r', '')
|
||||
s = s.replace('-', '+').replace('_', '/')
|
||||
pad = len(s) % 4
|
||||
if pad:
|
||||
s += '=' * (4 - pad)
|
||||
|
||||
encrypted = ubinascii.a2b_base64(s)
|
||||
cipher = ucryptolib.aes(key, 2, iv) # mode 2 = CBC
|
||||
decrypted = cipher.decrypt(encrypted)
|
||||
return _pkcs7_unpad(decrypted).decode('utf-8')
|
||||
126
identity_iot_ed25519.py
Normal file
126
identity_iot_ed25519.py
Normal file
@@ -0,0 +1,126 @@
|
||||
# ============================================================+
|
||||
# Identity IoT — Ed25519 for MicroPython
|
||||
# (c) Copyright : ae Aeonian Engineering Limited - Hong Kong
|
||||
# (c) Copyright : WIDE di D. Papa - Naples - Italy
|
||||
# ============================================================+
|
||||
# Based on Bernstein et al. reference implementation (public domain)
|
||||
# Iterative scalarmult — no recursion, safe for MicroPython stack
|
||||
# ============================================================+
|
||||
|
||||
from sha512 import sha512
|
||||
|
||||
_q = 2**255 - 19
|
||||
_l = 2**252 + 27742317777372353535851937790883648493
|
||||
_d = -121665 * pow(121666, _q - 2, _q) % _q
|
||||
_I = pow(2, (_q - 1) // 4, _q)
|
||||
|
||||
|
||||
def _xrecover(y):
|
||||
x2 = (y * y - 1) * pow(_d * y * y + 1, _q - 2, _q)
|
||||
x = pow(x2, (_q + 3) // 8, _q)
|
||||
if (x * x - x2) % _q != 0:
|
||||
x = x * _I % _q
|
||||
if x % 2 != 0:
|
||||
x = _q - x
|
||||
return x
|
||||
|
||||
|
||||
_By = 4 * pow(5, _q - 2, _q) % _q
|
||||
_Bx = _xrecover(_By)
|
||||
_B = [_Bx % _q, _By % _q, 1, _Bx * _By % _q]
|
||||
|
||||
|
||||
def _edwards_add(P, Q):
|
||||
x1, y1, z1, t1 = P
|
||||
x2, y2, z2, t2 = Q
|
||||
A = (y1 - x1) * (y2 - x2) % _q
|
||||
B = (y1 + x1) * (y2 + x2) % _q
|
||||
C = t1 * 2 * _d * t2 % _q
|
||||
D = z1 * 2 * z2 % _q
|
||||
E = B - A; F = D - C; G = D + C; H = B + A
|
||||
return [E * F % _q, G * H % _q, F * G % _q, E * H % _q]
|
||||
|
||||
|
||||
def _scalarmult(P, e):
|
||||
# Iterative double-and-add — no recursion
|
||||
Q = [0, 1, 1, 0] # neutral element
|
||||
while e > 0:
|
||||
if e & 1:
|
||||
Q = _edwards_add(Q, P)
|
||||
P = _edwards_add(P, P)
|
||||
e >>= 1
|
||||
return Q
|
||||
|
||||
|
||||
def _encodeint(y):
|
||||
bits = [(y >> i) & 1 for i in range(256)]
|
||||
return bytes([sum([bits[i * 8 + j] << j for j in range(8)]) for i in range(32)])
|
||||
|
||||
|
||||
def _encodepoint(P):
|
||||
x, y, z, _ = P
|
||||
zi = pow(z, _q - 2, _q)
|
||||
x = x * zi % _q
|
||||
y = y * zi % _q
|
||||
bits = [(y >> i) & 1 for i in range(255)] + [x & 1]
|
||||
return bytes([sum([bits[i * 8 + j] << j for j in range(8)]) for i in range(32)])
|
||||
|
||||
|
||||
def _bit(h, i):
|
||||
return (h[i // 8] >> (i % 8)) & 1
|
||||
|
||||
|
||||
def _hint(m):
|
||||
return int.from_bytes(sha512(m), 'little')
|
||||
|
||||
|
||||
def _decodepoint(s):
|
||||
y = sum(_bit(s, i) * 2 ** i for i in range(255))
|
||||
x = _xrecover(y)
|
||||
if x & 1 != _bit(s, 255):
|
||||
x = _q - x
|
||||
return [x, y, 1, x * y % _q]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Public API
|
||||
# ============================================================
|
||||
|
||||
def ed25519_keypair_from_seed(seed: bytes):
|
||||
if len(seed) != 32:
|
||||
raise ValueError('Seed must be 32 bytes')
|
||||
h = sha512(seed)
|
||||
a = 2 ** 254 + sum(2 ** i * _bit(h, i) for i in range(3, 254))
|
||||
A = _scalarmult(_B, a)
|
||||
pk = _encodepoint(A)
|
||||
return pk, seed + pk
|
||||
|
||||
|
||||
def ed25519_sign(seed: bytes, message: bytes) -> bytes:
|
||||
if len(seed) != 32:
|
||||
raise ValueError('Seed must be 32 bytes')
|
||||
h = sha512(seed)
|
||||
a = 2 ** 254 + sum(2 ** i * _bit(h, i) for i in range(3, 254))
|
||||
pk = ed25519_keypair_from_seed(seed)[0]
|
||||
r = _hint(h[32:64] + message)
|
||||
R = _scalarmult(_B, r)
|
||||
S = (r + _hint(_encodepoint(R) + pk + message) * a) % _l
|
||||
return _encodepoint(R) + _encodeint(S)
|
||||
|
||||
|
||||
def ed25519_verify(pk: bytes, message: bytes, signature: bytes) -> bool:
|
||||
if len(signature) != 64 or len(pk) != 32:
|
||||
return False
|
||||
try:
|
||||
R_enc = signature[:32]
|
||||
S = int.from_bytes(signature[32:], 'little')
|
||||
if S >= _l:
|
||||
return False
|
||||
A = _decodepoint(pk)
|
||||
R = _decodepoint(R_enc)
|
||||
h = _hint(R_enc + pk + message)
|
||||
lhs = _scalarmult(_B, 8 * S)
|
||||
rhs = _edwards_add(_scalarmult(R, 8), _scalarmult(A, 8 * h))
|
||||
return _encodepoint(lhs) == _encodepoint(rhs)
|
||||
except Exception:
|
||||
return False
|
||||
25
install.py
Normal file
25
install.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# ============================================================+
|
||||
# Identity IoT — install.py
|
||||
# Run once on a fresh Pico W to install all dependencies.
|
||||
# Edit SSID and PASS before running.
|
||||
# ============================================================+
|
||||
|
||||
SSID = 'YOUR_SSID'
|
||||
PASS = 'YOUR_PASSWORD'
|
||||
|
||||
# ---- WiFi ----
|
||||
import network, utime
|
||||
wlan = network.WLAN(network.STA_IF)
|
||||
wlan.active(True)
|
||||
wlan.connect(SSID, PASS)
|
||||
print('Connecting to WiFi...')
|
||||
while not wlan.isconnected():
|
||||
utime.sleep(1)
|
||||
print('Connected:', wlan.ifconfig()[0])
|
||||
|
||||
# ---- Install ----
|
||||
import mip
|
||||
print('Installing identity-micropython...')
|
||||
mip.install("https://git.aeonianengineering.net/wide/identity-micropython/raw/branch/main/package.json")
|
||||
print('Done. Remove SSID/PASS from install.py before committing.')
|
||||
|
||||
BIN
lib/ssd1306.mpy
Normal file
BIN
lib/ssd1306.mpy
Normal file
Binary file not shown.
8
main.py
Normal file
8
main.py
Normal file
@@ -0,0 +1,8 @@
|
||||
# ============================================================+
|
||||
# Identity IoT — main.py
|
||||
# (c) Copyright : ae Aeonian Engineering Limited - Hong Kong
|
||||
# (c) Copyright : WIDE di D. Papa - Naples - Italy
|
||||
# ============================================================+
|
||||
|
||||
import boot_manager
|
||||
boot_manager.run()
|
||||
20
node_relay.py
Normal file
20
node_relay.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# ============================================================+
|
||||
# Identity IoT — Node: Relay
|
||||
# (c) Copyright : ae Aeonian Engineering Limited - Hong Kong
|
||||
# (c) Copyright : WIDE di D. Papa - Naples - Italy
|
||||
# ============================================================+
|
||||
|
||||
import utime
|
||||
|
||||
|
||||
def run_relay(cfg, caps):
|
||||
"""Main loop for relay node type. Called from boot_manager."""
|
||||
print('[relay] starting — relay:', caps.get('has_relay'))
|
||||
|
||||
# TODO: listen for trigger from backend (polling or badge)
|
||||
# TODO: activate/deactivate relay pin
|
||||
# TODO: schedule support
|
||||
|
||||
while True:
|
||||
print('[relay] waiting for trigger...')
|
||||
utime.sleep(5)
|
||||
20
node_sensor.py
Normal file
20
node_sensor.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# ============================================================+
|
||||
# Identity IoT — Node: Sensor
|
||||
# (c) Copyright : ae Aeonian Engineering Limited - Hong Kong
|
||||
# (c) Copyright : WIDE di D. Papa - Naples - Italy
|
||||
# ============================================================+
|
||||
|
||||
import utime
|
||||
|
||||
|
||||
def run_sensor(cfg, caps):
|
||||
"""Main loop for sensor node type. Called from boot_manager."""
|
||||
print('[sensor] starting — sensor:', caps.get('has_sensor'))
|
||||
|
||||
# TODO: read DHT22 / other sensor
|
||||
# TODO: POST readings to backend via AES channel
|
||||
# TODO: polling interval from cfg
|
||||
|
||||
while True:
|
||||
print('[sensor] polling...')
|
||||
utime.sleep(10)
|
||||
19
package.json
Normal file
19
package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"urls": [
|
||||
["identity_iot.py", "https://git.aeonianengineering.net/wide/identity-micropython/raw/branch/main/identity_iot.py"],
|
||||
["identity_iot_ed25519.py", "https://git.aeonianengineering.net/wide/identity-micropython/raw/branch/main/identity_iot_ed25519.py"],
|
||||
["identity_iot_aes.py", "https://git.aeonianengineering.net/wide/identity-micropython/raw/branch/main/identity_iot_aes.py"],
|
||||
["sha512.py", "https://git.aeonianengineering.net/wide/identity-micropython/raw/branch/main/sha512.py"],
|
||||
["boot_manager.py", "https://git.aeonianengineering.net/wide/identity-micropython/raw/branch/main/boot_manager.py"],
|
||||
["provisioning_server.py", "https://git.aeonianengineering.net/wide/identity-micropython/raw/branch/main/provisioning_server.py"],
|
||||
["provisioning_nfc.py", "https://git.aeonianengineering.net/wide/identity-micropython/raw/branch/main/provisioning_nfc.py"],
|
||||
["pn532.py", "https://git.aeonianengineering.net/wide/identity-micropython/raw/branch/main/pn532.py"],
|
||||
["node_sensor.py", "https://git.aeonianengineering.net/wide/identity-micropython/raw/branch/main/node_sensor.py"],
|
||||
["node_relay.py", "https://git.aeonianengineering.net/wide/identity-micropython/raw/branch/main/node_relay.py"],
|
||||
["main.py", "https://git.aeonianengineering.net/wide/identity-micropython/raw/branch/main/main.py"]
|
||||
],
|
||||
"deps": [
|
||||
["ssd1306", "latest"]
|
||||
],
|
||||
"version": "0.1.0"
|
||||
}
|
||||
415
pn532.py
Normal file
415
pn532.py
Normal file
@@ -0,0 +1,415 @@
|
||||
# ============================================================+
|
||||
# Identity IoT — PN532 NFC Driver for MicroPython
|
||||
# (c) Copyright : ae Aeonian Engineering Limited - Hong Kong
|
||||
# (c) Copyright : WIDE di D. Papa - Naples - Italy
|
||||
# ============================================================+
|
||||
|
||||
import utime
|
||||
from machine import Pin, I2C
|
||||
|
||||
PN532_I2C_ADDRESS = 0x24
|
||||
PN532_CMD_GETFIRMWAREVERSION = 0x02
|
||||
PN532_CMD_SAMCONFIGURATION = 0x14
|
||||
PN532_CMD_INLISTPASSIVETARGET= 0x4A
|
||||
PN532_CMD_INDATAEXCHANGE = 0x40
|
||||
PN532_CMD_INRELEASE = 0x52
|
||||
PN532_HOSTTOPN532 = 0xD4
|
||||
PN532_PN532TOHOST = 0xD5
|
||||
|
||||
|
||||
class PN532Error(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PN532_I2C:
|
||||
|
||||
def __init__(self, i2c: I2C, address: int = PN532_I2C_ADDRESS):
|
||||
self._i2c = i2c
|
||||
self._addr = address
|
||||
|
||||
# Wake
|
||||
try:
|
||||
self._i2c.writeto(self._addr, bytes([0x00]))
|
||||
except Exception:
|
||||
pass
|
||||
utime.sleep_ms(500)
|
||||
|
||||
fw = self.get_firmware_version()
|
||||
if not fw:
|
||||
raise PN532Error('PN532 not found or not responding')
|
||||
print('[pn532] firmware: IC=%02x Ver=%d Rev=%d' % (fw['ic'], fw['ver'], fw['rev']))
|
||||
self._sam_config()
|
||||
|
||||
def _build_frame(self, cmd: list) -> bytes:
|
||||
body = [PN532_HOSTTOPN532] + cmd
|
||||
length = len(body)
|
||||
lcs = (~length + 1) & 0xFF
|
||||
dcs = (~(sum(body) & 0xFF) + 1) & 0xFF
|
||||
return bytes([0x00, 0x00, 0xFF, length, lcs] + body + [dcs, 0x00])
|
||||
|
||||
def _send_command(self, cmd: list):
|
||||
frame = self._build_frame(cmd)
|
||||
self._i2c.writeto(self._addr, frame)
|
||||
|
||||
def _wait_ready(self, timeout=1000) -> bool:
|
||||
t = timeout
|
||||
while t > 0:
|
||||
try:
|
||||
b = self._i2c.readfrom(self._addr, 1)
|
||||
if b[0] == 0x01:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
utime.sleep_ms(10)
|
||||
t -= 10
|
||||
return False
|
||||
|
||||
def _read_response(self, length=32, timeout=1000):
|
||||
"""Read after command — PN532 sends ACK then response."""
|
||||
# Read ACK (6 bytes + status)
|
||||
if not self._wait_ready(timeout):
|
||||
return None
|
||||
self._i2c.readfrom(self._addr, 7) # status + ACK frame
|
||||
utime.sleep_ms(10)
|
||||
|
||||
# Read response
|
||||
if not self._wait_ready(timeout):
|
||||
return None
|
||||
buf = self._i2c.readfrom(self._addr, length + 1)
|
||||
|
||||
# Parse: skip status byte, find data after header
|
||||
# Frame: [status][0x00][0x00][0xFF][len][lcs][TFI][cmd+1][data...][dcs][0x00]
|
||||
if len(buf) < 8:
|
||||
return None
|
||||
data_len = buf[4] # length field
|
||||
if data_len < 2:
|
||||
return bytes()
|
||||
# data starts at index 7 (after status+preamble+start+len+lcs+TFI)
|
||||
data = buf[7:7 + data_len - 1]
|
||||
return bytes(data)
|
||||
|
||||
def _sam_config(self):
|
||||
self._send_command([PN532_CMD_SAMCONFIGURATION, 0x01, 0x14, 0x01])
|
||||
self._read_response(timeout=200)
|
||||
|
||||
def get_firmware_version(self) -> dict:
|
||||
try:
|
||||
self._send_command([PN532_CMD_GETFIRMWAREVERSION])
|
||||
# First read: ACK frame
|
||||
if not self._wait_ready(500):
|
||||
return None
|
||||
self._i2c.readfrom(self._addr, 7) # status + ACK
|
||||
utime.sleep_ms(10)
|
||||
# Second read: response frame
|
||||
if not self._wait_ready(500):
|
||||
return None
|
||||
buf = self._i2c.readfrom(self._addr, 14)
|
||||
# buf[0]=status, buf[1..3]=preamble, buf[4]=len, buf[5]=lcs
|
||||
# buf[6]=TFI(0xD5), buf[7]=cmd(0x03), buf[8..11]=IC,Ver,Rev,Support
|
||||
if len(buf) < 12:
|
||||
return None
|
||||
return {
|
||||
'ic': buf[8],
|
||||
'ver': buf[9],
|
||||
'rev': buf[10],
|
||||
'support': buf[11],
|
||||
}
|
||||
except Exception as e:
|
||||
print('[pn532] fw error:', e)
|
||||
return None
|
||||
|
||||
def read_passive_target(self, timeout=1000) -> bytes:
|
||||
"""Wait for ISO14443A card. Returns UID bytes or None."""
|
||||
self._send_command([PN532_CMD_INLISTPASSIVETARGET, 0x01, 0x00])
|
||||
resp = self._read_response(length=32, timeout=timeout)
|
||||
if not resp:
|
||||
return None
|
||||
# resp[0]=cmd_response(0x4B), resp[1]=num_targets, resp[2]=tg
|
||||
# resp[3][4]=ATQA, resp[5]=SAK, resp[6]=uid_len, resp[7+]=UID
|
||||
if len(resp) < 8:
|
||||
return None
|
||||
if resp[1] != 0x01:
|
||||
return None
|
||||
uid_len = resp[6]
|
||||
return bytes(resp[7:7 + uid_len])
|
||||
|
||||
def read_uid(self, timeout=500) -> bytes:
|
||||
return self.read_passive_target(timeout=timeout)
|
||||
|
||||
def read_page(self, page: int) -> bytes:
|
||||
self._send_command([PN532_CMD_INDATAEXCHANGE, 0x01, 0x30, page & 0xFF])
|
||||
resp = self._read_response(length=16, timeout=500)
|
||||
if not resp or len(resp) < 4:
|
||||
return None
|
||||
return bytes(resp[:4])
|
||||
|
||||
def write_page(self, page: int, data: bytes) -> bool:
|
||||
if len(data) != 4:
|
||||
raise PN532Error('write_page: data must be 4 bytes')
|
||||
self._send_command([PN532_CMD_INDATAEXCHANGE, 0x01, 0xA2, page & 0xFF] + list(data))
|
||||
resp = self._read_response(timeout=500)
|
||||
return resp is not None
|
||||
|
||||
def release_target(self) -> bool:
|
||||
try:
|
||||
self._send_command([PN532_CMD_INRELEASE, 0x01])
|
||||
self._read_response(timeout=200)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def read_ndef(self, timeout=2000) -> str:
|
||||
"""
|
||||
Read NDEF TextRecord from NTAG.
|
||||
Returns text content string or None.
|
||||
NDEF TLV starts at page 4 (byte offset 16).
|
||||
"""
|
||||
# Read pages 4..19 (64 bytes) — enough for our payload
|
||||
data = bytearray()
|
||||
for page in range(4, 45):
|
||||
chunk = self.read_page(page)
|
||||
if chunk is None:
|
||||
break
|
||||
data += chunk
|
||||
|
||||
if not data:
|
||||
return None
|
||||
|
||||
# Parse TLV — find NDEF TLV (type 0x03)
|
||||
i = 0
|
||||
while i < len(data):
|
||||
t = data[i]; i += 1
|
||||
if t == 0xFE: # Terminator TLV
|
||||
break
|
||||
if t == 0x00: # Null TLV
|
||||
continue
|
||||
# Length
|
||||
if i >= len(data):
|
||||
break
|
||||
l = data[i]; i += 1
|
||||
if l == 0xFF: # 3-byte length
|
||||
if i + 2 >= len(data):
|
||||
break
|
||||
l = (data[i] << 8) | data[i+1]; i += 2
|
||||
if t != 0x03: # Skip non-NDEF TLVs
|
||||
i += l
|
||||
continue
|
||||
# NDEF message
|
||||
ndef_msg = data[i:i+l]
|
||||
break
|
||||
else:
|
||||
return None
|
||||
|
||||
if not ndef_msg:
|
||||
return None
|
||||
|
||||
# Parse NDEF record
|
||||
# Byte 0: flags (MB|ME|CF|SR|IL|TNF)
|
||||
# Byte 1: type length
|
||||
# Byte 2: payload length (SR=1) or 4 bytes (SR=0)
|
||||
# Byte 3+: type, id?, payload
|
||||
if len(ndef_msg) < 4:
|
||||
return None
|
||||
|
||||
flags = ndef_msg[0]
|
||||
type_len = ndef_msg[1]
|
||||
sr = (flags >> 4) & 1 # Short Record
|
||||
il = (flags >> 3) & 1 # ID Length present
|
||||
|
||||
idx = 2
|
||||
if sr:
|
||||
payload_len = ndef_msg[idx]; idx += 1
|
||||
else:
|
||||
if idx + 4 > len(ndef_msg): return None
|
||||
payload_len = (ndef_msg[idx]<<24 | ndef_msg[idx+1]<<16 |
|
||||
ndef_msg[idx+2]<<8 | ndef_msg[idx+3]); idx += 4
|
||||
|
||||
if il:
|
||||
id_len = ndef_msg[idx]; idx += 1
|
||||
idx += id_len
|
||||
|
||||
# Skip type bytes
|
||||
idx += type_len
|
||||
|
||||
if idx + payload_len > len(ndef_msg):
|
||||
return None
|
||||
|
||||
payload = ndef_msg[idx:idx+payload_len]
|
||||
|
||||
# TextRecord: first byte = status (lang length), then lang, then text
|
||||
if len(payload) < 1:
|
||||
return None
|
||||
lang_len = payload[0] & 0x3F
|
||||
text_start = 1 + lang_len
|
||||
if text_start > len(payload):
|
||||
return None
|
||||
|
||||
try:
|
||||
return bytes(payload[text_start:]).decode('utf-8')
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# =========================================================================
|
||||
# ISO 7816 / NTAG424 DNA NDEF read
|
||||
# =========================================================================
|
||||
|
||||
def _apdu(self, apdu: list, timeout=500, resp_len=64):
|
||||
"""Send ISO 7816 APDU via InDataExchange and return response bytes."""
|
||||
self._send_command([PN532_CMD_INDATAEXCHANGE, 0x01] + apdu)
|
||||
# Step 1: wait ready, read ACK
|
||||
if not self._wait_ready(timeout):
|
||||
return None
|
||||
self._i2c.readfrom(self._addr, 7) # ACK frame
|
||||
utime.sleep_ms(20)
|
||||
# Step 2: wait ready, read response
|
||||
if not self._wait_ready(timeout):
|
||||
return None
|
||||
buf = self._i2c.readfrom(self._addr, resp_len + 8)
|
||||
# buf: [status][preamble x3][len][lcs][TFI=0xD5][cmd=0x41][err][data...][SW1][SW2][dcs][0x00]
|
||||
# data after err byte: index 9, length = buf[4]-3 (TFI+cmd+err)
|
||||
if len(buf) < 10:
|
||||
return None
|
||||
data_len = buf[4]
|
||||
if data_len < 3:
|
||||
return None
|
||||
err = buf[8]
|
||||
if err != 0x00:
|
||||
print('[pn532] apdu err: %02x' % err)
|
||||
return None
|
||||
# data = everything from index 9 to 9+(data_len-3), then SW1 SW2
|
||||
data = bytes(buf[9:9 + data_len - 2])
|
||||
return data
|
||||
|
||||
def _sw(self, resp) -> tuple:
|
||||
"""Extract SW1 SW2 — last 2 bytes of clean APDU response."""
|
||||
if not resp or len(resp) < 2:
|
||||
return (0x00, 0x00)
|
||||
return (resp[-2], resp[-1])
|
||||
|
||||
def _sw2(self, resp) -> tuple:
|
||||
"""Extract SW1 SW2 — first 2 bytes when response is SW only."""
|
||||
if not resp or len(resp) < 2:
|
||||
return (0x00, 0x00)
|
||||
return (resp[0], resp[1])
|
||||
|
||||
def read_ndef_iso(self, timeout=2000) -> str:
|
||||
"""
|
||||
Read NDEF from NTAG424 DNA via ISO 7816 command set.
|
||||
No authentication required for default NDEF file.
|
||||
Returns text content or None.
|
||||
Must be called while tag is in field — polls for tag first.
|
||||
"""
|
||||
# 0. Poll for tag — activate it for InDataExchange
|
||||
uid = self.read_passive_target(timeout=timeout)
|
||||
if uid is None:
|
||||
return None, None, None
|
||||
utime.sleep_ms(100)
|
||||
|
||||
# 1. Select NDEF Application (D2 76 00 00 85 01 01)
|
||||
sel_app = [0x00, 0xA4, 0x04, 0x00, 0x07,
|
||||
0xD2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00]
|
||||
resp = self._apdu(sel_app)
|
||||
if not resp:
|
||||
return None, None
|
||||
sw1, sw2 = resp[0], resp[1]
|
||||
if sw1 != 0x90:
|
||||
print('[pn532] select app failed: %02x %02x' % (sw1, sw2))
|
||||
return None, None
|
||||
|
||||
# 2. Select NDEF file (E1 04)
|
||||
sel_file = [0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x04]
|
||||
resp = self._apdu(sel_file)
|
||||
if not resp:
|
||||
return None, None
|
||||
sw1, sw2 = resp[0], resp[1]
|
||||
if sw1 != 0x90:
|
||||
print('[pn532] select file failed: %02x %02x' % (sw1, sw2))
|
||||
return None, None
|
||||
|
||||
# 3. Read first 2 bytes to get NDEF length
|
||||
read_len = [0x00, 0xB0, 0x00, 0x00, 0x02]
|
||||
resp = self._apdu(read_len)
|
||||
if not resp or len(resp) < 4:
|
||||
return None, None
|
||||
# resp = [len_hi][len_lo][SW1][SW2]
|
||||
if len(resp) < 4:
|
||||
return None, None
|
||||
sw1, sw2 = resp[2], resp[3]
|
||||
if sw1 != 0x90:
|
||||
print('[pn532] read len failed: %02x %02x' % (sw1, sw2))
|
||||
return None, None
|
||||
ndef_len = (resp[0] << 8) | resp[1]
|
||||
if ndef_len == 0:
|
||||
return None, None
|
||||
|
||||
# 4. Read NDEF data (max 200 bytes per read)
|
||||
ndef_data = bytearray()
|
||||
offset = 2
|
||||
remaining = ndef_len
|
||||
|
||||
while remaining > 0:
|
||||
chunk = min(remaining, 50) # PN532 I2C frame limit
|
||||
read_data = [0x00, 0xB0,
|
||||
(offset >> 8) & 0xFF,
|
||||
offset & 0xFF,
|
||||
chunk & 0xFF]
|
||||
resp = self._apdu(read_data, timeout=timeout, resp_len=chunk+3)
|
||||
if not resp:
|
||||
break
|
||||
if len(resp) < 2:
|
||||
break
|
||||
sw1, sw2 = resp[-2], resp[-1]
|
||||
if sw1 != 0x90:
|
||||
print('[pn532] read data failed: %02x %02x' % (sw1, sw2))
|
||||
break
|
||||
ndef_data += resp[:-2] # exclude SW1 SW2
|
||||
offset += chunk
|
||||
remaining -= chunk
|
||||
|
||||
if not ndef_data:
|
||||
return None, None
|
||||
|
||||
# 5. Parse NDEF message
|
||||
# ndef_data = clean NDEF bytes, no response code prefix
|
||||
if len(ndef_data) < 3:
|
||||
return None, None
|
||||
|
||||
flags = ndef_data[0]
|
||||
type_len = ndef_data[1]
|
||||
sr = (flags >> 4) & 1
|
||||
il = (flags >> 3) & 1
|
||||
|
||||
idx = 2
|
||||
if sr:
|
||||
if idx >= len(ndef_data): return None
|
||||
payload_len = ndef_data[idx]; idx += 1
|
||||
else:
|
||||
if idx + 4 > len(ndef_data): return None
|
||||
payload_len = (ndef_data[idx]<<24 | ndef_data[idx+1]<<16 |
|
||||
ndef_data[idx+2]<<8 | ndef_data[idx+3]); idx += 4
|
||||
|
||||
if il:
|
||||
id_len = ndef_data[idx]; idx += 1
|
||||
idx += id_len
|
||||
|
||||
idx += type_len # skip type bytes
|
||||
|
||||
if idx + payload_len > len(ndef_data):
|
||||
return None, None
|
||||
|
||||
payload = ndef_data[idx:idx+payload_len]
|
||||
|
||||
# TextRecord: byte 0 = status (lang_len), then lang, then text
|
||||
if len(payload) < 1:
|
||||
return None, None
|
||||
lang_len = payload[0] & 0x3F
|
||||
text_start = 1 + lang_len
|
||||
if text_start > len(payload):
|
||||
return None, None
|
||||
|
||||
try:
|
||||
import ubinascii as _ub
|
||||
uid_hex = _ub.hexlify(uid).decode()
|
||||
return uid_hex, bytes(payload[text_start:]).decode('utf-8')
|
||||
except Exception:
|
||||
return None, None
|
||||
32
provisioning_nfc.py
Normal file
32
provisioning_nfc.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# ============================================================+
|
||||
# Identity IoT — Provisioning via NFC
|
||||
# (c) Copyright : ae Aeonian Engineering Limited - Hong Kong
|
||||
# (c) Copyright : WIDE di D. Papa - Naples - Italy
|
||||
# ============================================================+
|
||||
# Used when has_nfc = True and no config.json found.
|
||||
# Waits for writer badge tap, reads AES-encrypted config payload
|
||||
# from NTAG424, decrypts with PROG_KEY/PROG_IV.
|
||||
# ============================================================+
|
||||
|
||||
import utime
|
||||
from provisioning_server import PROG_KEY, PROG_IV
|
||||
from identity_iot_aes import aes_cbc_decrypt
|
||||
import ujson
|
||||
|
||||
|
||||
def run_provisioning_nfc():
|
||||
"""
|
||||
Block until writer badge is tapped.
|
||||
Reads encrypted config from NTAG424, returns config dict.
|
||||
"""
|
||||
print('[provisioning_nfc] waiting for writer badge...')
|
||||
|
||||
# TODO: init PN532
|
||||
# TODO: wait for NTAG424 tap
|
||||
# TODO: read encrypted config payload from tag memory
|
||||
# TODO: aes_cbc_decrypt(payload, PROG_KEY, PROG_IV)
|
||||
# TODO: validate and return config dict
|
||||
|
||||
while True:
|
||||
print('[provisioning_nfc] tap writer badge...')
|
||||
utime.sleep(2)
|
||||
212
provisioning_server.py
Normal file
212
provisioning_server.py
Normal file
@@ -0,0 +1,212 @@
|
||||
# ============================================================+
|
||||
# Identity IoT — Provisioning Server
|
||||
# (c) Copyright : ae Aeonian Engineering Limited - Hong Kong
|
||||
# (c) Copyright : WIDE di D. Papa - Naples - Italy
|
||||
# ============================================================+
|
||||
# Runs when no config.json found on flash.
|
||||
# 1. Connects WiFi in AP mode (fallback) or waits on existing LAN
|
||||
# 2. Broadcasts UDP beacon: "identity-iot|{sn}"
|
||||
# 3. Exposes HTTP on port 8080:
|
||||
# GET / → node info (plaintext)
|
||||
# POST /provision → AES-encrypted config payload → saves config.json
|
||||
# ============================================================+
|
||||
|
||||
import network
|
||||
import socket
|
||||
import ujson
|
||||
import ubinascii
|
||||
import machine
|
||||
import utime
|
||||
|
||||
from identity_iot_aes import aes_cbc_encrypt, aes_cbc_decrypt
|
||||
|
||||
# Hardcoded provisioning keys — same in Flutter app
|
||||
# Change only with coordinated firmware + app update
|
||||
PROG_KEY = b'IdentityIoTProv!Key_WIDE_AEL_32b' # 32 bytes
|
||||
PROG_IV = b'IdentityIoTIV16b' # 16 bytes
|
||||
|
||||
UDP_PORT = 5000
|
||||
HTTP_PORT = 8080
|
||||
BEACON_INTERVAL_MS = 2000
|
||||
|
||||
|
||||
def _get_sn():
|
||||
return ubinascii.hexlify(machine.unique_id()).decode()
|
||||
|
||||
|
||||
def _get_ip():
|
||||
wlan = network.WLAN(network.STA_IF)
|
||||
if wlan.isconnected():
|
||||
return wlan.ifconfig()[0]
|
||||
return '0.0.0.0'
|
||||
|
||||
|
||||
def _start_ap():
|
||||
"""Fallback: start AP so app can connect even without existing WiFi."""
|
||||
sn = _get_sn()
|
||||
ssid = 'IdentityIoT-' + sn[:8]
|
||||
ap = network.WLAN(network.AP_IF)
|
||||
ap.active(True)
|
||||
ap.config(essid=ssid, password='provision', security=4) # WPA2
|
||||
utime.sleep(2)
|
||||
print('[provisioning] AP started:', ssid, '— ip:', ap.ifconfig()[0])
|
||||
return ap.ifconfig()[0]
|
||||
|
||||
|
||||
def _beacon_loop(udp_sock, beacon_bytes, stop_flag):
|
||||
"""Send UDP broadcast beacon. Called between HTTP polls."""
|
||||
try:
|
||||
udp_sock.sendto(beacon_bytes, ('255.255.255.255', UDP_PORT))
|
||||
except Exception as e:
|
||||
print('[beacon] error:', e)
|
||||
|
||||
|
||||
def _parse_http_request(conn):
|
||||
"""Read HTTP request from socket. Returns (method, path, body)."""
|
||||
try:
|
||||
data = b''
|
||||
conn.settimeout(5)
|
||||
while True:
|
||||
chunk = conn.recv(1024)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
if b'\r\n\r\n' in data:
|
||||
# Check if we have full body
|
||||
header_end = data.index(b'\r\n\r\n') + 4
|
||||
headers_raw = data[:header_end].decode('utf-8', 'ignore')
|
||||
body = data[header_end:]
|
||||
content_length = 0
|
||||
for line in headers_raw.split('\r\n'):
|
||||
if line.lower().startswith('content-length:'):
|
||||
content_length = int(line.split(':')[1].strip())
|
||||
while len(body) < content_length:
|
||||
chunk = conn.recv(1024)
|
||||
if not chunk:
|
||||
break
|
||||
body += chunk
|
||||
first_line = headers_raw.split('\r\n')[0]
|
||||
parts = first_line.split(' ')
|
||||
method = parts[0] if len(parts) > 0 else 'GET'
|
||||
path = parts[1] if len(parts) > 1 else '/'
|
||||
return method, path, body.decode('utf-8', 'ignore')
|
||||
except Exception as e:
|
||||
print('[http] parse error:', e)
|
||||
return 'GET', '/', ''
|
||||
|
||||
|
||||
def _http_response(conn, status, body):
|
||||
resp = (
|
||||
'HTTP/1.1 ' + status + '\r\n'
|
||||
'Content-Type: application/json\r\n'
|
||||
'Content-Length: ' + str(len(body)) + '\r\n'
|
||||
'Connection: close\r\n'
|
||||
'\r\n' + body
|
||||
)
|
||||
conn.sendall(resp.encode())
|
||||
|
||||
|
||||
def run_provisioning():
|
||||
"""
|
||||
Block until a valid config is received from the Flutter app.
|
||||
Returns config dict ready to save.
|
||||
"""
|
||||
sn = _get_sn()
|
||||
print('[provisioning] SN:', sn)
|
||||
|
||||
# Try STA first, fall back to AP
|
||||
wlan_sta = network.WLAN(network.STA_IF)
|
||||
if not wlan_sta.isconnected():
|
||||
ip = _start_ap()
|
||||
else:
|
||||
ip = wlan_sta.ifconfig()[0]
|
||||
print('[provisioning] using existing WiFi, ip:', ip)
|
||||
|
||||
beacon_msg = ujson.dumps({
|
||||
'type': 'identity-iot',
|
||||
'sn': sn,
|
||||
'ip': ip,
|
||||
'port': HTTP_PORT,
|
||||
}).encode()
|
||||
|
||||
# UDP socket for beacon
|
||||
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
udp.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
udp.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
except Exception:
|
||||
pass
|
||||
udp.setblocking(False)
|
||||
|
||||
# HTTP server socket
|
||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
srv.bind(('0.0.0.0', HTTP_PORT))
|
||||
srv.listen(1)
|
||||
srv.setblocking(False)
|
||||
|
||||
print('[provisioning] HTTP listening on', ip + ':' + str(HTTP_PORT))
|
||||
print('[provisioning] UDP beacon on port', UDP_PORT)
|
||||
|
||||
last_beacon = 0
|
||||
received_cfg = None
|
||||
|
||||
while received_cfg is None:
|
||||
|
||||
# Beacon
|
||||
now = utime.ticks_ms()
|
||||
if utime.ticks_diff(now, last_beacon) >= BEACON_INTERVAL_MS:
|
||||
try:
|
||||
udp.sendto(beacon_msg, ('255.255.255.255', UDP_PORT))
|
||||
except Exception:
|
||||
pass
|
||||
last_beacon = now
|
||||
|
||||
# HTTP accept (non-blocking)
|
||||
try:
|
||||
conn, addr = srv.accept()
|
||||
print('[provisioning] connection from', addr)
|
||||
method, path, body = _parse_http_request(conn)
|
||||
|
||||
if method == 'GET' and path == '/':
|
||||
info = ujson.dumps({'sn': sn, 'ip': ip, 'status': 'awaiting_provision'})
|
||||
_http_response(conn, '200 OK', info)
|
||||
|
||||
elif method == 'POST' and path == '/provision':
|
||||
try:
|
||||
outer = ujson.loads(body)
|
||||
cipher_b64 = outer.get('data', '')
|
||||
if not cipher_b64:
|
||||
raise Exception('missing data')
|
||||
|
||||
clear = aes_cbc_decrypt(cipher_b64, PROG_KEY, PROG_IV)
|
||||
cfg = ujson.loads(clear)
|
||||
|
||||
# Basic validation
|
||||
required = ['wifi_ssid', 'wifi_pass', 'comm_key', 'comm_iv',
|
||||
'base_url', 'tenant_id', 'node_type', 'node_id']
|
||||
for k in required:
|
||||
if k not in cfg:
|
||||
raise Exception('missing field: ' + k)
|
||||
|
||||
_http_response(conn, '200 OK', ujson.dumps({'status': 'ok', 'sn': sn}))
|
||||
conn.close()
|
||||
received_cfg = cfg
|
||||
print('[provisioning] config received OK')
|
||||
|
||||
except Exception as e:
|
||||
print('[provisioning] provision error:', e)
|
||||
_http_response(conn, '400 Bad Request', ujson.dumps({'status': 'error', 'msg': str(e)}))
|
||||
conn.close()
|
||||
|
||||
else:
|
||||
_http_response(conn, '404 Not Found', '{}')
|
||||
conn.close()
|
||||
|
||||
except OSError:
|
||||
# No connection yet — normal in non-blocking mode
|
||||
utime.sleep_ms(100)
|
||||
|
||||
udp.close()
|
||||
srv.close()
|
||||
return received_cfg
|
||||
87
sha512.py
Normal file
87
sha512.py
Normal file
@@ -0,0 +1,87 @@
|
||||
# SHA-512 pure Python for MicroPython
|
||||
# Based on public domain implementation
|
||||
# Compact version optimized for MicroPython memory constraints
|
||||
|
||||
_K = [
|
||||
0x428a2f98d728ae22, 0x7137449123ef65cd, 0xb5c0fbcfec4d3b2f, 0xe9b5dba58189dbbc,
|
||||
0x3956c25bf348b538, 0x59f111f1b605d019, 0x923f82a4af194f9b, 0xab1c5ed5da6d8118,
|
||||
0xd807aa98a3030242, 0x12835b0145706fbe, 0x243185be4ee4b28c, 0x550c7dc3d5ffb4e2,
|
||||
0x72be5d74f27b896f, 0x80deb1fe3b1696b1, 0x9bdc06a725c71235, 0xc19bf174cf692694,
|
||||
0xe49b69c19ef14ad2, 0xefbe4786384f25e3, 0x0fc19dc68b8cd5b5, 0x240ca1cc77ac9c65,
|
||||
0x2de92c6f592b0275, 0x4a7484aa6ea6e483, 0x5cb0a9dcbd41fbd4, 0x76f988da831153b5,
|
||||
0x983e5152ee66dfab, 0xa831c66d2db43210, 0xb00327c898fb213f, 0xbf597fc7beef0ee4,
|
||||
0xc6e00bf33da88fc2, 0xd5a79147930aa725, 0x06ca6351e003826f, 0x142929670a0e6e70,
|
||||
0x27b70a8546d22ffc, 0x2e1b21385c26c926, 0x4d2c6dfc5ac42aed, 0x53380d139d95b3df,
|
||||
0x650a73548baf63de, 0x766a0abb3c77b2a8, 0x81c2c92e47edaee6, 0x92722c851482353b,
|
||||
0xa2bfe8a14cf10364, 0xa81a664bbc423001, 0xc24b8b70d0f89791, 0xc76c51a30654be30,
|
||||
0xd192e819d6ef5218, 0xd69906245565a910, 0xf40e35855771202a, 0x106aa07032bbd1b8,
|
||||
0x19a4c116b8d2d0c8, 0x1e376c085141ab53, 0x2748774cdf8eeb99, 0x34b0bcb5e19b48a8,
|
||||
0x391c0cb3c5c95a63, 0x4ed8aa4ae3418acb, 0x5b9cca4f7763e373, 0x682e6ff3d6b2b8a3,
|
||||
0x748f82ee5defb2fc, 0x78a5636f43172f60, 0x84c87814a1f0ab72, 0x8cc702081a6439ec,
|
||||
0x90befffa23631e28, 0xa4506cebde82bde9, 0xbef9a3f7b2c67915, 0xc67178f2e372532b,
|
||||
0xca273eceea26619c, 0xd186b8c721c0c207, 0xeada7dd6cde0eb1e, 0xf57d4f7fee6ed178,
|
||||
0x06f067aa72176fba, 0x0a637dc5a2c898a6, 0x113f9804bef90dae, 0x1b710b35131c471b,
|
||||
0x28db77f523047d84, 0x32caab7b40c72493, 0x3c9ebe0a15c9bebc, 0x431d67c49c100d4c,
|
||||
0x4cc5d4becb3e42b6, 0x597f299cfc657e2a, 0x5fcb6fab3ad6faec, 0x6c44198c4a475817,
|
||||
]
|
||||
|
||||
_M64 = 0xffffffffffffffff
|
||||
|
||||
def _rotr64(x, n):
|
||||
return ((x >> n) | (x << (64 - n))) & _M64
|
||||
|
||||
def _sha512_compress(state, block):
|
||||
w = list(block)
|
||||
for i in range(16, 80):
|
||||
s0 = _rotr64(w[i-15], 1) ^ _rotr64(w[i-15], 8) ^ (w[i-15] >> 7)
|
||||
s1 = _rotr64(w[i-2], 19) ^ _rotr64(w[i-2], 61) ^ (w[i-2] >> 6)
|
||||
w.append((w[i-16] + s0 + w[i-7] + s1) & _M64)
|
||||
|
||||
a, b, c, d, e, f, g, h = state
|
||||
|
||||
for i in range(80):
|
||||
S1 = _rotr64(e, 14) ^ _rotr64(e, 18) ^ _rotr64(e, 41)
|
||||
ch = (e & f) ^ ((~e & _M64) & g)
|
||||
temp1 = (h + S1 + ch + _K[i] + w[i]) & _M64
|
||||
S0 = _rotr64(a, 28) ^ _rotr64(a, 34) ^ _rotr64(a, 39)
|
||||
maj = (a & b) ^ (a & c) ^ (b & c)
|
||||
temp2 = (S0 + maj) & _M64
|
||||
|
||||
h = g; g = f; f = e
|
||||
e = (d + temp1) & _M64
|
||||
d = c; c = b; b = a
|
||||
a = (temp1 + temp2) & _M64
|
||||
|
||||
return [
|
||||
(state[0] + a) & _M64, (state[1] + b) & _M64,
|
||||
(state[2] + c) & _M64, (state[3] + d) & _M64,
|
||||
(state[4] + e) & _M64, (state[5] + f) & _M64,
|
||||
(state[6] + g) & _M64, (state[7] + h) & _M64,
|
||||
]
|
||||
|
||||
def sha512(data: bytes) -> bytes:
|
||||
# Initial hash values
|
||||
state = [
|
||||
0x6a09e667f3bcc908, 0xbb67ae8584caa73b,
|
||||
0x3c6ef372fe94f82b, 0xa54ff53a5f1d36f1,
|
||||
0x510e527fade682d1, 0x9b05688c2b3e6c1f,
|
||||
0x1f83d9abfb41bd6b, 0x5be0cd19137e2179,
|
||||
]
|
||||
|
||||
msg = bytearray(data)
|
||||
length = len(data) * 8
|
||||
msg.append(0x80)
|
||||
while len(msg) % 128 != 112:
|
||||
msg.append(0x00)
|
||||
# Append length as 128-bit big-endian (high 64 bits are 0)
|
||||
msg += (0).to_bytes(8, 'big')
|
||||
msg += length.to_bytes(8, 'big')
|
||||
|
||||
for i in range(0, len(msg), 128):
|
||||
block = []
|
||||
for j in range(0, 128, 8):
|
||||
block.append(int.from_bytes(msg[i+j:i+j+8], 'big'))
|
||||
state = _sha512_compress(state, block)
|
||||
|
||||
return b''.join(s.to_bytes(8, 'big') for s in state)
|
||||
|
||||
Reference in New Issue
Block a user