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