class Nuki_EncryptedCommand(object): def __init__(self, authID='', nukiCommand=None, nonce='', publicKey='', privateKey=''): self.byteSwapper = ByteSwapper() self.crcCalculator = CrcCalculator() self.authID = authID self.command = nukiCommand self.nonce = nonce if nonce == '': self.nonce = nacl.utils.random(24).hex() self.publicKey = publicKey self.privateKey = privateKey def generate(self, format='BYTE_ARRAY'): unencrypted = self.authID + self.command.generate(format='HEX')[:-4] crc = self.byteSwapper.swap(self.crcCalculator.crc_ccitt(unencrypted)) unencrypted = unencrypted + crc sharedKey = crypto_box_beforenm(bytes(bytearray.fromhex(self.publicKey)),bytes(bytearray.fromhex(self.privateKey))).hex() box = nacl.secret.SecretBox(bytes(bytearray.fromhex(sharedKey))) encrypted = box.encrypt(bytes(bytearray.fromhex(unencrypted)), bytes(bytearray.fromhex(self.nonce))).hex()[48:] length = self.byteSwapper.swap("%04X" % int((len(encrypted)/2))) msg = self.nonce + self.authID + length + encrypted if format == 'BYTE_ARRAY': return bytearray.fromhex(msg) else: return msg
class Nuki_Command(object): def __init__(self, payload=""): self.crcCalculator = CrcCalculator() self.byteSwapper = ByteSwapper() self.parser = NukiCommandParser() self.payload = payload def generate(self, format='BYTE_ARRAY'): msg = self.byteSwapper.swap(type(self).command) + self.payload crc = self.byteSwapper.swap(self.crcCalculator.crc_ccitt(msg)) msg = msg + crc if format == 'BYTE_ARRAY': return bytearray.fromhex(msg) else: return msg def isError(self): return type(self) == Nuki_ERROR
class Nuki_Command(object): def __init__(self, payload=""): self.crcCalculator = CrcCalculator() self.byteSwapper = ByteSwapper() self.parser = NukiCommandParser() self.command = '' self.payload = payload def generate(self, format='BYTE_ARRAY'): msg = self.byteSwapper.swap(self.command) + self.payload crc = self.byteSwapper.swap(self.crcCalculator.crc_ccitt(msg)) msg = msg + crc if format == 'BYTE_ARRAY': return array.array('B', msg.decode("hex")) else: return msg def isError(self): return self.command == '0012'
class NukiCommandParser: def __init__(self): self.byteSwapper = ByteSwapper() self.commandList = ['0001','0003','0004','0005','0006','0007','000C','001E','000E','0023','0024','0026','0012'] def isNukiCommand(self, commandString): command = self.byteSwapper.swap(commandString[:4]) return command.upper() in self.commandList def getNukiCommandText(self, command): return { '0001': 'Nuki_REQ', '0003': 'Nuki_PUBLIC_KEY', '0004': 'Nuki_CHALLENGE', '0005': 'Nuki_AUTH_AUTHENTICATOR', '0006': 'Nuki_AUTH_DATA', '0007': 'Nuki_AUTH_ID', '000C': 'Nuki_STATES', '001E': 'Nuki_AUTH_ID_CONFIRM', '000E': 'Nuki_STATUS', '0023': 'Nuki_LOCK_ENTRIES_REQUEST', '0024': 'Nuki_LOG_ENTRY', '0026': 'Nuki_LOG_ENTRY_COUNT', '0012': 'Nuki_ERROR', }.get(command.upper(), 'UNKNOWN') # UNKNOWN is default if command not found def parse(self, commandString): if self.isNukiCommand(commandString): command = self.byteSwapper.swap(commandString[:4]).upper() payload = commandString[4:-4] crc = self.byteSwapper.swap(commandString[-4:]) #print("command = {}, payload = {}, crc = {}" % (command, payload, crc)) if command == '0001': return Nuki_REQ(payload) elif command == '0003': return Nuki_PUBLIC_KEY(payload) elif command == '0004': return Nuki_CHALLENGE(payload) elif command == '0005': return Nuki_AUTH_AUTHENTICATOR(payload) elif command == '0006': return Nuki_AUTH_DATA(payload) elif command == '0007': return Nuki_AUTH_ID(payload) elif command == '000C': return Nuki_STATES(payload) elif command == '001E': return Nuki_AUTH_ID_CONFIRM(payload) elif command == '000E': return Nuki_STATUS(payload) elif command == '0023': return Nuki_LOG_ENTRIES_REQUEST(payload) elif command == '0024': return Nuki_LOG_ENTRY(payload) elif command == '0026': return Nuki_LOG_ENTRY_COUNT(payload) elif command == '0012': return Nuki_ERROR(payload) else: return "%s does not seem to be a valid Nuki command" % commandString def splitEncryptedMessages(self, msg): msgList = [] offset = 0 while offset < len(msg): nonce = msg[offset:offset+48] authID = msg[offset+48:offset+56] length = int(self.byteSwapper.swap(msg[offset+56:offset+60]), 16) singleMsg = msg[offset:offset+60+(length*2)] msgList.append(singleMsg) offset = offset+60+(length*2) return msgList def decrypt(self, msg, publicKey, privateKey): print("msg: %s" % msg) nonce = msg[:48] #print "nonce: %s" % nonce authID = msg[48:56] #print "authID: %s" % authID length = int(self.byteSwapper.swap(msg[56:60]), 16) #print "length: %d" % length encrypted = nonce + msg[60:60+(length*2)] #print "encrypted: %s" % encrypted sharedKey = crypto_box_beforenm(bytes(bytearray.fromhex(publicKey)),bytes(bytearray.fromhex(privateKey))).hex() box = nacl.secret.SecretBox(bytes(bytearray.fromhex(sharedKey))) decrypted = box.decrypt(bytes(bytearray.fromhex(encrypted))).hex() #print "decrypted: %s" % decrypted return decrypted
class Nuki(): # creates BLE connection with NUKI # -macAddress: bluetooth mac-address of your Nuki Lock def __init__(self, macAddress, cfg='/home/pi/nuki/nuki.cfg'): self._charWriteResponse = "" self.parser = nuki_messages.NukiCommandParser() self.crcCalculator = CrcCalculator() self.byteSwapper = ByteSwapper() self.macAddress = macAddress self.config = ConfigParser.RawConfigParser() self.config.read(cfg) self.device = None def _makeBLEConnection(self): if self.device == None: adapter = pygatt.backends.GATTToolBackend() nukiBleConnectionReady = False while nukiBleConnectionReady == False: print "Starting BLE adapter..." adapter.start() print "Init Nuki BLE connection..." try : self.device = adapter.connect(self.macAddress) nukiBleConnectionReady = True except: print "Unable to connect, retrying..." print "Nuki BLE connection established" def isNewNukiStateAvailable(self): if self.device != None: self.device.disconnect() self.device = None dev_id = 0 try: sock = bluez.hci_open_dev(dev_id) except: print "error accessing bluetooth device..." sys.exit(1) blescan.hci_le_set_scan_parameters(sock) blescan.hci_enable_le_scan(sock) returnedList = blescan.parse_events(sock, 10) newStateAvailable = -1 print "isNewNukiStateAvailable() -> search through %d received beacons..." % len(returnedList) for beacon in returnedList: beaconElements = beacon.split(',') if beaconElements[0] == self.macAddress.lower() and beaconElements[1] == "a92ee200550111e4916c0800200c9a66": print "Nuki beacon found, new state element: %s" % beaconElements[4] if beaconElements[4] == '-60': newStateAvailable = 0 else: newStateAvailable = 1 break else: print "non-Nuki beacon found: mac=%s, signature=%s" % (beaconElements[0],beaconElements[1]) print "isNewNukiStateAvailable() -> result=%d" % newStateAvailable return newStateAvailable # private method to handle responses coming back from the Nuki Lock over the BLE connection def _handleCharWriteResponse(self, handle, value): self._charWriteResponse += "".join(format(x, '02x') for x in value) # method to authenticate yourself (only needed the very first time) to the Nuki Lock # -publicKeyHex: a public key (as hex string) you created to talk with the Nuki Lock # -privateKeyHex: a private key (complementing the public key, described above) you created to talk with the Nuki Lock # -ID : a unique number to identify yourself to the Nuki Lock # -IDType : '00' for 'app', '01' for 'bridge' and '02' for 'fob' # -name : a unique name to identify yourself to the Nuki Lock (will also appear in the logs of the Nuki Lock) def authenticateUser(self, publicKeyHex, privateKeyHex, ID, IDType, name): self._makeBLEConnection() self.config.remove_section(self.macAddress) self.config.add_section(self.macAddress) pairingHandle = self.device.get_handle('a92ee101-5501-11e4-916c-0800200c9a66') print "Nuki Pairing UUID handle created: %04x" % pairingHandle publicKeyReq = nuki_messages.Nuki_REQ('0003') self.device.subscribe('a92ee101-5501-11e4-916c-0800200c9a66', self._handleCharWriteResponse, indication=True)) publicKeyReqCommand = publicKeyReq.generate() self._charWriteResponse = "" print "Requesting Nuki Public Key using command: %s" % publicKeyReq.show() self.device.char_write_handle(pairingHandle,publicKeyReqCommand,True,2) print "Nuki Public key requested" time.sleep(2) commandParsed = self.parser.parse(self._charWriteResponse) if self.parser.isNukiCommand(self._charWriteResponse) == False: sys.exit("Error while requesting public key: %s" % commandParsed) if commandParsed.command != '0003': sys.exit("Nuki returned unexpected response (expecting PUBLIC_KEY): %s" % commandParsed.show()) publicKeyNuki = commandParsed.publicKey self.config.set(self.macAddress,'publicKeyNuki',publicKeyNuki) self.config.set(self.macAddress,'publicKeyHex',publicKeyHex) self.config.set(self.macAddress,'privateKeyHex',privateKeyHex) self.config.set(self.macAddress,'ID',ID) self.config.set(self.macAddress,'IDType',IDType) self.config.set(self.macAddress,'Name',name) print "Public key received: %s" % commandParsed.publicKey publicKeyPush = nuki_messages.Nuki_PUBLIC_KEY(publicKeyHex) publicKeyPushCommand = publicKeyPush.generate() print "Pushing Public Key using command: %s" % publicKeyPush.show() self._charWriteResponse = "" self.device.char_write_handle(pairingHandle,publicKeyPushCommand,True,5) print "Public key pushed" time.sleep(2) commandParsed = self.parser.parse(self._charWriteResponse) if self.parser.isNukiCommand(self._charWriteResponse) == False: sys.exit("Error while pushing public key: %s" % commandParsed) if commandParsed.command != '0004': sys.exit("Nuki returned unexpected response (expecting CHALLENGE): %s" % commandParsed.show()) print "Challenge received: %s" % commandParsed.nonce nonceNuki = commandParsed.nonce authAuthenticator = nuki_messages.Nuki_AUTH_AUTHENTICATOR() authAuthenticator.createPayload(nonceNuki, privateKeyHex, publicKeyHex, publicKeyNuki) authAuthenticatorCommand = authAuthenticator.generate() self._charWriteResponse = "" self.device.char_write_handle(pairingHandle,authAuthenticatorCommand,True,5) print "Authorization Authenticator sent: %s" % authAuthenticator.show() time.sleep(2) commandParsed = self.parser.parse(self._charWriteResponse) if self.parser.isNukiCommand(self._charWriteResponse) == False: sys.exit("Error while sending Authorization Authenticator: %s" % commandParsed) if commandParsed.command != '0004': sys.exit("Nuki returned unexpected response (expecting CHALLENGE): %s" % commandParsed.show()) print "Challenge received: %s" % commandParsed.nonce nonceNuki = commandParsed.nonce authData = nuki_messages.Nuki_AUTH_DATA() authData.createPayload(publicKeyNuki, privateKeyHex, publicKeyHex, nonceNuki, ID, IDType, name) authDataCommand = authData.generate() self._charWriteResponse = "" self.device.char_write_handle(pairingHandle,authDataCommand,True,7) print "Authorization Data sent: %s" % authData.show() time.sleep(2) commandParsed = self.parser.parse(self._charWriteResponse) if self.parser.isNukiCommand(self._charWriteResponse) == False: sys.exit("Error while sending Authorization Data: %s" % commandParsed) if commandParsed.command != '0007': sys.exit("Nuki returned unexpected response (expecting AUTH_ID): %s" % commandParsed.show()) print "Authorization ID received: %s" % commandParsed.show() nonceNuki = commandParsed.nonce authorizationID = commandParsed.authID self.config.set(self.macAddress,'authorizationID',authorizationID) authId = int(commandParsed.authID,16) authIDConfirm = nuki_messages.Nuki_AUTH_ID_CONFIRM() authIDConfirm.createPayload(publicKeyNuki, privateKeyHex, publicKeyHex, nonceNuki, authId) authIDConfirmCommand = authIDConfirm.generate() self._charWriteResponse = "" self.device.char_write_handle(pairingHandle,authIDConfirmCommand,True,7) print "Authorization ID Confirmation sent: %s" % authIDConfirm.show() time.sleep(2) commandParsed = self.parser.parse(self._charWriteResponse) if self.parser.isNukiCommand(self._charWriteResponse) == False: sys.exit("Error while sending Authorization ID Confirmation: %s" % commandParsed) if commandParsed.command != '000E': sys.exit("Nuki returned unexpected response (expecting STATUS): %s" % commandParsed.show()) print "STATUS received: %s" % commandParsed.status with open('/home/pi/nuki/nuki.cfg', 'wb') as configfile: self.config.write(configfile) return commandParsed.status # method to read the current lock state of the Nuki Lock def readLockState(self): self._makeBLEConnection() keyturnerUSDIOHandle = self.device.get_handle("a92ee202-5501-11e4-916c-0800200c9a66") self.device.subscribe('a92ee202-5501-11e4-916c-0800200c9a66', self._handleCharWriteResponse, indication=True)) stateReq = nuki_messages.Nuki_REQ('000C') stateReqEncrypted = nuki_messages.Nuki_EncryptedCommand(authID=self.config.get(self.macAddress, 'authorizationID'), nukiCommand=stateReq, publicKey=self.config.get(self.macAddress, 'publicKeyNuki'), privateKey=self.config.get(self.macAddress, 'privateKeyHex')) stateReqEncryptedCommand = stateReqEncrypted.generate() self._charWriteResponse = "" self.device.char_write_handle(keyturnerUSDIOHandle,stateReqEncryptedCommand,True,3) print "Nuki State Request sent: %s\nresponse received: %s" % (stateReq.show(),self._charWriteResponse) time.sleep(2) commandParsed = self.parser.decrypt(self._charWriteResponse,self.config.get(self.macAddress, 'publicKeyNuki'),self.config.get(self.macAddress, 'privateKeyHex'))[8:] if self.parser.isNukiCommand(commandParsed) == False: sys.exit("Error while requesting Nuki STATES: %s" % commandParsed) commandParsed = self.parser.parse(commandParsed) if commandParsed.command != '000C': sys.exit("Nuki returned unexpected response (expecting Nuki STATES): %s" % commandParsed.show()) print "%s" % commandParsed.show() return commandParsed # method to perform a lock action on the Nuki Lock: # -lockAction: 'UNLOCK', 'LOCK', 'UNLATCH', 'LOCKNGO', 'LOCKNGO_UNLATCH', 'FOB_ACTION_1', 'FOB_ACTION_2' or 'FOB_ACTION_3' def lockAction(self,lockAction): self._makeBLEConnection() keyturnerUSDIOHandle = self.device.get_handle("a92ee202-5501-11e4-916c-0800200c9a66") self.device.subscribe('a92ee202-5501-11e4-916c-0800200c9a66', self._handleCharWriteResponse, indication=True)) challengeReq = nuki_messages.Nuki_REQ('0004') challengeReqEncrypted = nuki_messages.Nuki_EncryptedCommand(authID=self.config.get(self.macAddress, 'authorizationID'), nukiCommand=challengeReq, publicKey=self.config.get(self.macAddress, 'publicKeyNuki'), privateKey=self.config.get(self.macAddress, 'privateKeyHex')) challengeReqEncryptedCommand = challengeReqEncrypted.generate() self._charWriteResponse = "" self.device.char_write_handle(keyturnerUSDIOHandle,challengeReqEncryptedCommand,True,4) print "Nuki CHALLENGE Request sent: %s" % challengeReq.show() time.sleep(2) commandParsed = self.parser.decrypt(self._charWriteResponse,self.config.get(self.macAddress, 'publicKeyNuki'),self.config.get(self.macAddress, 'privateKeyHex'))[8:] if self.parser.isNukiCommand(commandParsed) == False: sys.exit("Error while requesting Nuki CHALLENGE: %s" % commandParsed) commandParsed = self.parser.parse(commandParsed) if commandParsed.command != '0004': sys.exit("Nuki returned unexpected response (expecting Nuki CHALLENGE): %s" % commandParsed.show()) print "Challenge received: %s" % commandParsed.nonce lockActionReq = nuki_messages.Nuki_LOCK_ACTION() lockActionReq.createPayload(self.config.getint(self.macAddress, 'ID'), lockAction, commandParsed.nonce) lockActionReqEncrypted = nuki_messages.Nuki_EncryptedCommand(authID=self.config.get(self.macAddress, 'authorizationID'), nukiCommand=lockActionReq, publicKey=self.config.get(self.macAddress, 'publicKeyNuki'), privateKey=self.config.get(self.macAddress, 'privateKeyHex')) lockActionReqEncryptedCommand = lockActionReqEncrypted.generate() self._charWriteResponse = "" self.device.char_write_handle(keyturnerUSDIOHandle,lockActionReqEncryptedCommand,True,4) print "Nuki Lock Action Request sent: %s" % lockActionReq.show() time.sleep(2) commandParsed = self.parser.decrypt(self._charWriteResponse,self.config.get(self.macAddress, 'publicKeyNuki'),self.config.get(self.macAddress, 'privateKeyHex'))[8:] if self.parser.isNukiCommand(commandParsed) == False: sys.exit("Error while requesting Nuki Lock Action: %s" % commandParsed) commandParsed = self.parser.parse(commandParsed) if commandParsed.command != '000C' and commandParsed.command != '000E': sys.exit("Nuki returned unexpected response (expecting Nuki STATUS/STATES): %s" % commandParsed.show()) print "%s" % commandParsed.show() # method to fetch the number of log entries from your Nuki Lock # -pinHex : a 2-byte hex string representation of the PIN code you have set on your Nuki Lock (default is 0000) def getLogEntriesCount(self, pinHex): self._makeBLEConnection() keyturnerUSDIOHandle = self.device.get_handle("a92ee202-5501-11e4-916c-0800200c9a66") self.device.subscribe('a92ee202-5501-11e4-916c-0800200c9a66', self._handleCharWriteResponse, indication=True)) challengeReq = nuki_messages.Nuki_REQ('0004') challengeReqEncrypted = nuki_messages.Nuki_EncryptedCommand(authID=self.config.get(self.macAddress, 'authorizationID'), nukiCommand=challengeReq, publicKey=self.config.get(self.macAddress, 'publicKeyNuki'), privateKey=self.config.get(self.macAddress, 'privateKeyHex')) challengeReqEncryptedCommand = challengeReqEncrypted.generate() self._charWriteResponse = "" print "Requesting CHALLENGE: %s" % challengeReqEncrypted.generate("HEX") self.device.char_write_handle(keyturnerUSDIOHandle,challengeReqEncryptedCommand,True,5) print "Nuki CHALLENGE Request sent: %s" % challengeReq.show() time.sleep(2) commandParsed = self.parser.decrypt(self._charWriteResponse,self.config.get(self.macAddress, 'publicKeyNuki'),self.config.get(self.macAddress, 'privateKeyHex'))[8:] if self.parser.isNukiCommand(commandParsed) == False: sys.exit("Error while requesting Nuki CHALLENGE: %s" % commandParsed) commandParsed = self.parser.parse(commandParsed) if commandParsed.command != '0004': sys.exit("Nuki returned unexpected response (expecting Nuki CHALLENGE): %s" % commandParsed.show()) print "Challenge received: %s" % commandParsed.nonce logEntriesReq = nuki_messages.Nuki_LOG_ENTRIES_REQUEST() logEntriesReq.createPayload(0, commandParsed.nonce, self.byteSwapper.swap(pinHex)) logEntriesReqEncrypted = nuki_messages.Nuki_EncryptedCommand(authID=self.config.get(self.macAddress, 'authorizationID'), nukiCommand=logEntriesReq, publicKey=self.config.get(self.macAddress, 'publicKeyNuki'), privateKey=self.config.get(self.macAddress, 'privateKeyHex')) logEntriesReqEncryptedCommand = logEntriesReqEncrypted.generate() self._charWriteResponse = "" self.device.char_write_handle(keyturnerUSDIOHandle,logEntriesReqEncryptedCommand,True,4) print "Nuki Log Entries Request sent: %s" % logEntriesReq.show() time.sleep(2) commandParsed = self.parser.decrypt(self._charWriteResponse,self.config.get(self.macAddress, 'publicKeyNuki'),self.config.get(self.macAddress, 'privateKeyHex'))[8:] if self.parser.isNukiCommand(commandParsed) == False: sys.exit("Error while requesting Nuki Log Entries: %s" % commandParsed) commandParsed = self.parser.parse(commandParsed) if commandParsed.command != '0026': sys.exit("Nuki returned unexpected response (expecting Nuki LOG ENTRY): %s" % commandParsed.show()) print "%s" % commandParsed.show() return int(commandParsed.logCount, 16) # method to fetch the most recent log entries from your Nuki Lock # -count: the number of entries you would like to fetch (if available) # -pinHex : a 2-byte hex string representation of the PIN code you have set on your Nuki Lock (default is 0000) def getLogEntries(self,count,pinHex): self._makeBLEConnection() keyturnerUSDIOHandle = self.device.get_handle("a92ee202-5501-11e4-916c-0800200c9a66") self.device.subscribe('a92ee202-5501-11e4-916c-0800200c9a66', self._handleCharWriteResponse, indication=True)) challengeReq = nuki_messages.Nuki_REQ('0004') challengeReqEncrypted = nuki_messages.Nuki_EncryptedCommand(authID=self.config.get(self.macAddress, 'authorizationID'), nukiCommand=challengeReq, publicKey=self.config.get(self.macAddress, 'publicKeyNuki'), privateKey=self.config.get(self.macAddress, 'privateKeyHex')) challengeReqEncryptedCommand = challengeReqEncrypted.generate() print "Requesting CHALLENGE: %s" % challengeReqEncrypted.generate("HEX") self._charWriteResponse = "" self.device.char_write_handle(keyturnerUSDIOHandle,challengeReqEncryptedCommand,True,5) print "Nuki CHALLENGE Request sent: %s" % challengeReq.show() time.sleep(2) commandParsed = self.parser.decrypt(self._charWriteResponse,self.config.get(self.macAddress, 'publicKeyNuki'),self.config.get(self.macAddress, 'privateKeyHex'))[8:] if self.parser.isNukiCommand(commandParsed) == False: sys.exit("Error while requesting Nuki CHALLENGE: %s" % commandParsed) commandParsed = self.parser.parse(commandParsed) if commandParsed.command != '0004': sys.exit("Nuki returned unexpected response (expecting Nuki CHALLENGE): %s" % commandParsed.show()) print "Challenge received: %s" % commandParsed.nonce logEntriesReq = nuki_messages.Nuki_LOG_ENTRIES_REQUEST() logEntriesReq.createPayload(count, commandParsed.nonce, self.byteSwapper.swap(pinHex)) logEntriesReqEncrypted = nuki_messages.Nuki_EncryptedCommand(authID=self.config.get(self.macAddress, 'authorizationID'), nukiCommand=logEntriesReq, publicKey=self.config.get(self.macAddress, 'publicKeyNuki'), privateKey=self.config.get(self.macAddress, 'privateKeyHex')) logEntriesReqEncryptedCommand = logEntriesReqEncrypted.generate() self._charWriteResponse = "" self.device.char_write_handle(keyturnerUSDIOHandle,logEntriesReqEncryptedCommand,True,6) print "Nuki Log Entries Request sent: %s" % logEntriesReq.show() time.sleep(2) messages = self.parser.splitEncryptedMessages(self._charWriteResponse) print "Received %d messages" % len(messages) logMessages = [] for message in messages: print "Decrypting message %s" % message try: commandParsed = self.parser.decrypt(message,self.config.get(self.macAddress, 'publicKeyNuki'),self.config.get(self.macAddress, 'privateKeyHex'))[8:] if self.parser.isNukiCommand(commandParsed) == False: sys.exit("Error while requesting Nuki Log Entries: %s" % commandParsed) commandParsed = self.parser.parse(commandParsed) if commandParsed.command != '0024' and commandParsed.command != '0026' and commandParsed.command != '000E': sys.exit("Nuki returned unexpected response (expecting Nuki LOG ENTRY): %s" % commandParsed.show()) print "%s" % commandParsed.show() if commandParsed.command == '0024': logMessages.append(commandParsed) except: print "Unable to decrypt message" return logMessages
class NukiCommandParser: def __init__(self): self.byteSwapper = ByteSwapper() def isNukiCommand(self, commandString): command = self.byteSwapper.swap(commandString[:4]) return command.upper() in NukiCommandText def getNukiCommandText(self, command): return NukiCommandText.get( command.upper(), 'UNKNOWN').__name__ # UNKNOWN is default if command not found def parse(self, commandString): if self.isNukiCommand(commandString): command = self.byteSwapper.swap(commandString[:4]).upper() payload = commandString[4:-4] crc = self.byteSwapper.swap(commandString[-4:]) #print(f"command = {command}, payload = {payload}, crc = {crc}") commandClass = NukiCommandText[command] if commandClass: return commandClass(payload) # pucgenie: check if the following thing changed since cleanup... if command == '0001': return Nuki_REQ(payload) elif command == '0003': return Nuki_PUBLIC_KEY(payload) elif command == '0004': return Nuki_CHALLENGE(payload) elif command == '0005': return Nuki_AUTH_AUTHENTICATOR(payload) elif command == '0006': return Nuki_AUTH_DATA(payload) elif command == '0007': return Nuki_AUTH_ID(payload) elif command == '000C': return Nuki_STATES(payload) elif command == '001E': return Nuki_AUTH_ID_CONFIRM(payload) elif command == '000E': return Nuki_STATUS(payload) elif command == '0023': return Nuki_LOG_ENTRIES_REQUEST(payload) elif command == '0024': return Nuki_LOG_ENTRY(payload) elif command == '0026': return Nuki_LOG_ENTRY_COUNT(payload) elif command == '0012': return Nuki_ERROR(payload) else: return f"{commandString} does not seem to be a valid Nuki command" def splitEncryptedMessages(self, msg): msgList = [] offset = 0 while offset < len(msg): nonce = msg[offset:offset + 48] authID = msg[offset + 48:offset + 56] length = int(self.byteSwapper.swap(msg[offset + 56:offset + 60]), 16) singleMsg = msg[offset:offset + 60 + (length * 2)] msgList.append(singleMsg) offset = offset + 60 + (length * 2) return msgList def decrypt(self, msg, publicKey, privateKey): print("msg: %s" % msg) nonce = msg[:48] #print "nonce: %s" % nonce authID = msg[48:56] #print "authID: %s" % authID length = int(self.byteSwapper.swap(msg[56:60]), 16) #print "length: %d" % length encrypted = nonce + msg[60:60 + (length * 2)] #print "encrypted: %s" % encrypted sharedKey = crypto_box_beforenm( bytes(publicKey), bytes(bytearray.fromhex(privateKey))).hex() box = nacl.secret.SecretBox(bytes(bytearray.fromhex(sharedKey))) decrypted = box.decrypt(bytes(bytearray.fromhex(encrypted))).hex() #print "decrypted: %s" % decrypted return decrypted
class Nuki(): # creates BLE connection with NUKI # -macAddress: bluetooth mac-address of your Nuki Lock def __init__(self, macAddress, cfg): self._charWriteResponse = "" self.parser = nuki_messages.NukiCommandParser() self.crcCalculator = CrcCalculator() self.byteSwapper = ByteSwapper() self.macAddress = macAddress self.config = configparser.RawConfigParser() self.config.read(cfg) self.configfile = cfg self.device = None def _makeBLEConnection(self, retries=3): if self.device == None: currentTries = 0 adapter = pygatt.backends.GATTToolBackend() nukiBleConnectionReady = False while (nukiBleConnectionReady == False and currentTries < retries): print("Starting BLE adapter...") adapter.start() print("Init Nuki BLE connection...") try: self.device = adapter.connect(self.macAddress) nukiBleConnectionReady = True except: currentTries += 1 print("Unable to connect, retrying..., retry count: " + str(currentTries)) if self.device == None: print("Could not connect after " + str(currentTries) + " tries") else: print("Nuki BLE connection established") def isNewNukiStateAvailable(self): if self.device != None: self.device.disconnect() self.device = None dev_id = 0 try: sock = bluez.hci_open_dev(dev_id) except: print("error accessing bluetooth device...") sys.exit(1) blescan.hci_le_set_scan_parameters(sock) blescan.hci_enable_le_scan(sock) returnedList = blescan.parse_events(sock, 10) newStateAvailable = -1 print( "isNewNukiStateAvailable() -> search through %d received beacons..." % len(returnedList)) for beacon in returnedList: beaconElements = beacon.split(',') if beaconElements[0] == self.macAddress.lower( ) and beaconElements[1] == "a92ee200550111e4916c0800200c9a66": print("Nuki beacon found, new state element: %s" % beaconElements[4]) if beaconElements[4] == '-60': newStateAvailable = 0 else: newStateAvailable = 1 break else: print( f"non-Nuki beacon found: mac={beaconElements[0]}, signature={beaconElements[1]}" ) print(f"isNewNukiStateAvailable() -> result={newStateAvailable}") return newStateAvailable # private method to handle responses coming back from the Nuki Lock over the BLE connection def _handleCharWriteResponse(self, handle, value): self._charWriteResponse += "".join(format(x, '02x') for x in value) # method to authenticate yourself (only needed the very first time) to the Nuki Lock # -publicKeyHex: a public key (as hex string) you created to talk with the Nuki Lock # -privateKeyHex: a private key (complementing the public key, described above) you created to talk with the Nuki Lock # -ID : a unique number to identify yourself to the Nuki Lock # -IDType : '00' for 'app', '01' for 'bridge' and '02' for 'fob' # -name : a unique name to identify yourself to the Nuki Lock (will also appear in the logs of the Nuki Lock) def authenticateUser(self, publicKey, privateKeyHex, ID, IDType, name): self._makeBLEConnection() if self.device == None: return self.config.remove_section(self.macAddress) self.config.add_section(self.macAddress) pairingHandle = self.device.get_handle(DEVICE_HANDLEID1) print("Nuki Pairing UUID handle created: %04x" % pairingHandle) publicKeyReq = nuki_messages.Nuki_REQ( nuki_messages.Nuki_PUBLIC_KEY.command) self.device.subscribe(DEVICE_HANDLEID1, self._handleCharWriteResponse, indication=True) publicKeyReqCommand = publicKeyReq.generate() self._charWriteResponse = "" print( f"Requesting Nuki Public Key using command: {publicKeyReq.show()}") self.device.char_write_handle(pairingHandle, publicKeyReqCommand, True, 2) print("Nuki Public key requested") # wtf time.sleep(2) commandParsed = self.parser.parse(self._charWriteResponse) if self.parser.isNukiCommand(self._charWriteResponse) == False: sys.exit(f"Error while requesting public key: {commandParsed}") if type(commandParsed) != nuki_messages.Nuki_PUBLIC_KEY: sys.exit( f"Nuki returned unexpected response (expecting PUBLIC_KEY): {commandParsed.show()}" ) publicKeyNuki = commandParsed.publicKey self.config.set(self.macAddress, 'publicKeyNuki', publicKeyNuki) self.config.set(self.macAddress, 'publicKeyHex', publicKey.hex()) self.config.set(self.macAddress, 'privateKeyHex', privateKeyHex) self.config.set(self.macAddress, 'ID', ID) self.config.set(self.macAddress, 'IDType', IDType) self.config.set(self.macAddress, 'Name', name) print(f"Public key received: {commandParsed.publicKey}") publicKeyPush = nuki_messages.Nuki_PUBLIC_KEY(publicKey) publicKeyPushCommand = publicKeyPush.generate() print(f"Pushing Public Key using command: {publicKeyPush.show()}") self._charWriteResponse = "" self.device.char_write_handle(pairingHandle, publicKeyPushCommand, True, 5) print("Public key pushed") time.sleep(2) commandParsed = self.parser.parse(self._charWriteResponse) if self.parser.isNukiCommand(self._charWriteResponse) == False: sys.exit(f"Error while pushing public key: {commandParsed}") if type(commandParsed) != nuki_messages.Nuki_CHALLENGE: sys.exit( f"Nuki returned unexpected response (expecting CHALLENGE): {commandParsed.show()}" ) print(f"Challenge received: {commandParsed.nonce}") nonceNuki = commandParsed.nonce authAuthenticator = nuki_messages.Nuki_AUTH_AUTHENTICATOR() authAuthenticator.createPayload(nonceNuki, privateKeyHex, publicKey, publicKeyNuki) authAuthenticatorCommand = authAuthenticator.generate() self._charWriteResponse = "" self.device.char_write_handle(pairingHandle, authAuthenticatorCommand, True, 5) print(f"Authorization Authenticator sent: {authAuthenticator.show()}") time.sleep(2) commandParsed = self.parser.parse(self._charWriteResponse) if self.parser.isNukiCommand(self._charWriteResponse) == False: sys.exit( f"Error while sending Authorization Authenticator: {commandParsed}" ) if type(commandParsed) != nuki_messages.Nuki_CHALLENGE: sys.exit( f"Nuki returned unexpected response (expecting CHALLENGE): {commandParsed.show()}" ) print(f"Challenge received: {commandParsed.nonce}") nonceNuki = commandParsed.nonce authData = nuki_messages.Nuki_AUTH_DATA() authData.createPayload(publicKeyNuki, privateKeyHex, nonceNuki, ID, IDType, name) authDataCommand = authData.generate() self._charWriteResponse = "" self.device.char_write_handle(pairingHandle, authDataCommand, True, 7) print(f"Authorization Data sent: {authData.show()}") time.sleep(2) commandParsed = self.parser.parse(self._charWriteResponse) if self.parser.isNukiCommand(self._charWriteResponse) == False: sys.exit( f"Error while sending Authorization Data: {commandParsed}") if type(commandParsed) != nuki_messages.Nuki_AUTH_ID: sys.exit( f"Nuki returned unexpected response (expecting AUTH_ID): {commandParsed.show()}" ) print(f"Authorization ID received: {commandParsed.show()}") nonceNuki = commandParsed.nonce authorizationID = commandParsed.authID self.config.set(self.macAddress, 'authorizationID', authorizationID) authId = int(commandParsed.authID, 16) authIDConfirm = nuki_messages.Nuki_AUTH_ID_CONFIRM() authIDConfirm.createPayload(publicKeyNuki, privateKeyHex, nonceNuki, authId) authIDConfirmCommand = authIDConfirm.generate() self._charWriteResponse = "" self.device.char_write_handle(pairingHandle, authIDConfirmCommand, True, 7) print(f"Authorization ID Confirmation sent: {authIDConfirm.show()}") time.sleep(2) commandParsed = self.parser.parse(self._charWriteResponse) if self.parser.isNukiCommand(self._charWriteResponse) == False: sys.exit( f"Error while sending Authorization ID Confirmation: {commandParsed}" ) if commandParsed.command != '000E': sys.exit( f"Nuki returned unexpected response (expecting STATUS): {commandParsed.show()}" ) print(f"STATUS received: {commandParsed.status}") with open(self.configfile, 'w') as configfile: self.config.write(configfile) return commandParsed.status # method to read the current lock state of the Nuki Lock def readLockState(self): self._makeBLEConnection() if self.device == None: return keyturnerUSDIOHandle = self.getHandle() self.executeChallenge('000C', keyturnerUSDIOHandle) commandParsed = self.parseChallengeResponse('000C') return commandParsed # method to perform a lock action on the Nuki Lock: # -lockAction: 'UNLOCK', 'LOCK', 'UNLATCH', 'LOCKNGO', 'LOCKNGO_UNLATCH', 'FOB_ACTION_1', 'FOB_ACTION_2' or 'FOB_ACTION_3' def lockAction(self, lockAction): epoch_time = int(time.time()) self._makeBLEConnection() if self.device == None: return keyturnerUSDIOHandle = self.getHandle() self.executeChallenge('0004', keyturnerUSDIOHandle) commandParsed = self.parseChallengeResponse('0004') self.executeLockAction(keyturnerUSDIOHandle, lockAction, commandParsed) response = self.checkLockActionResponse() print("Done in {} seconds".format((int(time.time()) - epoch_time))) return response @retry(Exception, tries=8, delay=0.5) def getHandle(self): print("Retrieving handle") keyturnerUSDIOHandle = self.device.get_handle( "a92ee202-5501-11e4-916c-0800200c9a66") print("Handle retrieved") self.device.subscribe('a92ee202-5501-11e4-916c-0800200c9a66', self._handleCharWriteResponse, indication=True) print("Subscribed to device") return keyturnerUSDIOHandle @retry(Exception, tries=8, delay=0.5) def executeChallenge(self, request, keyturnerUSDIOHandle): print("Going to execute challenge") challengeReq = nuki_messages.Nuki_REQ(request) challengeReqEncrypted = nuki_messages.Nuki_EncryptedCommand( authID=self.config.get(self.macAddress, 'authorizationID'), nukiCommand=challengeReq, publicKey=self.config.get(self.macAddress, 'publicKeyNuki'), privateKey=self.config.get(self.macAddress, 'privateKeyHex')) challengeReqEncryptedCommand = challengeReqEncrypted.generate() self._charWriteResponse = "" self.device.char_write_handle(keyturnerUSDIOHandle, challengeReqEncryptedCommand, True, 4) print("Nuki CHALLENGE Request sent: %s" % challengeReq.show()) @retry(Exception, tries=8, delay=0.5) def parseChallengeResponse(self, request): commandParsed = self.parser.decrypt( self._charWriteResponse, self.config.get(self.macAddress, 'publicKeyNuki'), self.config.get(self.macAddress, 'privateKeyHex'))[8:] if self.parser.isNukiCommand(commandParsed) == False: raise Exception("Error while checking challenge response") commandParsed = self.parser.parse(commandParsed) if commandParsed.command != request: raise Exception("Parsed command is not equal to the request") return commandParsed @retry(Exception, tries=8, delay=0.5) def executeLockAction(self, keyturnerUSDIOHandle, lockAction, commandParsed): lockActionReq = nuki_messages.Nuki_LOCK_ACTION() lockActionReq.createPayload(self.config.getint(self.macAddress, 'ID'), lockAction, commandParsed.nonce) lockActionReqEncrypted = nuki_messages.Nuki_EncryptedCommand( authID=self.config.get(self.macAddress, 'authorizationID'), nukiCommand=lockActionReq, publicKey=self.config.get(self.macAddress, 'publicKeyNuki'), privateKey=self.config.get(self.macAddress, 'privateKeyHex'), ) lockActionReqEncryptedCommand = lockActionReqEncrypted.generate() self._charWriteResponse = "" self.device.char_write_handle(keyturnerUSDIOHandle, lockActionReqEncryptedCommand, True, 4) print("Nuki Lock Action Request sent: %s" % lockActionReq.show()) @retry(Exception, tries=8, delay=0.5) def checkLockActionResponse(self): commandParsed = self.parser.decrypt( self._charWriteResponse, self.config.get(self.macAddress, 'publicKeyNuki'), self.config.get(self.macAddress, 'privateKeyHex'))[8:] if self.parser.isNukiCommand(commandParsed) == False: raise Exception("Error while request lock action") return self.parser.parse(commandParsed) # method to fetch the number of log entries from your Nuki Lock # -pinHex : a 2-byte hex string representation of the PIN code you have set on your Nuki Lock (default is 0000) def getLogEntriesCount(self, pinHex): self._makeBLEConnection() keyturnerUSDIOHandle = self.device.get_handle(DEVICE_HANDLEID2) self.device.subscribe(DEVICE_HANDLEID2, self._handleCharWriteResponse, indication=True) challengeReq = nuki_messages.Nuki_REQ( nuki_messages.Nuki_CHALLENGE.command) challengeReqEncrypted = nuki_messages.Nuki_EncryptedCommand( authID=self.config.get(self.macAddress, 'authorizationID'), nukiCommand=challengeReq, publicKey=self.config.get(self.macAddress, 'publicKeyNuki'), privateKey=self.config.get(self.macAddress, 'privateKeyHex')) challengeReqEncryptedCommand = challengeReqEncrypted.generate() self._charWriteResponse = "" print(f"Requesting CHALLENGE: {challengeReqEncrypted.generate('HEX')}") self.device.char_write_handle(keyturnerUSDIOHandle, challengeReqEncryptedCommand, True, 5) print("Nuki CHALLENGE Request sent: %s" % challengeReq.show()) # time.sleep(2) commandParsed = self.parser.decrypt( self._charWriteResponse, self.config.get(self.macAddress, 'publicKeyNuki'), self.config.get(self.macAddress, 'privateKeyHex'))[8:] if self.parser.isNukiCommand(commandParsed) == False: sys.exit(f"Error while requesting Nuki CHALLENGE: {commandParsed}") commandParsed = self.parser.parse(commandParsed) if type(commandParsed) != nuki_messages.Nuki_CHALLENGE: sys.exit( f"Nuki returned unexpected response (expecting Nuki CHALLENGE): {commandParsed.show()}" ) print(f"Challenge received: {commandParsed.nonce}") logEntriesReq = nuki_messages.Nuki_LOG_ENTRIES_REQUEST() logEntriesReq.createPayload(0, commandParsed.nonce, self.byteSwapper.swap(pinHex)) logEntriesReqEncrypted = nuki_messages.Nuki_EncryptedCommand( authID=self.config.get(self.macAddress, 'authorizationID'), nukiCommand=logEntriesReq, publicKey=self.config.get(self.macAddress, 'publicKeyNuki'), privateKey=self.config.get(self.macAddress, 'privateKeyHex'), ) logEntriesReqEncryptedCommand = logEntriesReqEncrypted.generate() self._charWriteResponse = "" self.device.char_write_handle(keyturnerUSDIOHandle, logEntriesReqEncryptedCommand, True, 4) print(f"Nuki Log Entries Request sent: {logEntriesReq.show()}") # time.sleep(2) commandParsed = self.parser.decrypt( self._charWriteResponse, self.config.get(self.macAddress, 'publicKeyNuki'), self.config.get(self.macAddress, 'privateKeyHex'))[8:] if self.parser.isNukiCommand(commandParsed) == False: sys.exit( f"Error while requesting Nuki Log Entries: {commandParsed}") commandParsed = self.parser.parse(commandParsed) if type(commandParsed) != nuki_messages.Nuki_LOG_ENTRY_COUNT: sys.exit( f"Nuki returned unexpected response (expecting Nuki LOG ENTRY): {commandParsed.show()}" ) print("%s" % commandParsed.show()) return int(commandParsed.logCount, 16) # method to fetch the most recent log entries from your Nuki Lock # -count: the number of entries you would like to fetch (if available) # -pinHex : a 2-byte hex string representation of the PIN code you have set on your Nuki Lock (default is 0000) def getLogEntries(self, count, pinHex): self._makeBLEConnection() keyturnerUSDIOHandle = self.device.get_handle(DEVICE_HANDLEID2) self.device.subscribe(DEVICE_HANDLEID2, self._handleCharWriteResponse, indication=True) challengeReq = nuki_messages.Nuki_REQ( nuki_messages.Nuki_CHALLENGE.command) challengeReqEncrypted = nuki_messages.Nuki_EncryptedCommand( authID=self.config.get(self.macAddress, 'authorizationID'), nukiCommand=challengeReq, publicKey=self.config.get(self.macAddress, 'publicKeyNuki'), privateKey=self.config.get(self.macAddress, 'privateKeyHex')) challengeReqEncryptedCommand = challengeReqEncrypted.generate() print(f"Requesting CHALLENGE: {challengeReqEncrypted.generate('HEX')}") self._charWriteResponse = "" self.device.char_write_handle(keyturnerUSDIOHandle, challengeReqEncryptedCommand, True, 5) print("Nuki CHALLENGE Request sent: %s" % challengeReq.show()) # time.sleep(2) commandParsed = self.parser.decrypt( self._charWriteResponse, self.config.get(self.macAddress, 'publicKeyNuki'), self.config.get(self.macAddress, 'privateKeyHex'))[8:] if self.parser.isNukiCommand(commandParsed) == False: sys.exit("Error while requesting Nuki CHALLENGE: %s" % commandParsed) commandParsed = self.parser.parse(commandParsed) if type(commandParsed) != nuki_messages.Nuki_CHALLENGE: sys.exit( "Nuki returned unexpected response (expecting Nuki CHALLENGE): %s" % commandParsed.show()) print("Challenge received: %s" % commandParsed.nonce) logEntriesReq = nuki_messages.Nuki_LOG_ENTRIES_REQUEST() logEntriesReq.createPayload(count, commandParsed.nonce, self.byteSwapper.swap(pinHex)) logEntriesReqEncrypted = nuki_messages.Nuki_EncryptedCommand( authID=self.config.get(self.macAddress, 'authorizationID'), nukiCommand=logEntriesReq, publicKey=self.config.get(self.macAddress, 'publicKeyNuki'), privateKey=self.config.get(self.macAddress, 'privateKeyHex')) logEntriesReqEncryptedCommand = logEntriesReqEncrypted.generate() self._charWriteResponse = "" self.device.char_write_handle(keyturnerUSDIOHandle, logEntriesReqEncryptedCommand, True, 6) print("Nuki Log Entries Request sent: %s" % logEntriesReq.show()) # time.sleep(2) messages = self.parser.splitEncryptedMessages(self._charWriteResponse) print("Received %d messages" % len(messages)) logMessages = [] for message in messages: print(f"Decrypting message {message}") try: commandParsed = self.parser.decrypt( message, self.config.get(self.macAddress, 'publicKeyNuki'), self.config.get(self.macAddress, 'privateKeyHex'))[8:] if self.parser.isNukiCommand(commandParsed) == False: sys.exit( f"Error while requesting Nuki Log Entries: {commandParsed}" ) commandParsed = self.parser.parse(commandParsed) if type(commandParsed) not in [ nuki_messages.Nuki_LOG_ENTRY, nuki_messages.Nuki_LOG_ENTRY_COUNT, nuki_messages.Nuki_STATUS ]: sys.exit( "Nuki returned unexpected response (expecting Nuki LOG ENTRY): %s" % commandParsed.show()) print("%s" % commandParsed.show()) if type(commandParsed) == nuki_messages.Nuki_LOG_ENTRY: logMessages.append(commandParsed) except: print("Unable to decrypt message") return logMessages