Note that if the nonce hint is derived reversibly (EG:16 byte nonce length derived using AES encryption) nonce hints can be checked against all device keys as a fallback option. A 64 bit block cipher like XTEAlightweight 64 bit block cipher would be perfect for this. I suggest SPECK.
Symmetric crypto and databases are cheap. Adding a DB and structuring appropriately turns an O(k)
problem (decrypt one message from one oftry all k
devicesdevice keys) into an O(1)
problem (database lookup). For sufficiently large values of k
this is worthwhile. If the hint is generated with a reversible keyed permutation, search can be very fast for the fallback case.
from Crypto.Cipher import AES
from nacl import bindings
from os import urandom
import struct
uint32_to_bytes=struct.Struct("!I").pack #pack 4 byte integer
bytes_to_uint=lambda s:sum(256**i * b for i,b in enumerate(bytearray(s[::-1])))
import hashlib
sha256=lambda s:hashlib.sha256(s).digest()
import hmac
sha256_hmac=lambda k,s:hmac.new(k,s,"sha256").digest()
def AES_CTR(m,k,nonce):
return AES.new(key=k,mode=AES.MODE_CTR,nonce=nonce).encrypt(m)
def nhint_make(nonce,k):
return AES.new(key=k,mode=AES.MODE_ECB).encrypt(
b"\0"*12+nonce)
class device():
def __init__(self,device_IDdev_id,device_keydev_key,device_key_nhintdev_key_nhint):
self.device_IDdev_id = device_IDdev_id
self.device_keydev_key = device_keydev_key
self.device_key_nhintdev_key_nhint = device_key_nhintdev_key_nhint
self.next_nonce = 0
def encrypt_message(self,m):
nb=uint32_to_bytes(self.next_nonce)
nh=nhint_make(nb, self.device_key_nhintdev_key_nhint)
self.next_nonce+=1
ct=AES_CTR(m, self.device_key[dev_key[:16],b"\0"*4+nb)
tag=sha256_hmac(self.device_key[16dev_key[16:],nh+ct)[:8]
return nh+ct+tag
class backend():
HINT_RUN_LENGTH=10
def __init__(self):
self.master_secret=urandom(32)#used to derive device keys
#FIXME:consider using a real database or explicitly designed data structure
#python dicts and strings are oversized for this
self.hint_db_meta={} #dev_id:(nonce_start,nonce_end)
self.hint_db={} #nhint:dev_ID
#in theory, nhint collisions should be handled by storing both dev_IDs in a list(dev_ID||nonce)
#but collisions are infrequent and this saves a lot of memory
self.hint_keys_all=bytearray() #dense list of keys used for fallback route
self._next_device_ID=0
def dev_key (self,dev_id):return sha256_hmac(self.master_secret,b"device_keyb"dev_key:"+dev_id)
def dev_key_nhint(self,dev_id):return sha256_hmac(self.master_secret,b"nhint_key:"+dev_id)[:16]
def provision_device(self):
dev_id=uint32_to_bytes(self._next_device_ID)
self._next_device_ID+=1
self.update_hints(dev_id, 0)
self.hint_keys_all+=self.dev_key_nhint(dev_id)
return device(dev_id,
self.dev_key(dev_id),
self.dev_key_nhint(dev_id))
def update_hints(self,dev_id,start,end=None):
if end is None:end=start+self.HINT_RUN_LENGTH
assert end>=start
p_set=set(range(*self.hint_db_meta.get(dev_id,(0,0))))
self.hint_db_meta[dev_id]=(start,end)
n_set=set(range(start,end))
#derive nhint key
key_nhint = self.dev_key_nhint(dev_id)
for i in p_set-n_set:
nb=uint32_to_bytes(i)
nh=nhint_make(nb, key_nhint)
try:del self.hint_db[nh].remove(dev_id+nb)
except KeyError:pass
for i in n_set-p_set:
nb=uint32_to_bytes(i)
nh=nhint_make(nb, key_nhint)
try:self.hint_db[nh].append(dev_id+nb)
except KeyError:self.hint_db[nh]=[dev_id+nb]hint_db[nh]=dev_id
def decrypt(self,data):
assert len(data)>=(16+8)#nhint_make,tag
nhint_makenh,ct,tag=data[:16],data[16:-8],data[-8:]
#look up the nonce hint(s)
try:hints=selfhints=[self.hint_db[nhint_make]hint_db[nh]]
except KeyError:
print("warning:failed to look up hint, trying fallback")
hints=self.fallback_nonce_candidates(nhint_makenh)
for hintdev_id in hints:
dev_id,nb=hint[:4],hint[4:]
#check the nonce_hint is well formed
block=AES.new(key=self.dev_key_nhint(dev_id),mode=AES.MODE_ECB).decrypt(nhint_makenh)
if not block==b"\0"*12+nbblock.startswith(b"\0"*12):continue
nb=block[12:]
#check the tag
key=self.dev_key(dev_id)
tag_correct=sha256_hmac(key[16:],nhint_make+ctnh+ct)[:8]
if hmac.compare_digest(tag,tag_correct):
m=AES_CTR(ct, key[:16],b"\0"*4+nb)
nonce=bytes_to_uint(nb)
self.update_hints(dev_id, nonce+1)
return dev_id,nonce,m
raise ValueError("couldn't decrypt the data")
def fallback_nonce_candidates(self,nhint_makenh):
for i in range(0,len(self.hint_keys_all),16):
key=self.hint_keys_all[i:i+16]
block=AES.new(key=key,mode=AES.MODE_ECB).decrypt(nhint_makenh)
if block.startswith(b"\0"*12):
yield dev_id=uint32_to_bytesuint32_to_bytes(i//16)
nb=block[12:]
yield dev_id+nb
if __name__=="__main__":
import time
def timeinc(a=[time.monotonic()]):then,a[0]=a[0],time.monotonic();return "%.6f"%(a[0]-then)
def printres(res):print("result:{devID:%r seq:%i, message:%r}"%res)
base=backend()
n=20000
print("provisioning %i devices ... "%n,end="")
base.HINT_RUN_LENGTH=0#don't populate hint db yet
lots_of_devs=[base.provision_device() for i in range(n)]
print(timeinc()+ "\nfilling hint DB ... ",end="")
import psutil
process = psutil.Process()
mem1=process.memory_info().rss
del base.HINT_RUN_LENGTH#populate afterwards
for d in lots_of_devs:
base.update_hints(d.dev_id, 0)
mem2=process.memory_info().rss
mem_used=mem2-mem1
print(timeinc()+"\nmemory used:%i (%.2f/device,%.2f/hint)"%(mem_used,mem_used/n,mem_used/n/base.HINT_RUN_LENGTH))
print("check round trip for first device")
dev1=lots_of_devs[0]
m=b"hello world!"
encd=dev1.encrypt_message(m)
#check it decrypts correctly
result=base.decrypt(encd)
printres(result)
#result:{devID:b'\x00\x00\x00\x00' seq:0, message:b'hello world!'}
print("1 message processed in "+timeinc())
assert (result[0],result[2])==(dev1.device_IDdev_id,m)
m=b"some telemetry"
dev2=lots_of_devs[n//2]
print("\ncheck round trip for device %i"%(n//2))
for i in range(1000):
encd=dev2.encrypt_message(m)
try:result=base.decrypt(encd)
except Exception:
print(i)
raise
assert result[::2]==(dev2.device_IDdev_id,m)
print("1k messages processed in "+timeinc())
print("\ncheck desynchronisation recovery")
for i in range(100):
encd=dev2.encrypt_message(m)
print("100 messages discarded to desynchronise "+str(timeinc()));
result=base.decrypt(encd)
print("decryption done "+str(timeinc()))
printres(result)
#result:{devID:b"\x00\x00'\x10" seq:1099, message:b'some telemetry'}
performancePerformance
I'm getting 500µs for an encrypt/decrypt round trip but this is using an in memory dict as a database with 20k simulated devices * 10 hints. memoryMemory usage is about 300B120B per hint (*10 hints *20k devices = 60MB24MB) which would. This is ~10x larger than needed.
The optimal data structure for this is a sorted list of nonce_hint || device_ID
strings. These can be very easy to optimise, just switchstored in a radix trie allowing sorted chunks with common prefixes to lose a real databasebyte or customtwo.
A static precomputed data structure can use sparse nonce_hint
values to index into a dense array of device_IDs
requiring only a few bytes per entry at the cost of needing to try a few dozen values per lookup.
A block cipher without complex key setup could be a lot faster if vectorised. Maybe run it on a GPU? That plausibly gets you to 1B block cipher ops per second.
OtherTime Based Nonce Patterns
If the devices have a clock inside them, you could do something time based (EG:32-bit day || 32 bit sequence counter
). Alternatively, to ensure resynchronisation or go fully time based with ~1 minute granularity. This fixes desynchronisation and gives youan authenticated sending time for free. For more accurate timestamps, a few bytes of the message can hold a finer grained time offset allowing messages that use "future" time values to indicate the real sending time.
from Crypto.Cipher import AES
from nacl import bindings
from os import urandom
import struct
uint32_to_bytes=struct.Struct("!I").pack #pack 4 byte integer
import hashlib
sha256=lambda s:hashlib.sha256(s).digest()
import hmac
sha256_hmac=lambda k,s:hmac.new(k,s,"sha256").digest()
def AES_CTR(m,k,nonce=b"\0"*8):
assert len(nonce)==8
return AES.new(key=k,mode=AES.MODE_CTR,nonce=nonce).encrypt(m)
class device():
def __init__(self,backend_pubkey,device_ID,device_key):
self.backend_pubkey = backend_pubkey
self.device_ID = device_ID
self.device_key = device_key
def encrypt_message(self,m):
pk_eph,sk_eph=bindings.crypto_kx_keypair()
k=bindings.crypto_kx_client_session_keys(pk_eph, sk_eph, self.backend_pubkey)[0]
ct=AES_CTR(self.device_ID+m, k[:16])
tag=sha256_hmac(self.device_key,k+ct)[:8]
return pk_eph+ct+tag
class backend():
def __init__(self):
self.pk_srv,self.sk_srv=bindings.crypto_kx_keypair()
self.master_secret=urandom(32)#used to derive device keys
self._next_device_ID=0
def dev_mac_key(self,dev_id):return sha256_hmac(self.master_secret,b"device_mac_key:"+dev_id)
def provision_device(self):
dev_id=uint32_to_bytes(self._next_device_ID)
self._next_device_ID+=1
return device(self.pk_srv,dev_id,self.dev_mac_key(dev_id))
def decrypt(self,data):
assert len(data)>=(32+4+8)#pk_eph,device_id,tag
pk_eph,ct,tag=data[:32],data[32:-8],data[-8:]
k=bindings.crypto_kx_server_session_keys(self.pk_srv, self.sk_srv, pk_eph)[1]
pt=AES_CTR(ct, k[:16])
dev_id,m=pt[:4],pt[4:]
device_mac_key=self.dev_mac_key(dev_id)
correct_tag=sha256_hmac(device_mac_key,k+ct)[:8]
if not hmac.compare_digest(correct_tag,tag):
raise ValueError("bad decryption:bad MAC")
return dev_id,m
if __name__=="__main__":
base=backend()
dev1=base.provision_device()
dev2=base.provision_device()
m=b"hello world!"
encd=dev1.encrypt_message(m)
#check it decrypts correctly
result=base.decrypt(encd)
assert result==(dev1.device_ID,m)
print("result:{devID:%r, message:%r}"%result)
# (b'\x00\x00\x00\x00', b'hello world!')
m=b"some telemetry"
encd=dev2.encrypt_message(m)
result=base.decrypt(encd)
assert result==(dev2.device_ID,m)
print("result:{devID:%r, message:%r}"%result)
# (b'\x00\x00\x00\x01', b'some telemetry')
```
Overhead is
- 32 B (ECDH point)
- 4 B (device ID in message)
- 8 B (MAC)